Python

GIL та модель конкурентності CPython — фундамент перед потоками і процесами

Глибокий розбір Global Interpreter Lock у CPython, різниці між concurrency та parallelism, I/O-bound та CPU-bound задачами. Benchmarks та шпаргалка вибору між threading, multiprocessing та asyncio.

GIL та модель конкурентності CPython

Проблема: сервер із тисячами запитів і програма, що рахує числа

Два різних світи — і для кожного з них Python поводиться принципово по-різному.

Перший сценарій: веб-сервер обробляє тисячі HTTP-запитів одночасно. Кожен запит чекає відповіді від бази даних 10–100 мілісекунд. Якщо обробляти їх послідовно — сервер буде замерзати на кожному запиті. Python має кілька способів вирішити це: запустити кілька потоків, кілька процесів або використати asyncio.

Другий сценарій: машинне навчання або обробка зображень. Потрібно застосувати фільтр до мільйона пікселів, або порахувати хеш великого файлу. Це чиста обчислювальна робота без будь-якого очікування. Тут ті ж самі підходи дадуть різні результати — і деякі з них будуть повільнішими за однопотоковий код.

Дивовижно? Все пояснює одна абревіатура: GIL.

Чому threading не прискорює CPU код?

Запустити 8 потоків для CPU-задачі на 8-ядерному процесорі — і отримати той самий час, що і в одному потоці. GIL не дозволяє Python-байткоду виконуватись справді паралельно.

Чому asyncio обслуговує тисячі з'єднань одним потоком?

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

Коли multiprocessing справді прискорює?

Кожен процес має власний GIL та власний інтерпретатор. 4 процеси на 4 ядрах — це справжній паралелізм. Але є ціна: накладні витрати на запуск та серіалізацію даних між процесами.

Щоб правильно вибрати між 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 (Global Interpreter Lock) — це мютекс (mutex), що дозволяє виконуватись лише одному потоку Python-байткоду в один момент часу в межах одного процесу 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()  # Розкоментуйте для перевірки (потрібен інтернет)
python gil_benchmark.py
$ python gil_benchmark.py
=== CPU-bound ===
Однопотоково: 3.82s
Два потоки: 4.01s ← майже те саме або повільніше!
Прискорення: 0.95x (очікувалось ~2x)
=== I/O-bound ===
Послідовно: 2.13s
Чотири потоки:0.56s ← ~4x швидше!
Прискорення: 3.80x

Результат наочний: два потоки для CPU-задачі дають 0.95x (повільніше!) замість очікуваних 2x. Для I/O-задачі — майже лінійне прискорення ~4x при 4 потоках.

Чому CPU-задача стала повільнішою? Потоки конкурують за GIL — один постійно чекає, поки інший його тримає. Накладні витрати на перемикання контексту і конкуренцію за GIL дають негативний ефект.

Як GIL обходять на практиці

NumPy / SciPy

Числові операції в NumPy (написані на C) звільняють GIL під час обчислень. Тому NumPy-код у потоках може справді виконуватись паралельно. Те саме стосується більшості C-extensions.

multiprocessing

Кожен процес має власний інтерпретатор і власний GIL. 4 процеси = 4 незалежних GIL = справжній паралелізм на 4 ядрах. Але між процесами немає спільної пам'яті.

asyncio

Обходить GIL по-іншому: один потік, але кооперативна конкурентність. Ніяких проблем із GIL, бо виконується тільки один корутин у момент часу.

PyPy / GraalPy

Альтернативні реалізації Python без GIL або з іншою стратегією garbage collection. Але сумісність і зрілість ще не на рівні CPython для виробничих систем.

Python 3.13: GIL стає опційним

Python 3.13 (PEP 703) вводить режим «no-GIL» як експериментальний. Збірка python3.13t (t = threaded) дозволяє справді паралельне виконання Python-потоків без GIL. Але це тягне за собою інші складнощі: потокобезпека структур даних, нові примітиви синхронізації, потенційно інша продуктивність у коді, що не написаний з урахуванням відсутності GIL.

Навіть коли «no-GIL» стане стандартом, розуміння 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

Порівняльна таблиця

Характеристикаthreadingmultiprocessingasyncio
МодельПотоки ОСПроцеси ОСКорутини (1 потік)
GILТак (обмежує CPU)Ні (кожен процес свій)Так (не важливо)
I/O-bound✅ Ефективно✅ Ефективно✅ Дуже ефективно
CPU-bound❌ Не прискорює✅ Лінійне прискорення❌ Не прискорює
Спільна пам'ять✅ Так❌ Ні (IPC)✅ Так
Накладні витратиМаліВеликіМінімальні
Складність кодуСередняВисокаВисока
СинхронізаціяLock, QueueQueue, PipeLock, Queue
Масштабованість~сотні потоків~десятки процесівТисячі корутин
Модульthreading, concurrent.futuresmultiprocessing, concurrent.futuresasyncio

Реальний 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
python concurrency_benchmark.py
$ python concurrency_benchmark.py
Завантажуємо 20 URL по ~0.3s кожен
Sequential: 6.18s (84320 байт)
Threading: 0.38s (84320 байт)
asyncio: 0.34s (84320 байт)
У цьому тесті 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

Event loop, 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 — уніфікований APIThreadPoolExecutor та ProcessPoolExecutor мають однаковий інтерфейс
Copyright © 2026