Skip to content

GIL Threads Processes

GIL, потоки, процеси

Що таке потік (Thread)? Що таке процес (Process)?

Потік - це легкий підпроцес, що існує в межах основного процесу і може виконувати паралельні задачі. Кожен потік має свій стек викликів, але поділяє адресний простір, купу та регістри процесора з іншими потоками в рамках процесу. Потоки дозволяють програмі виконувати багатозадачні операції, розділюючи I/O завдання між різними потоками для збільшення продуктивності. У Python, для роботи з потоками можна використовувати модуль threading.

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

Links

В чому відмінність потоків від процесів

Потоки історично з'явились пізніше процесів, і в Linux вони реалізовані однією і тією ж структурою даних, відомою як "таск" (Task). Для ядра Лінуксу немає різниці між потоком і процесом - потоки та процеси розглядаються як завдання (tasks). Тобто для створення того та іншого використовується єдина структура даних, де відмінність тільки в тому, що у треда буде встановлений TID, та декілька інших полей, які залежать від аргументів переданих до fork/clone викликів. CFS (Completely Fair Scheduler) та новий EEVDF (Earliest Eligible Virtual Deadline First) — планувальники, які використовуються для керування розподілом процесорного часу між завданнями в операційній системі - сприймають процеси та треди просто як "таски", тобто як абстракції завдань, тобто одиниці роботи, що потребують часу процесора. Вони не розрізняють тип цих завдань, а лише розподіляють час між ними залежно від заданих правил і алгоритмів.

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

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

Таким чином потоки є легшими, між ними швидше перемикається контекст.

Links

Недоліки потоків

Потоки складні При багатопоточному виконанні коду кожен з потоків має одночасний доступ до даних, що може призвести до неочікуваних результатів через проблему гонки потоків. Наприклад один потік читає значення 10, збільшує його на 1 і записує 11, інший потік читає те ж значення 10, також інкрементує його, отримавши 11. У результаті очікуване значення — 12, але ми отримали 11.

Помилки (баги) потоків і стан гонки (race conditions) у потокових програмах є найскладнішими типами помилок для виправлення. Потоки - це інструмент низького рівня, яким потрібно керувати вручну. Оскільки оптимальна кількість потоків для програми може змінюватися динамічно залежно від поточного завантаження системи та апаратного забезпечення, реалізація правильного рішення з допомогою потоків стає надзвичайно складною, якщо не неможливою. Крім того, механізми синхронізації, які зазвичай використовуються з потоками, додають складності та ризику в проєктування програмного забезпечення без будь-яких гарантій покращення продуктивності. З досвідом можливо розробляти нове програмне забезпечення, яке менш схильне до цих проблем, але у дуже складній програмі можлива ситуація, що ці проблеми буде неможливо виправити навіть експертам.

Потоки ресурсомісткі

Потоки вимагають додаткових ресурсів операційної системи для створення, таких як попередньо виділене, стекове місце, яке споживає віртуальну пам'ять процесу відразу. Це була велика проблема з 32-бітними операційними системами, оскільки адресний простір на процес обмежений 3 ГБ. Нині, з широкою доступністю 64-бітних операційних систем, віртуальна пам'ять вже не така цінна, як раніше (адресний простір для віртуальної пам'яті зазвичай 48 біт; тобто 256 Тіб). Але все-одно, потоки ресурсомісткі для програми (та системи) в плані використання пам'яті та продуктивності. Кожен потік вимагає виділення пам'яті як в адресному просторі ядра, так і в адресному просторі програми. Основні структури, необхідні для керування потоком та координації його планування, зберігаються в ядрі, використовуючи провідну пам'ять. Стек потоку та дані потоку зберігаються в адресному просторі програми. Більшість цих структур створюються та ініціалізуються при першому створенні потоку - процес, який може бути відносно дорогим через необхідність взаємодії з ядром. Однопотокові корутини не мають цих проблем і є набагато кращою альтернативою для одночасного введення-виведення.

Потоки можуть впливати на пропускну здатність

На дуже високих рівнях конкурентності (наприклад, > 5000 потоків) може виникнути вплив на пропускну здатність через витрати на перемикання контекстів, за умови, що вдасться налаштувати операційну систему для створення такої кількості потоків. Також, якщо програма буде мати занадто багато Locks - код може почати виконуватись послідовно.

Потоки негнучкі

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

Які існують способи синхронізації процесів та потоків ? Як передавати інформацію з одного процесу до іншого?

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

Блокування (Locks): threading.Lock або multiprocessing.Lock дозволяє обмежувати доступ до ресурсу лише одному потоку чи процесу в будь-який момент часу. Може призвести до взаємного блокування (deadlock).

class Counter:
    def __init__(self):
        self.value = 0
        self.lock = threading.Lock()

    def increase(self):
        with self.lock:
            self.value += 1
    ...

Deadlock

import threading

class DeadLockExample:
    def __init__(self):
        self.lock = threading.Lock()

    def outer_method(self):
        with self.lock:
            print('Зовнішній метод залоковано')
            self.inner_method()

    def inner_method(self):
        with self.lock:
            print('Внутрішній метод залоковано')

example = DeadLockExample()
thread = threading.Thread(target=example.outer_method)
thread.start()
thread.join()

RLocks (Reentrant Locks): Дозволяють одному і тому ж потоку повторно захоплювати блокування без блокування самого себе. В ирішує проблему deadlock. RLock відстежує кількість разів, коли потік захопив блокування, і дозволяє його відпустити лише після відповідної кількості release().

  • Перший раз acquire() отримує блокування.
  • Кожен наступний acquire() просто збільшує лічильник захоплень.
  • release() зменшує лічильник, але звільняє Lock тільки тоді, коли лічильник доходить до нуля.
import threading

lock = threading.Lock()

lock.acquire()  # Acquire the lock
lock.release()  # Release the lock

Семафори (Semaphores): threading.Semaphore дозволяє обмежувати доступ до ресурсу певною кількістю потоків. Для процесів використовується multiprocessing.Semaphore.

  • коли потік захоплює семафор, лічильник зменшується
  • якщо лічильник дорівнює нулю, потік чекає, доки семафор не звільниться
  • коли потік звільняє семафор, лічильник збільшується, і інший потік може отримати доступ
import threading
import time

def worker(name: str):
    with sem:  
        print(f'{name} отримав доступ')
        time.sleep(1)
        print(f'{name} звільнив доступ')

sem = threading.Semaphore(2)
threads = [threading.Thread(target=worker, args=(f'Thread-{i}',)) for i in range(5)]
for t in threads: t.start()
for t in threads: t.join()

BoundedSemaphore: звичайний threading.Semaphore не контролює парність викликів - якщо release() виконується більше разів, ніж acquire(), лічильник перевищує початкове значення, і обмеження порушується. Це типовий баг: повторне звільнення вже звільненого ресурсу, помилка в try/finally, race в коді користувача.

threading.BoundedSemaphore(n) запам'ятовує початкове значення n і кидає ValueError при спробі release() за межі стартового ліміту - проблема виявляється одразу, а не через незрозуміле перевищення квоти у проді.

sem = threading.BoundedSemaphore(3)
sem.acquire(); sem.release()  # OK
sem.release()                  # ValueError: Semaphore released too many times

Якщо немає причини спеціально дозволяти "розширення" лічильника, за замовчуванням використовують BoundedSemaphore. У asyncio діє та сама дихотомія: asyncio.Semaphore vs asyncio.BoundedSemaphore.

Події (Events): threading.Event або multiprocessing.Event використовуються для координації потоків або процесів через сигнали. Один потік повинен чекати на дозвіл від іншого перед початком роботи. Потік, що чекає, викликає wait(), і він заблокований, поки інший потік не викличе set(), сигналізуючи, що можна продовжувати. Event зручно використовувати, коли необхідно контролювати запуск потоків або координацію їх виконання, наприклад, у ситуаціях, коли потік має почати роботу лише після отримання певного сигналу.

import threading
import time

event = threading.Event()

def worker():
    print('Waiting for signal')
    event.wait()  # Waits for set()
    print('Thread started!')

thread = threading.Thread(target=worker)
thread.start()

time.sleep(2)
event.set()  # Grants the thread permission to work

Черги (Queues): queue.Queue для потоків і multiprocessing.Queue для процесів дозволяють обмінюватися даними між потоками чи процесами в безпечний спосіб. Черга працює за принципом FIFO (First In, First Out) — перший доданий елемент буде першим витягнутий.

import threading
import queue
import time

class ProducerConsumer:
    def __init__(self, num_consumers: int = 5, num_elements: int = 10):
        self.q = queue.Queue()
        self.producer_thread = threading.Thread(target=self._producer)
        self.consumer_threads = (threading.Thread(target=self._consumer) for _ in range(num_consumers))
        self.num_elements = num_elements

    def _producer(self):
        for i in range(self.num_elements):
            self.q.put(i)  # Adds an element to the queue
            print(f'Added {i}')
            time.sleep(0.1)  # Simulates work

    def _consumer(self):
        while not self.q.empty():
            item = self.q.get()  # Retrieves an element
            print(f'Received {item}')
            self.q.task_done()  # Marks the item as processed
            time.sleep(0.2)  # Simulates processing

    def run(self):
        self.producer_thread.start()
        time.sleep(0.5)  # Gives the producer time to add data

        for t in self.consumer_threads:
            t.start()

        self.producer_thread.join()  # Waits for the producer to finish
        self.q.join()  # Waits until all elements are processed

if __name__ == '__main__':
    pc = ProducerConsumer()
    pc.run()

Бар'єри (Barriers): threading.Barrier використовується для синхронізації групи потоків, які повинні досягти певного пункту перед продовженням роботи.

Для передачі інформації між процесами в Python найчастіше використовуються наступні способи

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

from multiprocessing import Process, Queue

def worker(q):
    q.put("Hello from process")  # Send a message to the queue

if __name__ == "__main__":
    q = Queue()
    p = Process(target=worker, args=(q,))
    p.start()
    print(q.get())  # Receive a message from the queue
    p.join()

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

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

from multiprocessing import Process, Pipe

def worker(conn):
    conn.send("Hello from worker")  # Send a message through the pipe
    conn.close()

if __name__ == "__main__":
    parent_conn, child_conn = Pipe()
    p = Process(target=worker, args=(child_conn,))
    p.start()
    print(parent_conn.recv())  # Receive a message from the pipe
    p.join()

Спільна пам'ять (Shared Memory): Через multiprocessing.Value або multiprocessing.Array можна ділитися даними між процесами. Дозволяє процесам обмінюватися даними через спільний простір у пам’яті без необхідності серіалізації та передачі через пайпи чи черги. Це робить її значно швидшою, оскільки дані передаються напряму без копіювання між процесами.

from multiprocessing import Process, Value

def worker(shared_value):
    shared_value.value += 1  # Modify shared value

if __name__ == "__main__":
    shared_value = Value('i', 0)  # Integer value in shared memory
    processes = [Process(target=worker, args=(shared_value,)) for _ in range(5)]

    for p in processes:
        p.start()
    for p in processes:
        p.join()

    print(shared_value.value)  # Output: 5

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

from multiprocessing import Manager, Process

def worker(shared_dict):
    shared_dict["key"] = "value"  # Update shared dictionary

if __name__ == "__main__":
    manager = Manager()
    shared_dict = manager.dict()
    p = Process(target=worker, args=(shared_dict,))
    p.start()
    p.join()
    print(shared_dict)  # Output: {'key': 'value'}

Links

Як працює Lock у багатопотоковості?

Summary

Lock - примітив синхронізації з двома станами. Потік викликає acquire(): якщо вільно - захоплює і йде далі; якщо зайнято - чекає. Після роботи з ресурсом викликає release(). Це гарантує, що в кожен момент часу до критичної секції має доступ лише один потік.

У Lock є два стани:

  • Locked - хтось уже користується ресурсом.
  • Unlocked - ресурс доступний.

Послідовність роботи:

  1. Потік викликає lock.acquire().
  2. Якщо Lock вільний - потік його захоплює і продовжує роботу.
  3. Якщо Lock уже зайнятий - потік блокується і чекає, поки не звільниться.
  4. Завершивши роботу з ресурсом, потік викликає lock.release(), і Lock стає доступний іншим.
import threading

lock = threading.Lock()
counter = 0

def worker():
    global counter
    with lock:  # context manager calls acquire() / release() automatically
        counter += 1

Альтернативний варіант без with:

def worker():
    global counter
    lock.acquire()
    try:
        counter += 1
    finally:
        lock.release()  # always release, even on exception

with варіант безпечніший - release() гарантовано викличеться навіть при винятку.

Як кілька процесів можуть безпечно працювати з одним файлом або об'єктом?

Summary

Через file locks (fcntl.flock, portalocker, filelock), міжпроцесні примітиви (multiprocessing.Lock/Semaphore/Queue), атомарні операції на рівні ОС, copy-on-write або винесення стану в СУБД.

Без координації кілька процесів, що пишуть в один файл/об'єкт, ризикують отримати race condition, пошкодження даних, втрату записів і читання неконсистентного стану (коли інший процес зловив "напівоновлені" дані).

1. File locks (блокування на рівні ОС)

Найпростіший спосіб для файлу - захопити блокування у самій файловій системі.

  • На Unix - fcntl.flock() або fcntl.lockf().
  • На Windows - msvcrt.locking().
  • Кросплатформенно - бібліотеки portalocker, filelock.
import fcntl

with open("data.txt", "a") as f:
    fcntl.flock(f, fcntl.LOCK_EX)  # exclusive lock - blocks other processes
    f.write("safe append\n")
    fcntl.flock(f, fcntl.LOCK_UN)  # release

2. Міжпроцесні примітиви синхронізації

Якщо процеси породжені з multiprocessing - можна використати спільні примітиви:

  • multiprocessing.Lock() - звичайне блокування.
  • multiprocessing.Semaphore(N) - обмежений доступ для N процесів.
  • multiprocessing.Queue() - потокобезпечна черга, найкраща стратегія "не ділити стан, а передавати повідомлення".
from multiprocessing import Process, Lock

def worker(lock, filename, value):
    with lock:
        with open(filename, "a") as f:
            f.write(f"{value}\n")

if __name__ == "__main__":
    lock = Lock()
    processes = [Process(target=worker, args=(lock, "out.txt", i)) for i in range(5)]
    for p in processes: p.start()
    for p in processes: p.join()

3. Атомарні операції на рівні ОС

Деякі операції самі по собі атомарні - наприклад, O_APPEND запис у файл на більшості POSIX-файлових систем атомарний у межах одного write(). Це знімає потребу в явному локі для простих сценаріїв логування.

4. Copy-on-Write (ізольована копія)

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

5. Винести стан у СУБД або спеціалізоване сховище

Для складної конкурентної логіки краще не зберігати спільний стан у файлі - БД вже вміє транзакції, ізоляцію, рядкові блокування (SELECT FOR UPDATE).

Без захисту типові проблеми:

  • Race condition - обидва процеси читають і пишуть одночасно.
  • Втрата даних - один процес перезаписує дані іншого.
  • Читання неконсистентних даних - читач бачить напівоновлений стан.

Що таке гонка (race condition)? Як з нею боротися?

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

Гонка виникає, якщо:

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

Проблеми гонки потоків існує для версій, молодших за Python 3.10.x, для інших — все працює як належить. У Python 3.10 було введено оптимізацію, яка змінила спосіб роботи GIL. Раніше GIL міг звільнятися і захоплюватися на будь-якій байткод-інструкції, але тепер це відбувається лише на певних "спеціальних" байткод-інструкціях, які називаються eval breakers.

import threading  

class Counter:
    def __init__(self):
        self.value = 0

    def increase(self):
        self.value += 1

def work(counter: Counter, operations_count: int):
    for _ in range(operations_count):
        counter.increase()

def run_threads(counter: Counter, count: int, operations_count: int):
    threads = []

    for _ in range(count):
        t = threading.Thread(target=work, args=(counter, operations_count))
        t.start()
        threads.append(t)

    for t in threads:
        t.join()

if __name__ == '__main__':
    threads_count = 10
    operations_per_thread_count = 1_000_000

    counter = Counter()
    run_threads(counter, threads_count, operations_per_thread_count)

    expected_result = threads_count * operations_per_thread_count
    actual_result = counter.value
    print(f'Expected: {expected_result}, actual: {actual_result}')

Коли Python виконує код, він перекладає його у набір байткод-інструкцій. Якщо спростити, для нашого прикладу це виглядає приблизно так:

  1. LOAD_ATTR — завантажує значення змінної value
  2. LOAD_CONST — завантажує константу 1
  3. BINARY_OP — додає 1 до значення value
  4. STORE_ATTR — зберігає результат у змінну value
  5. JUMP_BACKWARD — перемикає на наступну ітерацію

У 1-4 інструкціях немає eval breakers, тобто інструкцій, які змушують GIL звільнятися і захоплюватися знову. Це означає, що вся ця послідовність (тобто операція додавання) виконується атомарно — без втручання інших потоків. Таким чином, кожна ітерація виконується (майже) без гонки потоків.

Інструкції, які можуть (але не обов’язково) викликати звільнення GIL, це JUMP_BACKWARD (або JUMP_ABSOLUTE, залежно від версій Python), яка виконує перехід на початок циклу і може перевіряти GIL у довгих циклах, або CALL (у версіях до Python 3.11 — CALL_FUNCTION), що може звільнити GIL у випадках, коли викликається функція C, I/O або складні обчислення. Байт-код інструкції для програми можна побачити за допомогою модуля dis у Python, що використовується для дизасемблювання (розбору) байткоду, який виконує інтерпретатор Python.

Але якщо в коді між 1-4 інструкціями додасться нова, що викличе eval breakers, який може звільнити GIL, то проблема розірвання цілісної операції додавання перемиканням потоків повернеться.

Як боротися з гонкою

  • Використовувати блокування (threading.Lock): Блокування дозволяє гарантувати, що лише один потік чи процес може змінювати ресурс у певний момент.
import threading

counter = 0
lock = threading.Lock()

def increment():
    global counter
    for _ in range(100000):
        with lock:  # Ensure only one thread modifies the counter
            counter += 1

threads = [threading.Thread(target=increment) for _ in range(2)]

for t in threads:
    t.start()
for t in threads:
    t.join()

print(counter)  # Will be always 200000
  • Використовувати атомарні операції: У деяких випадках можна використовувати спеціальні функції або модулі для виконання атомарних операцій. Наприклад, multiprocessing.Value з atomic lock.
  • Семафори: Якщо потрібно контролювати доступ до ресурсу для певної кількості потоків чи процесів.
semaphore = threading.Semaphore(1)  # Maximum one thread in a critical section
  • Queue: Для передачі даних між потоками чи процесами краще використовувати безпечні черги (queue.Queue або multiprocessing.Queue), що автоматично забезпечують синхронізацію.
  • Переосмислення архітектури програми: Уникнення спільного доступу до змінних, використання іммутабельних структур даних та передачі повідомлень замість спільного ресурсу.
  • Глобальний блок інтерпретатора (GIL): У CPython GIL частково запобігає деяким видам гонок, але його наявність не означає, що гонки не можуть виникнути.

Links

Що таке GIL? Які проблеми в нього є?

Summary

GIL - Global Interpreter Lock - Глобальний блок інтерпретатора - це механізм, який регулює виконання потоків у межах інтерпретатора CPython, запобігаючи одночасному виконанню кількох потоків Python-коду. Він забезпечує безпеку доступу до пам’яті під час виконання інтерпретатора, але водночас створює значні обмеження для паралельного виконання обчислювальних задач. GIL — це не просто обмеження. Він є компромісом між продуктивністю, безпекою пам’яті та простотою реалізації інтерпретатора.

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

GIL — це блокування, яке має бути захоплено перед будь-яким доступом до Python (не так важливо, чи це виконується Python-код або виклики Python C API).

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

Також GIL робить розробку розширень на C набагато простішою. Багато бібліотек на C розраховують на те, що GIL захищає доступ до об’єктів Python. Це дозволяє бібліотекам уникати ручної синхронізації та працювати з об’єктами Python у безпечному середовищі.

Принцип роботи простий. Потоки утримують GIL, поки вони виконуються. Однак, вони звільняють його при блокуванні для операцій введення-виведення. Кожного разу, коли поток змушений чекати, інші готові до виконання потоки отримують шанс запуститися.

PyObject*
_PyEval_EvalFrameDefault(PyThreadState *tstate, PyFrameObject *f, int throwflag)
{
    // оголошення локальних змінних тощо
    // обчислювальний цикл
    for (;;) {
        // eval_breaker повідомляє, чи потрібно призупинити виконання байт-коду, наприклад, якщо інший потік запросив GIL
        if (_Py_atomic_load_relaxed(eval_breaker)) {
            // eval_frame_handle_pending() призупиняє виконання байт-коду, звільняє GIL і знову очікує доступності GIL
            if (eval_frame_handle_pending(tstate) != 0) {
                goto error;
            }
        }

        NEXTOPARG(); // отримати наступну інструкцію байт-коду

        switch (opcode) {
            case TARGET(NOP) {
                FAST_DISPATCH(); // наступна ітерація
            }
            case TARGET(LOAD_FAST) {
                FAST_DISPATCH(); // наступна ітерація
            }
            // ще 117 блоків case, що відповідають усім можливим кодам операцій
        }
        // обробка помилок
    }
    // завершення
}

Функція __PyEval_EvalFrameDefault_ відповідає за обробку байт-коду і виконання його інструкцій.

Коли потік починає роботу, він захоплює GIL. Через певний час планувальник процесів вирішує, що поточний потік виконав достатньо роботи і передає управління наступному потоку. Потік №2 бачить, що GIL захоплений, тому він не продовжує роботу, а сам себе переводить у сплячий режим, уступаючи процесор потоку №1.

Але потік не може утримувати GIL нескінченно. До версії Python 3.2 GIL перемикався кожні 100 тіків (машинних інструкцій). У пізніших версіях GIL може бути утримано потоком не більше 5 мс. Значення цього інтервалу можна змінити за допомогою функції sys.setswitchinterval(). GIL також звільняється, якщо потік здійснює системний виклик, працює з диском або мережею.

import threading
import time

def run_io_task(name: str):
    print(f'{name} почав I/O операцію')
    time.sleep(2)
    print(f'{name} закінчив I/O операцію')

threads = [threading.Thread(target=run_io_task, args=(f'Потік-{i}',)) for i in range(4)]

for t in threads:
    t.start()

for t in threads:
    t.join()

Кожен потік викликає time.sleep(2) інструкцію, яка звільняє GIL. Поки один потік чекає завершення sleep, інші потоки можуть виконуватися. У результаті всі потоки працюють ефективно, і програма виконується швидше (приблизно 2 секунди), ніж при послідовному виконанні (приблизно 8 секунд).

На глибокому рівні механізм роботи GIL реалізовано через примітиви синхронізації операційної системи. У CPython це зазвичай м’ютекси для блокування та семафори для сигналізації між потоками. У сучасних версіях Python (починаючи з Python 3.2) GIL реалізовано через м’ютекс і умови (pthreads condition variables) для підвищення продуктивності.

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

Проблема полягає в тому, що через GIL далеко не всі завдання можуть бути вирішені у потоках. Навпаки, їх використання часто знижує продуктивність програми (у випадку CPU-bound завдань). Використання потоків вимагає контролю доступу до загальних ресурсів, таких як словники, файли, підключення до бази даних.

  • GIL спрощує інтеграцію незахищених від багатопотоковості (non thread safe) бібліотек на C. Завдяки GIL у нас є так багато швидких модулів та зв'язків майже до всього.
  • Бібліотекам на C доступний механізм керування GIL. Наприклад, NumPy звільняє його під час тривалих операцій.

На практиці, GIL у Python робить непотрібною ідею використання потоків для паралелізму в обчислювальних задачах. Вони будуть працювати послідовно навіть на багатопроцесорній системі. У випадку CPU-bound задач програма не прискориться, а тільки сповільниться, оскільки тепер потокам доведеться поділити процесорний час навпіл. При цьому операції I/O GIL не сповільнює, оскільки перед системним викликом потік звільняє GIL.

GIL існує лише в оригінальному інтерпретаторі CPython.

Способи обходу GIL

Кілька стандартних шляхів отримати справжню паралельність попри GIL у CPython:

  • Багатопроцесність. multiprocessing, concurrent.futures.ProcessPoolExecutor. Кожен процес має власний інтерпретатор і власний GIL, тому Python-код виконується паралельно на різних ядрах. Накладні витрати - порядку мегабайт пам'яті на процес і необхідність IPC замість прямих посилань на об'єкти.
  • GIL відпускається на IO-блокуючих syscall'ах (recv, read, accept, time.sleep). Поки потік чекає у ядрі, інші потоки виконують байт-код. Тому потоки залишаються корисними для IO-bound задач: 100 потоків, що чекають на сокетах, не блокують одне одного.
  • C-розширення, що явно звільняють GIL. Бібліотеки на C/C++/Rust, які виконують довгі обчислення, можуть звільнити GIL через макрос Py_BEGIN_ALLOW_THREADS на час чистого C-коду. NumPy, scipy, lxml, hashlib (для великих блоків), pandas (частково) роблять це. У результаті CPU-bound операція в NumPy через кілька потоків справді розпаралелюється.
  • Sub-interpreters (PEP 684, PEP 734). Кілька ізольованих інтерпретаторів у межах одного процесу, кожен з власним GIL. Доступне з Python 3.12 на рівні C API (PEP 684 - per-interpreter GIL); з Python 3.13 додано stdlib-модуль interpreters (PEP 734) з Python-API. Об'єкти не діляться між інтерпретаторами напряму; обмін - через канали / pickle. Накладні витрати менші за multiprocessing (без окремих процесів), але вища складність моделі ізоляції.
  • No-GIL CPython (PEP 703). Експериментальна збірка CPython без GIL (--disable-gil), доступна з 3.13 як опція. Дає справжню паралельність потоків без зміни моделі програмування, ціною накладних витрат на atomic-операції з лічильниками посилань і складнішої сумісності з C-розширеннями. Поки що "free-threaded" режим - офіційно підтримувана експериментальна функція, не дефолт; для продакшну орієнтуватися на ноти релізу конкретної версії.

Links

GIL i GC

Кожен об'єкт у Python - це, насамперед, об'єкт, успадкований від базового класу PyObject. Цей самий PyObject містить всього два поля: ob_refcnt - кількість посилань, і ob_type - вказівник на інший об'єкт, тип даного об'єкта.

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

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

Тобто, лічильник посилань піддається проблемам у багатопотоковому середовищі. Стани гонки можуть призводити до некоректності оновлення цього лічильника з різних потоків. Щоб цього уникнути, CPython використовує GIL - Global Interpreter Lock. Кожен раз, коли відбувається робота з пам'яттю, GIL - як глобальний блок - перешкоджає виконанню цих дій одночасно з двох потоків. Він гарантує, що спершу відпрацює один, потім інший.

Як реалізовано багатопотоковість у Python

Багатопотоковість реалізована за допомогою модуля Threading. Це нативні POSIX-потоки. Такі потоки виконуються на рівні операційної системи, а не віртуальною машиною.

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

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

Links

Життєвий цикл треда

  • Спочатку створюємо клас, який успадковує від класу Thread з модуля threading. В ньому перевизначаємо метод run .
  • Створюємо екземпляр (instance) цього класу, викликаємо start(), який готує тред до виконання. Переводимо тред у стан виконання
  • Можна викликати різні методи, наприклад, sleep() і join(), які переводять тред у режим очікування
  • Коли режим очікування чи виконання припиняється, інші треди, які очікують, готуються до виконання
  • Після завершення виконання тред зупиняється
import random  
import time  
from threading import Thread  

class MyThread(Thread):  
    def __init__(self, name):  
        super().__init__()  
        self.name = name  

    def run(self):  
        amount = random.randint(3, 15)  
        time.sleep(amount)  
        msg = f"{self.name} is running"
        print(msg)  


def create_threads():  
    for i in range(5):  
        name = f"Thread #{i + 1}"  
        my_thread = MyThread(name)  
        my_thread.start()  


if __name__ == "__main__":  
    create_threads()

Для чого використовують ThreadPool? Як виділяються та повертаються потоки в ThreadPool?

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

Використання ThreadPool дозволяє знизити витрати на створення/знищення потоків, оскільки замість створення нового потоку для кожного завдання, потоки використовуються повторно. Також він дозволяє обмежити кількість потоків, що одночасно виконують завдання, запобігаючи перевантаженню системи.

Механізм роботи ThreadPool

  • Ініціалізація:
    • Пул створює певну кількість потоків (N) під час ініціалізації. Ці потоки знаходяться в стані очікування завдань.
    • Розмір пулу визначає максимальну кількість потоків, які можуть одночасно виконувати завдання.
  • Постановка завдання:
    • Завдання (функція чи об’єкт, що викликається) додається в чергу завдань пулу.
    • Потік із пулу бере завдання з черги і починає його виконувати.
  • Виконання завдань:
    • Кожен потік із пулу виконує одне завдання за раз.
    • Після завершення виконання потік не завершується, а повертається в пул, де очікує наступне завдання.
  • Закриття пулу:
    • Коли всі завдання завершені, і пул більше не потрібен, його закривають, що завершує всі потоки.
from concurrent.futures import ThreadPoolExecutor
import time


def task(name):
    print(f"Starting task {name}")
    time.sleep(2)  # Simulate a long-running operation
    print(f"Task {name} completed")
    return f"Result of {name}"


with ThreadPoolExecutor(max_workers=3) as executor:  # Create a ThreadPool with 3 threads
    futures = [executor.submit(task, i) for i in range(5)]
    for future in futures:
        print(future.result())  # Blocks until the task is completed

Як працює thread local?

Thread-local (локальні для потоку) змінні — це механізм, який дозволяє зберігати дані, специфічні для кожного потоку. Це означає, що кожен потік має свій власний набір значень цих змінних, незалежний від інших потоків. У Python це реалізовано через клас threading.local. Python створює окремий простір для кожного потоку, де зберігаються всі атрибути, додані до об'єкта threading.local. Цей простір зв'язується з ідентифікатором потоку (thread ID) і зникає разом із завершенням потоку.

threading.local працює наступним чином

  • Ізоляція даних: Кожен потік має окремий простір для зберігання значень thread-local змінних. Значення, встановлене в одному потоці, недоступне іншому.
  • Thread-local змінні доступні так само, як звичайні атрибути об'єкта.

Thread-local використовуються щоб

  • Зберігати тимчасові дані, специфічні для потоку (наприклад, ідентифікатор користувача, контекст запиту).
  • Уникнути передавання додаткових параметрів через функції.
  • Локалізувати стан в багатопоточних додатках, зберігаючи читабельність коду.

В django ORM та алхімії thread-local використовується для того, щоб для кожного треда зберігати свій конекшн в БД.

import threading


local_data = threading.local()  # Create a thread-local storage object

def process_data():
    print(f"Thread {threading.current_thread().name} has value: {local_data.value}")

def worker(value):
    local_data.value = value  # Assign a thread-specific value
    process_data()

threads = [threading.Thread(target=worker, args=(i,), name=f"Thread-{i}") for i in range(3)]

for t in threads:
    t.start()
for t in threads:
    t.join()
Thread Thread-0 has value: 0
Thread Thread-1 has value: 1
Thread Thread-2 has value: 2

Links

Які завдання добре паралеляться, а які погано

Summary

Добре паралеляться IO-bound, погано - CPU-bound.

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

Проте, якщо потім потрібно обробити отримані дані в потоці, то буде виконуватися лише один потік. Це не тільки не додасть швидкодії, але й сповільнить програму через перемикання на інші потоки.

Завдання, пов'язані з мережею, добре підходять для потоків. Наприклад, завантаження ста URL-адрес. Отримані дані потрібно обробляти поза потоками.

Що таке блокуючі операції

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

Основні приклади блокуючих операцій включають:

  • Операції затримки (наприклад, time.sleep()): Ці операції призводять до призупинки виконання коду на задану тривалість. Протягом цього часу інші задачі не можуть продовжувати своє виконання.
  • Очікування блокування ресурсів: Це включає ситуації, коли код намагається отримати доступ до ресурсів, які зайняті іншими задачами. Коли ресурс заблокований, виконання потоку зупиняється, поки ресурс не стане доступним.

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

Потрібно обчислити 100 рівнянь. Робити це у потоках або ні

Ні, оскільки у цій задачі немає операцій вводу-виводу. Інтерпретатор лише витратить додатковий час на переключення потоків. Складні математичні завдання краще винести в окремі процеси або використовувати фреймворк для розподілених завдань, такий як Celery, або підключати C-бібліотеки.

C-розширення, які відпускають GIL

Summary

Винятком із правила "CPU-bound у CPython не паралелиться через потоки" є C/C++/Rust-розширення, які явно відпускають GIL макросами Py_BEGIN_ALLOW_THREADS / Py_END_ALLOW_THREADS навколо тривалих обчислень. У такі моменти інші потоки виконують Python-байткод реально паралельно. Це канонічний механізм поєднання asyncio + CPU-bound операції: винести виклик у asyncio.to_thread, потік відпустить GIL, event loop продовжить обслуговувати запити.

Принцип

C-розширення викликає Py_BEGIN_ALLOW_THREADS перед тривалою операцією, яка не торкається Python-обʼєктів (компресія буфера, шифрування, обчислення хешу, векторна арифметика над ndarray). Поки GIL відпущений, інтерпретатор віддає його іншому потоку. Після завершення обчислення розширення викликає Py_END_ALLOW_THREADS і чекає GIL, щоб повернути результат як Python-обʼєкт.

З Python-боку це невидимо - просто виклик func(buf) блокує потік на час C-роботи, але не утримує GIL.

Канонічні бібліотеки, які відпускають GIL

  • hashlib (sha256, md5, sha1, ...) - на буферах від ~2048 байт і більше CPython вмикає GIL-release режим.
  • zlib, gzip, bz2, lzma - компресія/декомпресія блоків.
  • NumPy - векторні операції над ndarray (np.sum, np.dot, np.linalg.*, ufunc'и). Pure-Python ітерація через for x in arr - не відпускає, бо кожен елемент проходить через Python-обʼєкт.
  • Pandas - частина агрегацій через NumPy під капотом; .apply(func) з Python-функцією - утримує GIL.
  • Pillow - перетворення зображень (resize, convert, filter, save у стиснений формат).
  • bcrypt, argon2-cffi - password hashing. Канонічний приклад блокуючого CPU-bound у auth-сервісі.
  • cryptography (hazmat-шар) - симетричне шифрування, X.509-операції.
  • lxml - парсинг XML/HTML на буферах через libxml2.
  • Pillow-SIMD, opencv-python, scipy - image/signal processing.

Канонічний приклад: bcrypt у asyncio

import asyncio
import bcrypt


async def register_user(password: str) -> bytes:
    salt = bcrypt.gensalt(rounds=12)
    # ~200ms CPU-bound, but releases GIL inside the C extension
    return await asyncio.to_thread(bcrypt.hashpw, password.encode(), salt)

Без to_thread подія event loop'а блокувалася б на ~200 мс, не обслуговуючи інших запитів. З to_thread C-розширення відпускає GIL під час хешування, event loop вільно крутить інші корутини, паралельно з фоновим потоком.

Перевірка GIL-release для конкретної операції

  • Документація бібліотеки (для NumPy, Pillow, cryptography - явні згадки).
  • Вихідний код C-розширення: пошук Py_BEGIN_ALLOW_THREADS / NOGIL.
  • Емпірично: запустити кілька потоків з тестовою операцією, виміряти, чи дає threading прискорення проти послідовного виконання. Якщо ні - GIL утримується.
# Smoke test: linear speedup -> GIL released
from concurrent.futures import ThreadPoolExecutor
import time, hashlib

buf = b"\x00" * 10_000_000

def hash_once():
    hashlib.sha256(buf).digest()

t0 = time.perf_counter()
for _ in range(4): hash_once()
sequential = time.perf_counter() - t0

t0 = time.perf_counter()
with ThreadPoolExecutor(max_workers=4) as pool:
    list(pool.map(lambda _: hash_once(), range(4)))
parallel = time.perf_counter() - t0

print(f"Sequential: {sequential:.2f}s, Parallel: {parallel:.2f}s, "
      f"Speedup: {sequential/parallel:.2f}x")

При відпущенні GIL на 4 потоки очікувано speedup ~3-4×; якщо ~1× - GIL утримується і шлях через multiprocessing.Pool / ProcessPoolExecutor.

Чого не вистачає

  • Чиста Python-операція ніколи не відпускає GIL. Жодний to_thread навколо цикла for i in range(...) не дасть паралельності в межах одного процесу.
  • functools.lru_cache / dict-операції / атрибутний доступ - також під GIL.
  • Сам to_thread не магічно паралелізує - він лише дає шанс на паралельність, якщо викликаний код її уможливлює.

Канонічна евристика з вибору інструменту - у розділі "Для яких завдань варто використовувати потоки, для яких - процеси, а для яких - асинхронність?".

Links

Що таке грінлети. Загальне поняття. Приклади реалізацій

Грінлети - Greenlet - Green thread - Зелені потоки - Легкі потоки всередині віртуальної машини. Вони можуть називатися корутинами, субпроцесами, акторами та іншими, залежно від платформи.

Операційна система їх не бачить. З точки зору операційної системи, запущений лише один процес віртуальної машини, а те, що відбувається всередині, не відомо. Управління такими потоками відбувається самою віртуальною машиною: вона створює їх, виконує, координує доступ до ресурсів.

Приклади: корутини в мовах Go і Lua, легкі процеси в Erlang, модуль greenlet для Python. Модуль gevent використовує грінлети.

Сигнали та планувальники потоків

Сигнали в Python є способом взаємодії між процесами, який дозволяє операційній системі або іншим програмам надсилати асинхронні повідомлення програмі, що виконується. Наприклад, коли користувач натискає Ctrl+C, генерується сигнал SIGINT, який зазвичай завершує програму. Варто зазначити, що в Python можна змінити стандартну поведінку сигналу (окрім тих, котрі обробляються безпосередньо операційною системою, до прикладу SIGKILL і SIGSTOP).

Сигнали мають обмеження: вони працюють лише в основному потоці Python. Якщо програма використовує багатопоточність, все одно сигнали можуть надходити й оброблятися виключно в головному потоці. Тому може виникнути проблема із завершенням багатопотокових програм, а саме спробами зупинити виконання коду за допомогою Ctrl+C. Річ у тому, що програма, запущена в кількох потоках, не може бути зупинена за допомогою переривання клавіатурою (keyboard interrupt).

У Python сигнали, такі як SIGINT, обробляються тільки в головному потоці. Якщо головний потік заблокований, наприклад, під час очікування завершення іншого потоку (thread-join) або утримування блокування (lock), обробник сигналу може не отримати можливості виконатися, у результаті програма продовжить працювати, навіть якщо сигнал було надіслано. kill —9, на відміну від Ctrl+C, надішле сигнал SIGKILL, який не може бути перехоплений, проігнорований або змінений програмою, а отже обов’язково виконається та зупинить програму.

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

Така хаотичність при перемиканні між потоками після отримання сигналу пов'язана з плануванням потоків у Python. У Python немає засобів для визначення того, який потік має виконуватися наступним. Відсутні такі механізми, як пріоритети, витісняюча багатозадачність чи кругова черга (round-robin) тощо. Управління виконанням потоків повністю покладається на операційну систему. Це одна з причин непередбачуваної поведінки сигналів: інтерпретатор не контролює порядок запуску потоків, а лише перемикає їх якомога частіше, сподіваючись, що головний потік отримає можливість виконатися.

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

Що таке системний виклик fork?

Для створення процесів у Linux можна використовувати два системні виклики

clone(). Це основна функція для створення дочірніх процесів. За допомогою прапорців розробник вказує, які структури батьківського процесу повинні бути спільними з дочірнім. Базово використовується для створення потоків (мають спільний простір адрес, файлові дескриптори, обробники сигналів).

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

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

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

Для яких завдань варто використовувати потоки, для яких - процеси, а для яких - асинхронність?

Summary

  • CPU-залежна задача => Multi Processing
  • I/O-залежна, швидкий I/O => Multi Threading
  • I/O-залежна, повільний I/O => Asyncio (робота з вебсокетами)

Наприклад треба написати HTTP або WebSocket сервер, який обробляє кожне з'єднання в окремому потоці.

Можна створити 100, а можливо, навіть 500 потоків, щоб обробити необхідну кількість одночасних з'єднань. Для коротких запитів це може працювати і дозволити витримувати навантаження 5000 RPS на найбільш доступному DO інстансі за п'ять доларів - це досить непогано. Якщо менше, то можливо вам не потрібні жодні AsyncIO/Tornado/Twisted.

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

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

Перемикання контексту - взагалі витратна для процесора операція, яка потребує скидання регістрів, кешу та таблиці відображення сторінок пам'яті. Чим більше потоків запущено, тим більше процесор робить марні перемикання на потоки, що заблоковані GIL, перед тим, як дістатися до того самого, який утримує цей GIL. Не дуже ефективно.

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

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

Як і у випадку з потоками, асинхронні фреймворки використовуються для I/O-bound задач - таких як мережеві виклики, взаємодія з базами даних, робота з файлами. Для CPU-bound задач, які вимагають багато обчислень, такий підхід може бути неефективним, оскільки співпрограми теж працюють в одному потоці через GIL.

Ще одна найважливіша особливість корутину полягає в тому, що вони легкі. Вони "легші" за потоки. Тобто швидше запускаються і використовують менше пам'яті. По суті, корутини – це особливий різновид функцій, а от потоки представлені Python-об'єктами та пов'язані з потоками в операційній системі, з якими ці об'єкти мають взаємодіяти. Перемикання між потоками відбувається тоді, коли заманеться операційній системі. У випадку з asyncio - перемикання може відбутись на await, що дає більше контролю над програмою.

В Python-програмі можуть міститись тисячі потоків. А якщо говорити про корутини, то їх уже можуть бути десятки або сотні тисячі, і всі вони будуть розміщені в одному потоці.

Якщо задача потребує інтенсивних обчислень (CPU-bound), то потрібно використовувати процеси або оптимізації. Бібліотеки NumPy, Pandas вміють відпускати GIL. Також можна переписати критичні частини коду з допомогою Cyton, Numba, Clang. Оптимальна кількість процесів зазвичай дорівнює або трохи менша кількості ядер процесора. Рекомендується залишати хоча б одне ядро вільним, наприклад, запускати cpu_count() - 1 процесів, або експериментально визначати оптимальну кількість.

Links