GIL Threads Processes
GIL, потоки, процеси⚑
Що таке потік (Thread)? Що таке процес (Process)?⚑
Потік - це легкий підпроцес, що існує в межах основного процесу і може виконувати паралельні задачі. Кожен потік має свій стек викликів, але поділяє адресний простір, купу та регістри процесора з іншими потоками в рамках процесу. Потоки дозволяють програмі виконувати багатозадачні операції, розділюючи I/O завдання між різними потоками для збільшення продуктивності. У Python, для роботи з потоками можна використовувати модуль threading
.
Процес - це незалежний виконуваний екземпляр програми, який має свою власну пам'ять та ресурси (файлові дескриптори, незалежні стеки викликів, купи та регістри процесора) і виконує код незалежно від інших процесів. Кожен процес має власний ідентифікатор процесу (PID) та виконується в окремому розділі пам'яті. У Python, для роботи з процесами можна використовувати модуль multiprocessing
. Процеси є корисними для використання багатоядерних систем, оскільки кожен процес може працювати на власному ядрі процесора, що підвищує продуктивність програм. Особливо у випадку Python, де через GIL багатозадачність не дає справжнього паралелізму.
Links
- Python без блокувань. Як працюють потоки - dou.ua
- Розділяй і володарюй. Як працюють процеси в Python - dou.ua
В чому відмінність потоків від процесів⚑
Потоки історично з'явились пізніше процесів, і в 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()
- Події (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
Що таке гонка (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)
excepted_result = threads_count * operations_per_thread_count
actual_result = counter.value
print(f'Expected: {excepted_result}, actual: {actual_result}')
Коли Python виконує код, він перекладає його у набір байткод-інструкцій. Якщо спростити, для нашого прикладу це виглядає приблизно так:
LOAD_ATTR
— завантажує значення змінноїvalue
LOAD_CONST
— завантажує константу 1BINARY_OP
— додає 1 до значенняvalue
STORE_ATTR
— зберігає результат у зміннуvalue
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. - Семафори: Якщо потрібно контролювати доступ до ресурсу для певної кількості потоків чи процесів.
- Queue: Для передачі даних між потоками чи процесами краще використовувати безпечні черги (
queue.Queue
абоmultiprocessing.Queue
), що автоматично забезпечують синхронізацію. - Переосмислення архітектури програми: Уникнення спільного доступу до змінних, використання іммутабельних структур даних та передачі повідомлень замість спільного ресурсу.
- Глобальний блок інтерпретатора (GIL): У CPython GIL частково запобігає деяким видам гонок, але його наявність не означає, що гонки не можуть виникнути.
Links
- Thinking about Concurrency, Raymond Hettinger, Python core developer
- https://habr.com/ru/articles/764420/
Що таке 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.
Links
- GIL у Python. Ключ до стабільності чи ворог продуктивності - dou.ua
- GlobalInterpreterLock
- Как устроен GIL в Python
- Embracing the Global Interpreter Lock (GIL) - David Beazley
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
- Python без блокувань. Як працюють потоки - dou.ua
- https://otus.ru/journal/threads-v-python/
- https://realpython.com/intro-to-python-threading/
Життєвий цикл треда⚑
- Спочатку створюємо клас, який успадковує від класу
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()
Links
Які завдання добре паралеляться, а які погано⚑
Summary
Добре паралеляться IO-bound, погано - CPU-bound.
Добре паралеляться завдання, які виконують тривалу операцію вводу-виводу. Коли потік чекає на завершення сокета або диску, інтерпретатор переключає цей потік і запускає наступний. Це означає, що не буде простою через очікування. З іншого боку, якщо в одному потоці здійснювати мережеві дзвінки (у циклі), то кожного разу доведеться чекати на відповідь.
Проте, якщо потім потрібно обробити отримані дані в потоці, то буде виконуватися лише один потік. Це не тільки не додасть швидкодії, але й сповільнить програму через переключення на інші потоки.
Завдання, пов'язані з мережею, добре підходять для потоків. Наприклад, завантаження ста URL-адрес. Отримані дані потрібно обробляти поза потоками.
Що таке блокуючі операції⚑
У контексті асинхронного програмування, блокуючі операції - це операції, які затримують виконання коду та очікують на результат. Під час блокування інші задачі не можуть виконуватись, оскільки весь потік виконання зупиняється.
Основні приклади блокуючих операцій включають: - Операції затримки (наприклад, time.sleep()): Ці операції призводять до призупинки виконання коду на задану тривалість. Протягом цього часу інші задачі не можуть продовжувати своє виконання. - Очікування блокування ресурсів: Це включає ситуації, коли код намагається отримати доступ до ресурсів, які зайняті іншими задачами. Коли ресурс заблокований, виконання потоку зупиняється, поки ресурс не стане доступним.
Асинхронний код дозволяє уникнути блокування під час таких операцій, дозволяючи іншим задачам продовжувати виконання.
Потрібно обчислити 100 рівнянь. Робити це у потоках або ні⚑
Ні, оскільки у цій задачі немає операцій вводу-виводу. Інтерпретатор лише витратить додатковий час на переключення потоків. Складні математичні завдання краще винести в окремі процеси або використовувати фреймворк для розподілених завдань, такий як Celery, або підключати C-бібліотеки.
Що таке грінлети. Загальне поняття. Приклади реалізацій⚑
Грінлети - 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