Розділяй і володарюй. Як працюють процеси в Python

💡 Усі статті, обговорення, новини про Python — в одному місці. Приєднуйтесь до Python спільноти!

«Хороше життя — це процес, а не стан буття. Це напрямок, а не пункт призначення»
Carl Rogers

Привіт усім шукачам істини! Це продовження серії досліджень GIL та його впливу на багатопоточність і багатопроцесність у Python. У попередніх частинах ми вже розібралися, що таке GIL, як він працює, які операції він блокує, а які залишаються поза його владою. Також ми детально дослідили потоки як засіб для паралелізму у випадках, коли основним вузьким місцем є операції вводу-виводу (англ. I/O-bound operations). Тепер настав час поговорити про процеси — фінальний акорд цієї серії. З’ясуємо, як правильно їх застосовувати та в яких сценаріях вони розкривають свій потенціал найкраще.

— Створення процесу
— Як обрати кількість процесів
— Взаємодія та обмін даними
— Черги повідомлень
— Пайпи
— Спільна пам’ять
— Інші способи синхронізації даних
— Висновки

Насамперед раджу ознайомитися з попередніми частинами серії, адже ця стаття є логічним продовженням вже розглянутої теми. Тут не буде повторення викладеного матеріалу — натомість зануримося глибше у процеси, нашаровуючи нові знання та розширюючи розуміння теми.

Частина 1: GIL у Python. Ключ до стабільності чи ворог продуктивності?
Частина 2: Python без блокувань. Як працюють потоки

Ми вже говорили про CPU-інтенсивні задачі та досліджували, чому потоки не можуть допомогти у питанні покращення продуктивності для задач з великими навантаженнями на процесор. Дійсно, потоки не є розв’язанням цього питання, а от процеси — те, що може допомогти.

У Python процес — це незалежна одиниця виконання програми, яка має власний простір пам’яті та ресурси, що робить їх стійкішими до проблем синхронізації порівняно з потоками, але обмін даними тут складніший.

Коли ми розмовляли про один або кілька потоків, вони всі працювали в рамках одного процесу. Говорячи про процеси, маємо на увазі паралелізм вже іншого рівня — паралелізм, пов’язаний з можливостями процесора комп’ютера.

Створення процесу

Коли у Python створюється новий процес, під капотом відбувається цілий ряд операцій. Спершу потрібно зрозуміти, що таке процес на рівні операційної системи.

Процес — це ізольований екземпляр програми, що виконується у власному адресному просторі та має власні ресурси, такі як пам’ять, файлові дескриптори та потоки управління.

У Python створення процесів відбувається через модуль multiprocessing, який використовує механізми операційної системи для ініціалізації нового процесу. Залежно від платформи, використовується різний підхід.

У Unix-системах, таких як Linux та macOS, створення процесу відбувається через системний виклик fork(), який створює копію батьківського процесу разом із його пам’яттю та середовищем. При цьому копія працює незалежно і може змінювати свій стан без впливу на батьківський процес. Однак сучасні Unix-системи реалізують механізм Copy-on-Write (абрев. COW), що означає, що пам’ять фактично не копіюється при створенні процесу, а лише позначається як спільна. Якщо дочірній процес змінює дані в пам’яті, тоді операційна система фізично копіює змінений блок, щоб уникнути конфлікту між процесами.

Найбільша технічна конфа ТУТ!🤌

На Windows створення процесу працює інакше, оскільки fork() тут не підтримується. Натомість використовується метод spawn(), при якому новий процес стартується «з нуля» і не успадковує пам’ять батьківського процесу. Це означає, що при створенні процесу виконується новий екземпляр інтерпретатора Python, який завантажує весь необхідний код заново. Через це створення процесів у Windows є повільнішим порівняно з Unix-системами, адже немає оптимізації через Copy-on-Write.

Після створення процесу операційна система надає йому унікальний ідентифікатор процесу (абрев. PID) та додає його в таблицю процесів. Далі планувальник операційної системи визначає, колий на якому ядрі CPU цей процес буде виконуватись.

Схема показує, що спочатку запускається головний, батьківський, процес. Коли викликається fork(), операційна система створює новий дочірній процес, який є майже точною копією батьківського.

Ключова особливість fork() полягає в тому, що він викликається лише один раз, але повертається двічі — один раз у батьківському процесі та один раз у дочірньому.

У батьківському процесі fork() повертає PID дочірнього процесу. Це дозволяє батьківському процесу знати, хто є його нащадком і працювати з ним, наприклад, чекати його завершення.

У дочірньому процесі fork() завжди повертає 0, оскільки він сам по собі не створює нових процесів. Це допомагає розрізняти, де знаходиться код — у батьківському чи дочірньому процесі.

Процеси можуть працювати паралельно на різних ядрах процесора, що дозволяє значно пришвидшити виконання обчислювально важких задач, особливо у випадку Python, де через GIL багатозадачність не дає справжнього паралелізму (див. частину першу).

import time
import multiprocessing

def fib(n: int) -> int:
    if n <= 1:
        return n
    return fib(n - 1) + fib(n - 2)

def sequential_fibonacci(numbers: list[int]) -> list[int]:
    return [fib(n) for n in numbers]

def parallel_fibonacci(numbers: list[int]) -> list[int]:
    processes_num = multiprocessing.cpu_count()
    with multiprocessing.Pool(processes=processes_num) as pool:
        results = pool.map(fib, numbers)
    return results

if __name__ == '__main__':
    test_numbers = [35, 41, 40, 38]

    start_time = time.time()
    seq_result = sequential_fibonacci(test_numbers)
    seq_time = time.time() - start_time

    start_time = time.time()
    par_result = parallel_fibonacci(test_numbers)
    par_time = time.time() - start_time

    print(f'Час послідовного виконання: {seq_time:.2f} сек')
    print(f'Час паралельного виконання: {par_time:.2f} сек')
    print(f'Прискорення: {seq_time / par_time:.2f}x')

Розглянемо паралельне виконання на задачі з числами Фібоначчі. Одразу заувага — код слугує прикладом суто для теми паралельного виконання CPU-навантажених задач за допомогою процесів, тож у ньому навмисно не застосовано оптимізації підрахунку чисел Фібоначчі на кшталт кешування тощо.

Отже, програма порівнює швидкість обчислення чисел Фібоначчі рекурсивним методом у двох варіантах: послідовному та паралельному. Послідовний підхід просто обчислює значення для кожного числа у списку по черзі, має експоненційну складність O(2n). У паралельному варіанті використовується multiprocessing.Pool, щоб розподілити обчислення між усіма доступними ядрами процесора. Наприкінці програма вимірює час виконання обох підходів і виводить прискорення, отримане завдяки багатопроцесності.

Я побачила на екрані такі результати:

Час послідовного виконання: 31.24 сек
Час паралельного виконання: 16.90 сек
Прискорення: 1.85x

Отримуємо непоганий приріст завдяки паралельному виконанню незалежних обчислень на кількох процесорних ядрах. Оскільки кожен виклик fib(n) не залежить від інших, їх можна розподілити між окремими процесами, що дозволяє ефективніше використовувати ресурси процесора. У результаті замість виконання всіх обчислень на одному ядрі вони виконуються одночасно на кількох, що суттєво зменшує загальний час виконання.

Поглянемо на завантаження процесора у момент виконання коду. На першому графіку бачимо, як виглядає навантаження до запуску коду. Загальне навантаження на ядра дуже низьке (переважно 0-2%). Лише деякі ядра мають незначні піки через фонові програми на моєму компʼютері, але всі вони знаходяться на рівні <10%. Процеси розподілені рівномірно між ядрами, немає явних перевантажень.

Тепер запустимо код та проаналізуємо результати. Спостерігається значне підвищення навантаження на всі CPU. Використання ядер досягає піків у 100%, що свідчить про ефективну паралельну обробку.

Навантаження змінюється динамічно, що може означати чергування задач між ядрами — планувальник ОС може перерозподіляти ресурси, або ж деякі процеси завершуються швидше, водночас інші залишаються активними.

Розглянемо другий графік пильніше. Згадаймо, у коді спочатку запускається послідовне виконання підрахунку чисел Фібоначчі, а після — паралельне з використанням процесів. На графіку навантаження ми можемо це побачити.

Перша частина графіка відповідає послідовному виконанню. Навантаження спостерігається на кількох ядрах, але тільки одне ядро працює на 100% у конкретний момент. Це пояснюється тим, що код не використовує паралелізм: всі обчислення йдуть один за одним в одному процесі. Планувальник ОС може переносити цей єдиний процес між ядрами, тому навантаження розподіляється нерівномірно. Отже, використовується тільки один процес, а решта ядер просто «чекають».

Друга частина графіка демонструє розподіл навантаження на CPU під час виконання підрахунку паралельно — з використанням процесів. Помітно рівномірне використання всіх ядер, оскільки процеси виконуються паралельно. Усі CPU мають піки навантаження, але жодне не працює на 100% увесь час. Є «хвилі» навантаження, що вказує на динамічний розподіл процесів.

По аналогії з ThreadPoolExecutor для потоків у випадку з процесами також існує інструмент, що автоматично управляє ресурсами — ProcessPoolExecutor. На відміну від Pool з multiprocessing, який має більш старий і менш гнучкий інтерфейс, ProcessPoolExecutor краще інтегрується із сучасним кодом.

from concurrent.futures import ProcessPoolExecutor
import math

def compute_factorial(number: int) -> int:
    return math.factorial(number)

if __name__ == '__main__':
    numbers = [100000, 200000, 300000, 400000, 500000]

    with ProcessPoolExecutor(max_workers=4) as executor:
        results = executor.map(compute_factorial, numbers)

Як обрати кількість процесів

Аналогічно потокам у випадку процесів теж слід правильно обрати їх кількість, щоб дійсно оптимально їх використовувати.

Якщо задача потребує інтенсивних обчислень (англ. CPU-bound), то оптимальна кількість процесів зазвичай дорівнює або трохи менша кількості ядер процесора. Це можна визначити за допомогою multiprocessing.cpu_count(), як було показано для задачі підрахунку чисел Фібоначчі. Наприклад, якщо у вас 8-ядерний процесор, то можна використовувати 6-8 процесів, залишаючи трохи ресурсів для інших системних завдань.

Якщо використовувати всі ядра процесора без залишку, операційна система може почати гальмувати, оскільки не залишиться вільних ресурсів для фонових завдань. Це може спричинити зависання або зниження продуктивності не лише програми, а й усього комп’ютера.

ОС постійно виконує системні процеси, наприклад, обробку мережевого трафіку, управління пам’яттю та диспетчеризацію задач. Якщо всі ядра будуть повністю завантажені, ці системні процеси отримуватимуть менше ресурсів, що може призвести до нестабільної роботи системи або збільшення затримок у виконанні коду.

Також у деяких випадках створення процесів може супроводжуватися контекстним перемиканням, яке займає ресурси. Якщо система перевантажена, ефективність може навіть знизитися через витрати на перемикання між процесами. Тому рекомендується залишати хоча б одне ядро вільним, наприклад, запускати cpu_count() - 1 процесів, або експериментально визначати оптимальну кількість.

Також варто враховувати обсяг доступної оперативної пам’яті, оскільки надмірна кількість процесів може призвести до значних витрат пам’яті, а, отже, зниження продуктивності.

Для експериментального підходу можна почати з кількості процесів, рівної min(cpu_count(), X), де X — це допустиме споживання пам’ятіта навантаження на систему, та поступово тестувати, змінюючи значення.

Виникає питання: чи можна, враховуючи кількість ядер та накладні витрати, що спричиняє використання процесів у коді, досхочу додавати їх кількість? Де буде та межа, що зупинить приріст продуктивності від збільшення кількості процесів?

Поговоримо про закон Амдала, що описує обмеження, з якими стикається паралельне обчислення. Він пояснює, що не весь код може бути розпаралелений, і навіть якщо частина програми виконується на багатьох процесах, загальне прискорення буде обмежене тими частинами, які залишаються послідовними, що відповідно є вузьким місцем програми.

Формула закону виглядає так:

Де:

S — це загальне прискорення;

P — частка коду, що НЕ можна розпаралелити;

N — кількість процесорів.

Якщо, наприклад, 90% коду може працювати паралельно, а решта 10% виконується послідовно, навіть при безмежній кількості процесів максимальне прискорення складе 10 разів.

Це має важливе значення в багатопроцесності, оскільки навіть якщо розподілити обчислення між великою кількістю процесів, залишковий послідовний код буде гальмувати загальну продуктивність. Окрім цього, памʼятаємо, кожен процес вимагає певних накладних витрат, таких як створення, управління пам’яттю і комунікація між процесами. Це означає, що збільшення кількості процесів не завжди приводить до лінійного прискорення, а іноді може навіть уповільнити виконання через витрати на синхронізацію.

Для досягнення максимальної ефективності важливо не тільки правильно визначати кількість процесів, а й зменшувати послідовні частини коду, оптимізувати взаємодію між процесами та мінімізувати накладні витрати, що виникають при передачі даних між ними.

Взаємодія та обмін даними

Тож кожен процес має свою власну памʼять, що ускладнює синхронізацію даних між паралельно виконуваними частинами.

IPC (Inter-Process Communication) — це механізм, який дозволяє процесам взаємодіяти між собою, обмінюватися даними та координувати виконання.

Тут виділяють два основні підходи: обмін повідомленнями та спільну пам’ять. Обмін повідомленнями передбачає, що процеси передають дані один одному через спеціальні механізми, такі як черги, канали або сокети. Це безпечний, але трохи повільніший спосіб, оскільки дані копіюються між адресними просторами (памʼяттю) процесів. Спільна пам’ять, навпаки, дозволяє процесам безпосередньо працювати з однією і тією ж ділянкою пам’яті. Це дуже швидкий метод, оскільки не потребує копіювання даних, але він вимагає додаткових механізмів синхронізації для уникнення конфліктів між процесами (по аналогії з потоками — див. статтю частину 2).

Ще одна класифікація стосується рівня взаємодії процесів. IPC може бути локальним, коли процеси взаємодіють у межах однієї операційної системи, або мережевим, коли процеси працюють на різних пристроях і використовують для комунікації протоколи, такі як TCP/IP. Локальна взаємодія зазвичай швидша, оскільки немає мережевих затримок, тоді як мережевий IPC відкриває можливості для розподілених систем і мікросервісної архітектури.

Черги повідомлень

Загалом черги — це один із найпростіших і найефективніших способів організації IPC.Насправді механізм роботи, види черг — все аналогічно з чергами в контексті багатопоточності, що ми розглядали у минулій статті, тож дублювати інформацію не буду.

У Python черги для IPC реалізовані в модулі multiprocessing. Вони забезпечують потокобезпечний механізм передачі даних між процесами без необхідності використовувати блокування або семафори вручну. Черга створюється в основному процесі та передається як аргумент дочірнім процесам, які можуть додавати або отримувати з неї дані. При цьому використовується серіалізація через модуль pickle, що дозволяє передавати об’єкти, але додає деякі накладні витрати.

Використання multiprocessing.Queue() на рівні коду аналогічне зі вже розглянутим у попередній статті queue.Queue() для потоків. Але схожий інтерфейс не означає, що вони працюють однаково.

queue.Queue() використовується в потоках і забезпечує їхню синхронізацію за допомогою блокувань (threading.Lock). Вона дозволяє ефективно передавати дані між потоками в межах одного процесу без необхідності серіалізації, оскільки потоки мають спільний доступ до пам’яті. Це робить її швидшою та менш ресурсомісткою, але вона не підходить для паралельного виконання CPU-навантажених завдань через обмеження GIL.

multiprocessing.Queue() створена для міжпроцесного обміну даними й працює через пайпи (multiprocessing.Pipe). Кожен процес у Python має власний простір пам’яті, тож щоб розв’язати цю проблему multiprocessing.Queue() автоматично серіалізує дані перед передачею через pickle. Це дозволяє ефективно працювати з незалежними процесами, які виконуються на різних ядрах CPU, проте серіалізація додає накладні витрати, особливо при передачі великих об’єктів.

Пайпи

Пайпи в контексті міжпроцесної взаємодії (IPC) — це механізм передачі даних між процесами через спеціальні канали зв’язку. Пайп створюється у вигляді двох кінців — один процес записує дані send(), а інший читає recv(). Це забезпечує односторонній або двосторонній зв’язок між процесами. За замовчуванням пайп працює в режимі «point-to-point», тобто дані можуть передаватися лише між двома процесами. Якщо використовується двосторонній зв’язок, то обидва процеси можуть як відправляти, так і отримувати дані.

Механізм роботи пайпів базується на файлових дескрипторах, де один кінець записує дані в канал, а інший читає з нього. На низькому рівні пайпи використовують системні виклики операційної системи для створення потоків зв’язку між процесами, що дозволяє ефективно передавати навіть великі об’єкти.

У Python для цього існує multiprocessing.Pipe(), який повертає два кінці пайпу conn1 і conn2. Один процес отримує conn1 і пише в нього, інший процес отримує conn2 і читає дані. Для безпечного використання можна закривати кінець пайпу, коли він більше не потрібен, щоб уникнути зависань процесів.

Тож батьківська функція створює канал за допомогою Pipe(). Після створення каналу він викликає fork() для створення дочірнього процесу. Залежно від реалізації дочірнього і батьківського процесу вони спілкуються один з одним.

Зауважимо, дані в пайпі можуть бути пошкоджені, якщо два процеси одночасно спробують читати або записувати в один і той самий кінець каналу. Але якщо процеси працюють з різними кінцями пайпу одночасно — дані будуть у безпеці.

import multiprocessing
import time
from multiprocessing.connection import Connection

def sender(conn: Connection, messages: list[str]):
    for msg in messages:
        print(f'Відправлено: {msg}')
        conn.send(msg)  # Відправляємо повідомлення через пайп
        time.sleep(1)  # Симуляція затримки
    conn.close()  # Закриваємо з'єднання після завершення передачі

def receiver(conn: Connection, num_messages: int):
    for _ in range(num_messages):
        msg = conn.recv()  # Отримуємо дані з пайпу
        print(f'Отримано: {msg}')
    conn.close()  # Закриваємо з'єднання після завершення отримання даних

if __name__ == '__main__':
    parent_conn, child_conn = multiprocessing.Pipe()
    messages = ['Привіт!', 'Просто хотіла сказати,', 'що Харків - залізобетон!', 'Дякую за увагу!']

    sender_process = multiprocessing.Process(target=sender, args=(parent_conn, messages))
    receiver_process = multiprocessing.Process(target=receiver, args=(child_conn, len(messages)))

    sender_process.start()
    receiver_process.start()

    sender_process.join()
    receiver_process.join()

Розглянемо дуже простий приклад для демонстрації роботи пайпів. Основна ідея полягає в тому, що пайп створює канал зв’язку між двома кінцями (parent_conn і child_conn), де один процес пише в один кінець, а інший читає з другого.

Функція sender() проходить по списку повідомлень, кожне з яких надсилається у пайп через conn.send(). Функція receiver() знає заздалегідь, скільки повідомлень вона повинна отримати, проходиться по ним та виводить на екран.

Пайп забезпечує швидкий і ефективний двосторонній зв’язок між процесами, але не виконує синхронізацію. Якщо обидва процеси спробують одночасно записувати або читати з одного кінця пайпу, можуть виникнути помилки або пошкодження даних. Саме тому у цьому прикладі кожен процес працює тільки зі своїм кінцем пайпу, що робить взаємодію безпечною.

Слід зазначити, що може бути важко керувати двома пайпами, особливо якщо йде обмін великою кількістю даних. Канали можуть бути заблоковані, якщо один процес намагається прочитати з порожнього каналу або записати в повний канал.

Щоб подолати ці недоліки, замість звичайних каналів для двостороннього зв’язку можна використовувати іменовані канали. Це іменовані об’єкти, які можуть використовуватися багатьма процесами. Вони також мають можливості буферизації, які можуть допомогти запобігти блокуванню.

Спільна пам’ять

Спільна пам’ять (англ. shared memory) у контексті міжпроцесної взаємодії дозволяє процесам обмінюватися даними через спільний простір у пам’яті без необхідності серіалізації та передачі через пайпи чи черги. Це робить її значно швидшою, оскільки дані передаються напряму без копіювання між процесами.

У Python модуль multiprocessing надає засоби для використання спільної пам’яті через multiprocessing.shared_memory або multiprocessing.Value і multiprocessing.Array. Вони дозволяють створювати змінні або масиви, доступні для всіх процесів, які працюють у межах однієї програми.

import multiprocessing
import time
from multiprocessing.sharedctypes import Synchronized, SynchronizedArray
from multiprocessing.synchronize import Lock

def modify_shared_memory(shared_value: Synchronized, shared_array: SynchronizedArray, lock: Lock):
    for i in range(len(shared_array)):
        with lock:  # Захист від одночасного доступу
            shared_value.value += 1
            shared_array[i] += 1
            print(f'Процес {multiprocessing.current_process().name}: shared_value={shared_value.value}, shared_array={list(shared_array)}')
        time.sleep(0.5)  # Симуляція навантаження

if __name__ == '__main__':
    lock = multiprocessing.Lock()  # Використовується для запобігання конфліктам

    shared_value = multiprocessing.Value('i', 0)  # 'i' означає ціле число, початкове значення 0
    shared_array = multiprocessing.Array('i', [1, 2, 3, 4, 5])  # Масив цілих чисел

    processes = [multiprocessing.Process(target=modify_shared_memory, args=(shared_value, shared_array, lock)) for _ in range(3)]

    for p in processes:
        p.start()

    for p in processes:
        p.join()

    print(f'Фінальне значення shared_value: {shared_value.value}')
    print(f'Фінальне значення shared_array: {list(shared_array)}')

У прикладі створюється multiprocessing.Value, що зберігає ціле число, і multiprocessing.Array, що містить масив цілих чисел. Кілька процесів змінюють спільну змінну shared_value.value та елементи масиву shared_array[i], збільшуючи їх на 1. Операції змін виконуються всередині with lock, що гарантує, що лише один процес має доступ до змінних у певний момент.

Після завершення всіх процесів у масиві кожен елемент збільшиться на 3, а shared_value отримає значення загальної кількості змін — 15.

Діаграма показує структуру пам’яті двох процесів та їхню взаємодію через спільну пам’ять (англ. shared memory).

  • Text: текстовий сегмент, містить машинний код виконуваної програми, тобто сам бінарний код. Це статична область пам’яті, зазвичай тільки для читання (щоб уникнути випадкової зміни коду). Вона однакова для всіх процесів, які виконують один і той же програмний файл.
  • Data: сегмент даних, використовується для зберігання глобальних і статичних змінних.
  • Heap: купа, використовується для динамічного виділення пам’яті, розширюється або скорочується під час виконання програми.
  • Stack: стек, використовується для локальних змінних і викликів функцій, працює за принципом LIFO (Last In, First Out).

І останнє — shared memory. Це виділений блок пам’яті, який обидва процеси можуть бачити одночасно. На діаграмі він позначений як «mapped», оскільки він відображений у віртуальний адресний простір обох процесів. Приклад даних у спільній памʼяті відповідає отриманим результатам у нашому останньому прикладі.

Тож спільна памʼять «відображається» у памʼяті кожного процеса. Це взагалі як? Відображення пам’яті (англ. memory mapping) — це техніка, при якій фізична або віртуальна пам’ять пов’язується з адресним простором процесу. Це дозволяє процесам працювати зі спільною пам’яттю, ніби це просто частина їхньої власної пам’яті.

Уявіть, що у двох людей є спільний документ у хмарі (наприклад, Google Docs). Кожен із них може бачити зміни в реальному часі, тому що працюють зі спільним ресурсом. У цьому випадку цей документ є «mapped memory» для обох людей.

Інші способи синхронізації даних

Коротенько поговоримо й про інші способи синхронізації даних між процесами.

Сокети є одним із найпоширеніших способів IPC. Якщо сказати просто, сокети — це абстракція, що забезпечує з’єднання між двома кінцевими точками. IPC через сокети зазвичай організовується у форматі клієнт-сервер. Один процес виступає сервером, створюючи сокет і чекаючи підключень. Інший процес підключається до цього сокета як клієнт. Сокети підтримують двосторонню передачу даних, що дозволяє процесам як надсилати, так і отримувати інформацію.

У контексті IPC виділяють два види сокетів:

  • TCP/IP сокети — для процесів, які можуть виконуватися як на локальному хості, так і на різних хостах;
  • Unix Domain Sockets (абрев. UDS) — для локальних процесів, що взаємодіють через файлову систему, де замість IP-адреси використовуються файлові шляхи, наприклад, /tmp/mysocket.

Ось маленький демонстраційний приклад роботи сокетів.

Сервер:

import socket

class Server:
    def __init__(self, host: str, port: int):

        self.host = host
        self.port = port

    def start(self):
        sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
        sock.bind((self.host, self.port))

        sock.listen()
        print(f'Сервер розпочав роботу на порту {self.port}')

        while True:
            client, address = sock.accept()
            print(f'Встановлено зʼєднання з клієнтом {client} на порту {address}')
            message = 'Привід від сервера!!'
            client.send(message.encode())
            client.close()

if __name__ == '__main__':
    server = Server('127.0.0.1', 9999)
    server.start()

Клієнт:

import socket

class Client:
    BUFFER_SIZE = 1024

    def __init__(self, host: str, port: int):
        self.host = host
        self.port = port

    def connect(self):
        client_sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
        client_sock.connect((self.host, self.port))

        message = client_sock.recv(self.BUFFER_SIZE)
        client_sock.close()
        print(message.decode())

if __name__ == '__main__':
    client = Client('127.0.0.1', 9999)
    client.connect()

Я отримала такі результати зі сторони сервера:

Сервер розпочав роботу на порту 9999
Встановлено зʼєднання з клієнтом <socket.socket fd=4, family=2, type=1, proto=0, laddr=('127.0.0.1', 9999), raddr=('127.0.0.1', 50808)> на порту ('127.0.0.1', 50808)

Коли сервер слухає порт 9999, кожне клієнтське з’єднання відбувається через інший тимчасовий порт (raddr), дозволяючи серверу обслуговувати кілька клієнтів одночасно. Сокет, наприклад, fd=4, в Unix представлений як файловий дескриптор, тому серверу потрібні достатні ресурси для роботи з багатьма клієнтами одночасно.

Наступним способом синхронізації між процесами є бази даних. Для такого виду обміну даними процеси використовують загальну таблицю, куди один процес записує дані, а інший читає їх. Наприклад, у PostgreSQL можна створити таблицю для зберігання стану або повідомлень, а також використовувати механізми блокувань або стовпці зі статусами для уникнення конфліктів. Один процес записує нові дані або змінює статус запису, а інший періодично читає ці дані, наприклад, за допомогою SELECT-запитів із фільтром. Також можна налаштувати тригери в базі даних або використовувати PostgreSQL LISTEN чи NOTIFY для миттєвого сповіщення про зміни. Цей підхід дозволяє процесам обмінюватися даними навіть на різних машинах, якщо вони підключені до спільної бази.

І останній спосіб синхронізації між процесами, про котрий хотілося б сказати, це менеджер. Manager з модуля multiprocessing забезпечує високорівневий інтерфейс, який створює серверний процес, що дозволяє спільно використовувати Python-об’єкти (списки, словники, черги тощо) між різними процесами. Менеджер автоматично синхронізує доступ до цих об’єктів і забезпечує їхню безпеку. Ключова перевага Manager полягає в тому, що він дозволяє працювати зі звичними Python-об’єктами, не турбуючись про механіку блокування чи передачі даних між процесами. Всі операції над такими об’єктами відбуваються через проксі, який керується Manager.

Дуже простий приклад виглядає так:

from multiprocessing import Process, Manager

def worker(shared_list: list, value: int):
    shared_list.append(value)
    print(f'Процес додав значення {value}')

if __name__ == '__main__':
    manager = Manager()
    shared_list = manager.list()

    processes = []
    for i in range(5):
        p = Process(target=worker, args=(shared_list, i))
        processes.append(p)
        p.start()

    for p in processes:
        p.join()

    print(f'Результат у спільному списку: {list(shared_list)}')

Коли ми створюємо об’єкт через Manager, він фактично зберігається у серверному процесі, а кожен клієнтський процес отримує доступ до цього об’єкта через проксі. Завдяки цьому зміни, внесені одним процесом, одразу видно іншим.

Результат виконання коду, що я побачила на екрані:

Процес додав значення 1

Процес додав значення 0

Процес додав значення 3

Процес додав значення 2

Процес додав значення 4

Результат у спільному списку: [1, 0, 3, 2, 4]

Тобто Manager створює спільний список manager.list(), доступний усім процесам. Кожен процес додає свій елемент до цього списку, а після завершення роботи всіх процесів список містить результати від кожного з них.

Цей спосіб синхронізації підходить для сценаріїв, де потрібна проста організація спільного доступу до даних між процесами без складного управління низькорівневими механізмами синхронізації. Серед плюсів також можна виділити підтримку багатьох типів об’єктів та автоматичне управління блокуванням і доступом.

Висновки

Отже, процеси в Python — це потужний інструмент для обходу обмежень GIL і ефективного використання багатоядерних процесорів. На відміну від потоків, кожен процес має власну пам’ять і незалежно виконує код, що робить їх ідеальним вибором для ресурсомістких обчислень або задач, які потребують ізоляції. Хоча створення та обмін даними між процесами потребує більших ресурсів, ніж у потоків, перевага в повній незалежності й масштабованості виправдовує ці витрати для багатьох сценаріїв.

На цьому цикл статей завершується. Хто ще не встиг ознайомитися з минулими частинами — ласкаво прошу.

Частина 1: GIL у Python. Ключ до стабільності чи ворог продуктивності?
Частина 2: Python без блокувань. Як працюють потоки

А підсумовуючи, хочеться сказати, що багатозадачність у Python — це не тільки про вибір між потоками чи процесами. Це про розуміння поставлених завдань, аналіз їхніх потреб і вибір найбільш відповідного інструмента для досягнення оптимального результату. Адже багатозадачність — це не лише про паралельність, а про грамотний поділ роботи та максимальну ефективність.

У контексті багатозадачності також корисною буде тема асинхронності в Python, я її не торкалася у циклі статей, але, хто знає, може, то буде наступна моя стаття, побачимо згодом.

Усім, хто дійсно прочитав усі три статті серії та долучився до моїх роздумів про багатозадачність у Python, — щиро дякую та сподіваюсь, що матеріал був корисний та цікавий.

І памʼятайте, Python — це інструмент, а справжня магія завжди в руках розробника.
Дякую за увагу! Не прощаємось.

👍ПодобаєтьсяСподобалось23
До обраногоВ обраному17
LinkedIn
Дозволені теги: blockquote, a, pre, code, ul, ol, li, b, i, del.
Ctrl + Enter
Дозволені теги: blockquote, a, pre, code, ul, ol, li, b, i, del.
Ctrl + Enter

Нарешті на доу, по спражньому технічні підвезли

Дійсно, потоки не є розв’язанням цього питання, а от процеси — те, що може допомогти.

Насправді на рівні ядра Лінукс та деякі інші ОС не розрізняють потоки та процеси. Для створення того та іншого використовується єдина структура даних, де відмінність тільки в тому, що у треда буде встановлений TID, та декілька інших полей, які залежать від аргументів переданих до fork/clone викликів. І CFS, і новий EEVDF, сприймають процеси та треди просто як «таски».

створення процесу відбувається через системний виклик fork(), який створює копію батьківського процесу разом із його пам’яттю та середовищем

Все ж таки цей процес дещо складніший. Та і fork в лінухі задепрекейчений і рекомендують використовувати vfork. Ну і сам процес створення процеси дещо складніший ніж те, що ви описали, а ви взагалі не написали, що відбувається після форку.

shared memory. Це виділений блок пам’яті, який обидва процеси можуть бачити одночасно.

Я б ще додав, що на відміну від Value та Array, shared_memory потрібно синхронізувати вручну, адже такий об’єкт не буде завернутий в лок для вас. Ну і за межами пайтона така штука не буде працювати, бо потрібно уже буде використовувати сісколи типу shmget(), shmat(). Як по мені, то Value та Array виглядає як жалюгідна спроба додати в пайтон атомарні операції.

fork в лінухі задепрекейчений і рекомендують використовувати vfork

Excerpt from linux recommendations on vfork(2)

Standard description
       (From  POSIX.1)  The  vfork()  function  has  the  same effect as fork(2), except that the behavior is undefined if ...

Linux description
       vfork(), just like fork(2), creates a child process of the calling process. For details ... see fork(2).

Linux notes  
       Some consider the semantics of vfork() to be an architectural blemish ...
       A call to vfork() is equivalent to calling clone(2) with flags specified as ...

HISTORY
       4.3BSD; POSIX.1-2001 (but marked OBSOLETE). POSIX.1-2008 removes the specification of vfork()

CAVEATS
       ... signal handlers can be especially problematic: if a signal handler that is invoked in the child of vfork() changes memory, those changes may result in an inconsistent process state

Так, я змішав тут все в одну кучу. Форк задепрекейчений не в самому лінуксі, а в сайтоні. Але бачив ще пости про те, що і в лінухі fork discouraged на користь vfork.

Дякую за працю, було цікаво читати!

Дякую за схвальний відгук)

Було б цікаво побачити порівняння швидкості виконання, коли дані передаються між кількома процесами через **спільну пам’ять (shared memory)** та **канали (pipes)** 🙂

Тому що, коли я востаннє з цим стикався, швидкість виконання коду через **спільну пам’ять** була **катастрофічно нижчою**, ніж через **канали**... 😅

Тому що, коли я востаннє з цим стикався, швидкість виконання коду через **спільну пам’ять** була **катастрофічно нижчою**, ніж через **канали**... 😅

Це на одній системі? Вкрай дивно. Мабуть, пороблено було щось в логиці...

Важливо розуміти, що multiprocessing.cpu_count() повертає кількість ядер процесора машини, на якій виконується код.

Якщо програма запущена в оточенні з урізаною кількістю ресурсів (наприклад, kubernetes pod), то ця функція може повернути не той результат, який ви очікуєте.

Тобто якщо на машині доступно 4 CPU, але ви запускаєте pod з 1 CPU, то multiprocessing.cpu_count() все-одно поверне 4

Підписатись на коментарі