GIL та модель конкурентності CPython — фундамент перед потоками і процесами
GIL та модель конкурентності CPython
Проблема: сервер із тисячами запитів і програма, що рахує числа
Два різних світи — і для кожного з них Python поводиться принципово по-різному.
Перший сценарій: веб-сервер обробляє тисячі HTTP-запитів одночасно. Кожен запит чекає відповіді від бази даних 10–100 мілісекунд. Якщо обробляти їх послідовно — сервер буде замерзати на кожному запиті. Python має кілька способів вирішити це: запустити кілька потоків, кілька процесів або використати asyncio.
Другий сценарій: машинне навчання або обробка зображень. Потрібно застосувати фільтр до мільйона пікселів, або порахувати хеш великого файлу. Це чиста обчислювальна робота без будь-якого очікування. Тут ті ж самі підходи дадуть різні результати — і деякі з них будуть повільнішими за однопотоковий код.
Дивовижно? Все пояснює одна абревіатура: GIL.
Чому threading не прискорює CPU код?
Чому asyncio обслуговує тисячі з'єднань одним потоком?
Коли multiprocessing справді прискорює?
Щоб правильно вибрати між threading, multiprocessing і asyncio, потрібно розуміти три речі: що таке concurrency vs parallelism, що таке I/O-bound vs CPU-bound задачі, і як влаштований GIL. Саме про це ця стаття.
Частина I: Concurrency vs Parallelism — концептуальний фундамент
Два різних поняття, що часто плутають
Ці два терміни вживаються як синоніми, але вони описують принципово різні речі:
Паралелізм (Parallelism) — це коли кілька задач виконуються фізично одночасно на різних ядрах процесора. Уявіть двох кухарів, що одночасно ріжуть різні овочі на різних дошках. Реальна фізична одночасність.
Конкурентність (Concurrency) — це коли кілька задач перебувають в процесі виконання одночасно, але не обов'язково виконуються в один і той самий момент. Один кухар ставить воду на плиту, поки вона закипає — ріже овочі, потім повертається до плити. Задачі чергуються, а не виконуються справді паралельно.
Паралелізм (2 ядра):
Ядро 1: ████████████████████████ → Задача A виконана
Ядро 2: ████████████████████████ → Задача B виконана
Час: ════════════════════════ (1 одиниця часу для обох)
Конкурентність (1 ядро, чергування):
Ядро 1: ████░░░░████░░░░████░░░░ → Задача A (виконується по частинах)
Ядро 1: ░░░░████░░░░████░░░░████ → Задача B (в паузах A)
Час: ════════════════════════ (2 одиниці часу для обох, але "одночасно")
Rob Pike (один із авторів Go) сформулював це ємко: «Concurrency is about dealing with lots of things at once. Parallelism is about doing lots of things at once». Перше — про структуру, друге — про виконання.
Чому конкурентність без паралелізму корисна?
Якщо одне ядро перемикається між задачами — здається, що ефективності нуль. Але це хибна інтуїція для задач, де процесор більшість часу чекає:
Однопотоковий HTTP-клієнт (послідовно):
[Запит 1] ───wait 100ms───► [Отримано]
[Запит 2] ───wait 100ms───► [Отримано]
[Запит 3] ───wait...
Загальний час: 300ms+
Конкурентний HTTP-клієнт (asyncio або threading):
[Запит 1] ──────wait 100ms──────► [Отримано]
[Запит 2] ──────wait 100ms──────► [Отримано]
[Запит 3] ──────wait 100ms──────► [Отримано]
Загальний час: ~100ms ← всі три чекають "одночасно"
Поки перший запит очікує відповіді від сервера, процесор може надсилати другий і третій запити. Це і є сила конкурентності для I/O-bound задач.
Частина II: I/O-bound vs CPU-bound — який тип задачі у вас?
Правильна відповідь на питання «який інструмент конкурентності використовувати» починається не з вибору між threading і asyncio, а з розуміння природи задачі.
I/O-bound задачі
Задачі, де вузьке місце — це очікування на зовнішній ресурс:
- Мережеві запити (HTTP, REST API, gRPC)
- Запити до бази даних (PostgreSQL, MongoDB)
- Читання/запис файлів на диск
- Читання з stdin або черги повідомлень (Kafka, RabbitMQ)
У таких задачах CPU більшість часу простоює і чекає. Операційна система блокує потік, що очікує на мережеву відповідь, і не дає йому займати ядро процесора. Для прискорення таких задач достатньо конкурентності (без справжнього паралелізму) — поки один потік/coroutine чекає, інший працює.
CPU-bound задачі
Задачі, де вузьке місце — сам процесор:
- Математичні обчислення (матриці, статистика)
- Обробка зображень, відео, аудіо (без GPU)
- Шифрування/дешифрування великих об'ємів даних
- Парсинг і трансформація великих датасетів
- Пошук підрядка у великому тексті
У таких задачах CPU постійно зайнятий. Жодного очікування немає. Тут конкурентність на одному ядрі не допоможе — потрібен справжній паралелізм (кілька ядер).
Швидкий тест: як визначити тип задачі?
# io_vs_cpu_test.py
import time
import urllib.request
# === Тест I/O-bound ===
def io_bound_task():
"""Завантажуємо сторінку — майже весь час очікуємо відповіді сервера."""
start = time.perf_counter()
with urllib.request.urlopen("https://httpbin.org/delay/1") as resp:
data = resp.read()
elapsed = time.perf_counter() - start
print(f"I/O-bound: {elapsed:.2f}s, отримано {len(data)} байт")
# === Тест CPU-bound ===
def cpu_bound_task(n: int = 10_000_000) -> int:
"""Рахуємо суму — процесор постійно зайнятий, жодного очікування."""
start = time.perf_counter()
result = sum(i * i for i in range(n))
elapsed = time.perf_counter() - start
print(f"CPU-bound: {elapsed:.2f}s, результат {result}")
return result
htop або Task Manager під час виконання. Якщо завантаження CPU близьке до 100% — CPU-bound. Якщо CPU майже вільний — I/O-bound.Частина III: GIL — Global Interpreter Lock
Навіщо потрібен GIL
CPython (стандартна реалізація Python) управляє пам'яттю через підрахунок посилань (reference counting). Кожен об'єкт у пам'яті зберігає лічильник того, скільки імен на нього посилається. Коли лічильник досягає нуля — пам'ять звільняється:
# reference_counting_demo.py
import sys
a = [1, 2, 3] # список створено, refcount = 1
b = a # refcount = 2
c = a # refcount = 3
print(sys.getrefcount(a)) # 4 (getrefcount сам додає +1 тимчасово)
del b # refcount = 3
del c # refcount = 2
# Коли 'a' вийде зі scope → refcount = 0 → пам'ять звільнена автоматично
Ця система проста і ефективна в однопотоковому середовищі. Але уявіть, що два потоки одночасно збільшують або зменшують лічильник одного об'єкта. Це гонка за ресурс (race condition) на найнижчому рівні — можна отримати витік пам'яті або передчасне звільнення об'єкта, що ще використовується.
Щоб вирішити це без накладних витрат на атомарні операції для кожного об'єкта окремо, розробники CPython ввели один глобальний замок — GIL:
Як GIL функціонує: check_interval та перемикання
GIL не утримується нескінченно. CPython перевіряє, чи не потрібно передати GIL іншому потоку, кожні N байткод-інструкцій (у Python 3.2+ це замінено на часовий інтервал):
import sys
# Поточний інтервал перемикання (у секундах)
print(sys.getswitchinterval()) # 0.005 (5 мілісекунд за замовчуванням)
# Можна змінити (зазвичай не потрібно):
sys.setswitchinterval(0.001) # перемикати кожну мілісекунду
Але є важливий виняток: при виконанні I/O-операцій потік добровільно звільняє GIL. Коли socket.recv() або file.read() передає управління операційній системі — GIL відпускається. Тому інші потоки можуть виконуватись, поки перший чекає на дані:
Потік 1: [Python код]──GIL──[socket.recv() → звільнює GIL]──────────────────[GIL повернуто]──[обробка]
Потік 2: (чекає GIL) [отримує GIL]──[виконує код]──[звільняє GIL]
Саме тому threading корисний для I/O-bound задач навіть попри GIL — поки один потік чекає на мережеву відповідь, GIL вільний і інші потоки виконують Python-код.
Демонстрація: GIL у дії
Порівняємо однопотоковий код і багатопотоковий для двох типів задач:
# gil_benchmark.py
import threading
import time
from concurrent.futures import ThreadPoolExecutor
import urllib.request
# ── CPU-bound задача (страждає від GIL) ──────────────────────────────────────
def count(n: int = 50_000_000) -> int:
"""Чиста обчислювальна задача без I/O."""
return sum(range(n))
def benchmark_cpu() -> None:
n = 50_000_000
# Однопотоково
t0 = time.perf_counter()
count(n)
count(n)
single_time = time.perf_counter() - t0
# Два потоки паралельно (через threading)
t0 = time.perf_counter()
t1 = threading.Thread(target=count, args=(n,))
t2 = threading.Thread(target=count, args=(n,))
t1.start(); t2.start()
t1.join(); t2.join()
thread_time = time.perf_counter() - t0
print("=== CPU-bound ===")
print(f" Однопотоково: {single_time:.2f}s")
print(f" Два потоки: {thread_time:.2f}s ← майже те саме або повільніше!")
print(f" Прискорення: {single_time / thread_time:.2f}x (очікувалось ~2x)")
# ── I/O-bound задача (GIL не заважає) ────────────────────────────────────────
URLS = [
"https://httpbin.org/delay/0.5",
"https://httpbin.org/delay/0.5",
"https://httpbin.org/delay/0.5",
"https://httpbin.org/delay/0.5",
]
def fetch(url: str) -> int:
with urllib.request.urlopen(url, timeout=10) as resp:
return len(resp.read())
def benchmark_io() -> None:
# Послідовно
t0 = time.perf_counter()
for url in URLS:
fetch(url)
sequential_time = time.perf_counter() - t0
# Паралельно через потоки
t0 = time.perf_counter()
with ThreadPoolExecutor(max_workers=4) as pool:
list(pool.map(fetch, URLS))
thread_time = time.perf_counter() - t0
print("\n=== I/O-bound ===")
print(f" Послідовно: {sequential_time:.2f}s")
print(f" Чотири потоки:{thread_time:.2f}s ← ~4x швидше!")
print(f" Прискорення: {sequential_time / thread_time:.2f}x")
benchmark_cpu()
# benchmark_io() # Розкоментуйте для перевірки (потрібен інтернет)
Результат наочний: два потоки для CPU-задачі дають 0.95x (повільніше!) замість очікуваних 2x. Для I/O-задачі — майже лінійне прискорення ~4x при 4 потоках.
Чому CPU-задача стала повільнішою? Потоки конкурують за GIL — один постійно чекає, поки інший його тримає. Накладні витрати на перемикання контексту і конкуренцію за GIL дають негативний ефект.
Як GIL обходять на практиці
NumPy / SciPy
multiprocessing
asyncio
PyPy / GraalPy
Python 3.13: GIL стає опційним
Python 3.13 (PEP 703) вводить режим «no-GIL» як експериментальний. Збірка python3.13t (t = threaded) дозволяє справді паралельне виконання Python-потоків без GIL. Але це тягне за собою інші складнощі: потокобезпека структур даних, нові примітиви синхронізації, потенційно інша продуктивність у коді, що не написаний з урахуванням відсутності GIL.
Частина IV: Три моделі конкурентності Python — повне порівняння
threading — потоки і кооперація під GIL
Модель threading:
Процес Python (1 GIL)
┌─────────────────────────────────────────────┐
│ Потік 1 Потік 2 Потік 3 │
│ [код] [код] [код] │
│ ↕ ↕ ↕ ← перемикання │
│ GIL: тільки один активний у момент часу │
│ Спільна пам'ять: так (але треба синхронізація) │
└─────────────────────────────────────────────┘
- Що прискорює: I/O-bound задачі (мережа, файли, БД)
- Що не прискорює: CPU-bound задачі
- Спільна пам'ять: так, всі потоки бачать одні й ті самі об'єкти
- Синхронізація: потрібна (
Lock,Queue,Event) - Накладні витрати: малі (потоки легші за процеси)
multiprocessing — справжній паралелізм
Модель multiprocessing:
Процес 1 (GIL₁) Процес 2 (GIL₂) Процес 3 (GIL₃)
┌──────────────┐ ┌──────────────┐ ┌──────────────┐
│ [Python код]│ │ [Python код]│ │ [Python код]│
│ Ядро 1 │ │ Ядро 2 │ │ Ядро 3 │
└──────────────┘ └──────────────┘ └──────────────┘
↑ ↑ ↑
Окрема пам'ять Окрема пам'ять Окрема пам'ять
(дані копіюються між процесами через серіалізацію)
- Що прискорює: CPU-bound задачі, що добре паралелізуються
- Спільна пам'ять: ні (окремі адресні простори); передача через IPC
- Синхронізація: через
multiprocessing.Queue,Pipe,Manager - Накладні витрати: значні (запуск процесу + серіалізація pickle)
asyncio — кооперативна конкурентність
Модель asyncio:
Один процес, один потік, один GIL
┌──────────────────────────────────────────────────┐
│ Event Loop │
│ ┌──────┐ ┌──────┐ ┌──────┐ ┌──────┐ │
│ │Task 1│ │Task 2│ │Task 3│ │Task 4│ ← черга │
│ └──┬───┘ └──┬───┘ └──┬───┘ └──┬───┘ │
│ │await │await │await │await │
│ └────────┴────────┴────────┘ │
│ Поки Task 1 чекає → event loop запускає Task 2 │
└──────────────────────────────────────────────────┘
- Що прискорює: I/O-bound задачі з дуже великою кількістю конкурентних операцій
- Спільна пам'ять: так (один процес), але без гонок (один корутин активний)
- Синхронізація: потрібна при спільному стані (
asyncio.Lock,asyncio.Queue) - Накладні витрати: мінімальні (немає переключення потоків ОС)
Частина V: Шпаргалка вибору та benchmark
Алгоритм вибору
Задача CPU-bound? (обчислення, без очікування)
→ так → multiprocessing (ProcessPoolExecutor)
→ ні → задача I/O-bound
Задача I/O-bound і...
→ дуже багато конкурентних операцій (>1000)? → asyncio
→ потрібна інтеграція з синхронними бібліотеками? → threading (ThreadPoolExecutor)
→ простий скрипт з кількома паралельними запитами? → threading (ThreadPoolExecutor)
→ бібліотека вже асинхронна (aiohttp, asyncpg)? → asyncio
Порівняльна таблиця
| Характеристика | threading | multiprocessing | asyncio |
|---|---|---|---|
| Модель | Потоки ОС | Процеси ОС | Корутини (1 потік) |
| GIL | Так (обмежує CPU) | Ні (кожен процес свій) | Так (не важливо) |
| I/O-bound | ✅ Ефективно | ✅ Ефективно | ✅ Дуже ефективно |
| CPU-bound | ❌ Не прискорює | ✅ Лінійне прискорення | ❌ Не прискорює |
| Спільна пам'ять | ✅ Так | ❌ Ні (IPC) | ✅ Так |
| Накладні витрати | Малі | Великі | Мінімальні |
| Складність коду | Середня | Висока | Висока |
| Синхронізація | Lock, Queue | Queue, Pipe | Lock, Queue |
| Масштабованість | ~сотні потоків | ~десятки процесів | Тисячі корутин |
| Модуль | threading, concurrent.futures | multiprocessing, concurrent.futures | asyncio |
Реальний benchmark: три підходи, одна задача
Завантажимо 20 URL трьома способами і порівняємо час:
# concurrency_benchmark.py
import asyncio
import time
import urllib.request
from concurrent.futures import ThreadPoolExecutor, ProcessPoolExecutor
from typing import Callable
URLS = [f"https://httpbin.org/delay/0.3" for _ in range(20)]
# ── Послідовний (baseline) ────────────────────────────────────────────────────
def fetch_sync(url: str) -> int:
with urllib.request.urlopen(url, timeout=15) as r:
return len(r.read())
def run_sequential() -> None:
t = time.perf_counter()
results = [fetch_sync(u) for u in URLS]
print(f"Sequential: {time.perf_counter() - t:.2f}s ({sum(results)} байт)")
# ── Threading ─────────────────────────────────────────────────────────────────
def run_threading() -> None:
t = time.perf_counter()
with ThreadPoolExecutor(max_workers=20) as pool:
results = list(pool.map(fetch_sync, URLS))
print(f"Threading: {time.perf_counter() - t:.2f}s ({sum(results)} байт)")
# ── asyncio ───────────────────────────────────────────────────────────────────
async def fetch_async(url: str, session) -> int:
# Використовуємо run_in_executor для urllib (синхронна), щоб не встановлювати aiohttp
loop = asyncio.get_event_loop()
result = await loop.run_in_executor(None, fetch_sync, url)
return result
async def run_asyncio_impl() -> None:
t = time.perf_counter()
tasks = [fetch_async(u, None) for u in URLS]
results = await asyncio.gather(*tasks)
print(f"asyncio: {time.perf_counter() - t:.2f}s ({sum(results)} байт)")
def run_asyncio() -> None:
asyncio.run(run_asyncio_impl())
# ── Запуск ───────────────────────────────────────────────────────────────────
if __name__ == "__main__":
print(f"Завантажуємо {len(URLS)} URL по ~0.3s кожен\n")
run_sequential() # ~6s
run_threading() # ~0.3-0.5s
run_asyncio() # ~0.3-0.5s
threading і asyncio показують схожий результат (~16x прискорення проти sequential). Для I/O-bound задач з помірною кількістю операцій — вони рівноцінні. Перевага asyncio над threading проявляється при тисячах конкурентних операцій, де накладні витрати на потоки ОС стають відчутними.Підсумок: що читати далі
Ця стаття заклала теоретичний фундамент. Тепер ви розумієте:
- Чим concurrency відрізняється від parallelism
- Що таке I/O-bound і CPU-bound задачі
- Як GIL обмежує threading для CPU-коду і чому не заважає для I/O-коду
- Які три моделі конкурентності є в Python і коли кожну використовувати
Наступні статті розкривають кожен інструмент детально:
Стаття 12: threading
threading.Thread, гонки за ресурс, Lock, Semaphore, Queue, ThreadPoolExecutor. Детальний розбір з прикладами синхронізації та антипатернами.Стаття 13: multiprocessing
multiprocessing.Process, Pool, ProcessPoolExecutor, IPC через Queue і Pipe, shared_memory. Справжній паралелізм для CPU-bound задач.Стаття 14: asyncio
async def, await, Task, Future, asyncio.gather(), синхронізація. Кооперативна конкурентність для тисяч I/O-операцій.Ключові принципи для запам'ятовування
| Принцип | Деталь |
|---|---|
| GIL — не ворог для I/O | При I/O-операціях GIL звільняється, потоки виконуються паралельно |
| GIL — проблема для CPU | Два Python-потоки на CPU-коді не дадуть 2x прискорення |
| Тип задачі — перший крок | Визначте I/O-bound чи CPU-bound до вибору інструменту |
| asyncio ≠ магія | asyncio не прискорює CPU-код, тільки реорганізує очікування |
| multiprocessing = накладні витрати | Запуск процесу + pickle для кожного аргументу — це дорого |
concurrent.futures — уніфікований API | ThreadPoolExecutor та ProcessPoolExecutor мають однаковий інтерфейс |
Dataclasses, NamedTuple та сучасні контейнери Python
Вичерпний розбір сучасних способів опису структур даних у Python — від ручного написання __init__ до @dataclass, NamedTuple, TypedDict і Enum. Порівняння продуктивності, використання пам'яті та зручності синтаксису.
Threading — конкурентність для I/O-bound задач
Вичерпний розбір модуля threading у Python — від Thread і daemon-потоків до Race Condition, Lock, RLock, Semaphore, Event, Barrier, потокобезпечних черг та ThreadPoolExecutor. Реальні патерни та антипатерни з прикладами.