Python Конкурентность: GIL, threading, multiprocessing, asyncio — что когда
0%

Конкурентность: GIL, threading, multiprocessing, asyncio — что когда

Конкурентность: GIL, threading, multiprocessing, asyncio — что когда

Есть задача, на которой ломается интуиция большинства питонистов. Сервис ходит в двенадцать внешних API, складывает ответы и считает по ним агрегат. Разработчик читает про потоки, оборачивает вызовы в ThreadPoolExecutor — и получает ускорение в двенадцать раз. Радуется, применяет тот же приём к функции, которая считает хеши миллиона строк, — и получает замедление. Ищет причину, находит слово «GIL», читает три статьи с заголовками «в Python нет настоящей многопоточности» и делает вывод, что язык сломан.

Вывод неверный, а наблюдения — оба верные. Модель исполнения CPython устроена так, что один и тот же инструмент даёт двенадцатикратный выигрыш и отрицательный выигрыш на двух задачах, которые с точки зрения кода выглядят одинаково. Разобраться в этом стоит один раз и навсегда: после этого выбор между threading, multiprocessing, asyncio и concurrent.futures перестаёт быть гаданием и становится следствием одного вопроса — где ваш процесс проводит время.

Эта статья предполагает, что вы знаете, что такое поток и процесс на уровне ОС; если нет, начните с «Процессы и планирование» и «Потоки и синхронизация». Про генераторы, на которых технически стоят корутины, — в «Идиоматичном Python»; про ExceptionGroup и CancelledError — в «Исключениях».

Три слова, которые постоянно путают

Термин Что означает Нужно ли для этого несколько ядер
Конкурентность Несколько задач в работе одновременно, прогресс чередуется Нет
Параллелизм Несколько задач физически исполняются в один момент времени Да
Асинхронность Операция запущена, результат придёт позже, поток не ждёт Нет

Классическая формулировка Роба Пайка: «Concurrency is not parallelism» — конкурентность про структуру программы, параллелизм про исполнение. Python даёт отличную конкурентность и посредственный параллелизм внутри одного процесса. Практически всё, что дальше, — про то, как из первого извлечь пользу, а второе получить обходным путём.

GIL: что это на самом деле

Global Interpreter Lock — мьютекс, которым CPython защищает своё внутреннее состояние: счётчики ссылок объектов, внутренние структуры интерпретатора, свободные списки аллокатора. Правило одно: чтобы исполнять байт-код, поток обязан владеть GIL.

Зачем он появился. CPython управляет памятью подсчётом ссылок (см. «Модель данных»). Операция Py_INCREF — это obj->ob_refcnt++, обычный инкремент в памяти. Без блокировки два потока, одновременно берущие ссылку на один объект, теряют инкремент — и объект освобождается, пока на него ещё ссылаются. Это немедленный segfault, а не логическая ошибка. Вариантов два: сделать каждый счётчик атомарным (медленно для однопоточного кода — а это 99 % кода) или взять один глобальный замок (быстро, но убивает параллелизм). В 1992 году выбрали второе, и тридцать лет это было правильным решением: простая C-API, тривиальное встраивание C-библиотек, отличная однопоточная скорость.

GIL защищает интерпретатор, а не ваши данные. Это ключевая мысль, из которой растёт половина ошибок ниже.

Как GIL отпускается

Есть ровно три момента:

  1. По таймеру. Каждые sys.getswitchinterval() секунд (по умолчанию 0.005 — 5 мс) владелец получает запрос «отдай GIL» и отдаёт его на ближайшей границе байт-код-инструкции.
  2. На блокирующем системном вызове. socket.recv, file.read, time.sleep, os.stat — весь такой код в CPython обрамлён макросами Py_BEGIN_ALLOW_THREADS / Py_END_ALLOW_THREADS и работает без GIL.
  3. Внутри C-расширений, которые явно об этом позаботились: numpy в большинстве ufunc, hashlib на буферах больше пары килобайт, zlib, lxml, psycopg, cryptography.

Владение GIL двумя потоками: CPU-bound не ускоряется, I/O-bound ускоряется

Отсюда следует всё практическое поведение. Проверим измерением — это, кстати, единственный честный способ спорить о производительности (подробнее в «Производительности»).

# gil_demo.py — сравнение потоков и процессов на двух типах нагрузки
import time
from concurrent.futures import ProcessPoolExecutor, ThreadPoolExecutor

N = 8_000_000


def burn(_: int) -> int:
    """Чистый Python-счёт: GIL нужен на каждую инструкцию."""
    total = 0
    for i in range(N):
        total += i * i
    return total


def wait(_: int) -> None:
    """Имитация сетевого запроса: GIL отпущен на всё время сна."""
    time.sleep(0.5)


def measure(label: str, fn, executor_cls, workers: int) -> None:
    start = time.perf_counter()
    with executor_cls(max_workers=workers) as pool:
        list(pool.map(fn, range(workers)))
    print(f"{label:<34} {time.perf_counter() - start:.2f} c")


if __name__ == "__main__":          # обязательно: без этого spawn уйдёт в рекурсию
    measure("счёт, 1 поток", burn, ThreadPoolExecutor, 1)
    measure("счёт, 4 потока", burn, ThreadPoolExecutor, 4)
    measure("счёт, 4 процесса", burn, ProcessPoolExecutor, 4)
    measure("ожидание, 1 поток", wait, ThreadPoolExecutor, 1)
    measure("ожидание, 4 потока", wait, ThreadPoolExecutor, 4)

Типичный вывод на 8-ядерной машине, CPython 3.12:

счёт, 1 поток                      0.62 c
счёт, 4 потока                     2.71 c     <- хуже, чем последовательно
счёт, 4 процесса                   0.71 c     <- почти линейное ускорение
ожидание, 1 поток                  0.50 c
ожидание, 4 потока                 0.50 c     <- четыре ожидания за цену одного

Строка «4 потока — 2.71 c» — это не просто отсутствие ускорения, это штраф. Четыре потока дерутся за один замок, каждые 5 мс происходит переключение контекста, кеш процессора вымывается. Дэвид Бизли назвал крайний случай этого явления convoy effect и подробно разобрал в «Understanding the Python GIL» — это до сих пор лучший часовой доклад по теме.

Чего GIL НЕ даёт: атомарность

Самое дорогое заблуждение: «GIL всё синхронизирует, локи не нужны». GIL гарантирует атомарность одной инструкции байт-кода, а типичная операция состоит из нескольких.

import dis

counter = 0

def inc() -> None:
    global counter
    counter += 1

dis.dis(inc)
  LOAD_GLOBAL              0 (counter)   <- прочитали
  LOAD_CONST               1 (1)
  BINARY_OP               13 (+=)        <- прибавили
  STORE_GLOBAL             0 (counter)   <- записали

Между LOAD_GLOBAL и STORE_GLOBAL поток может потерять GIL, и второй поток прочитает устаревшее значение. Классическая гонка, ничем не отличающаяся от такой же на C:

import threading

counter = 0
ITERATIONS = 200_000


def worker() -> None:
    global counter
    for _ in range(ITERATIONS):
        counter += 1          # НЕ атомарно


threads = [threading.Thread(target=worker) for _ in range(4)]
for t in threads:
    t.start()
for t in threads:
    t.join()

print(counter)   # ожидали 800000, получили, например, 543127

Правило: если операция «прочитать — изменить — записать», нужна явная синхронизация. Что действительно атомарно (потому что укладывается в один опкод и один вызов C-функции): list.append, list.pop, d[k] = v, d.setdefault, чтение элемента. Полный исторический список — в FAQ по потокам, но опираться на него не стоит: это деталь реализации CPython, которая уже не выполняется в free-threaded сборках так же очевидно.

Правильный вариант — Lock, а лучше — вообще не разделять состояние:

lock = threading.Lock()

def worker() -> None:
    global counter
    for _ in range(ITERATIONS):
        with lock:            # ~100 нс накладных расходов на итерацию
            counter += 1

Ещё лучше — считать локально и сложить один раз в конце, или отдать агрегацию queue.Queue. Разделяемое изменяемое состояние — источник почти всех багов конкурентности; см. «Конкурентные парадигмы».

threading: когда потоки — правильный ответ

Потоки в Python — настоящие потоки ОС (pthreads / Windows threads), не зелёные. Они стоят по 8 МБ виртуального стека, планируются ядром, видны в top. Просто их байт-код сериализован GIL.

Берите потоки, когда: блокирующий драйвер БД без асинхронного аналога; библиотека requests в скрипте, который переписывать некогда; работа с файлами и subprocess; десятки, а не тысячи одновременных операций; вызовы в C-расширения, отпускающие GIL.

import queue
import threading
import time
from dataclasses import dataclass

@dataclass(frozen=True, slots=True)
class Job:
    url: str

def producer(q: "queue.Queue[Job | None]", urls: list[str], workers: int) -> None:
    for url in urls:
        q.put(Job(url))
    for _ in range(workers):
        q.put(None)                    # «отравленная пилюля» на каждого воркера

def consumer(q: "queue.Queue[Job | None]", results: list[str]) -> None:
    while (job := q.get()) is not None:
        time.sleep(0.1)                # здесь был бы сетевой вызов
        results.append(f"ok {job.url}")
        q.task_done()

def main() -> None:
    urls = [f"https://example.com/{i}" for i in range(20)]
    q: "queue.Queue[Job | None]" = queue.Queue(maxsize=50)
    results: list[str] = []            # list.append атомарен, но лучше не рисковать
    workers = 5

    threads = [
        threading.Thread(target=consumer, args=(q, results), name=f"w{i}")
        for i in range(workers)
    ]
    for t in threads:
        t.start()
    producer(q, urls, workers)
    for t in threads:
        t.join()
    print(len(results))                # 20

main()

queue.Queue — главный примитив многопоточного Python. Он потокобезопасен, поддерживает ограничение размера (обратное давление!) и с Python 3.13 умеет shutdown() — можно не изобретать «отравленные пилюли».

Примитивы синхронизации и что с ними не так

Примитив Задача Грабли
Lock Взаимное исключение Повторный захват тем же потоком = дедлок
RLock То же, но реентерабельно Прячет плохой дизайн; медленнее
Condition Ждать изменения состояния Всегда while not predicate: cond.wait(), никогда if
Event Одноразовый сигнал «поехали» clear() после set() — гонка
Semaphore Ограничить параллелизм N Забыли release() при исключении — используйте with
Barrier Синхронная точка встречи Один упавший поток вешает всех остальных
threading.local Состояние на поток В asyncio не работает — там contextvars

Порядок захвата двух локов — самая частая причина настоящих дедлоков. Правило: захватывать всегда в одном глобальном порядке (например, по id() объекта) либо не захватывать больше одного.

Что в потоках болит именно в Python

  • Поток нельзя убить. Нет Thread.kill(). Только кооперативная отмена через threading.Event, который воркер проверяет сам.
  • daemon=True не «фоновый», а «убить без предупреждения». При выходе интерпретатора демон-потоки прерываются в произвольной точке — незакрытые файлы, недописанные буферы. Для фоновых задач используйте обычные потоки и явную остановку.
  • Исключение в потоке не долетает до main. Оно печатается через threading.excepthook и всё. Программа продолжит работать с «пропавшим» воркером. concurrent.futures эту проблему решает — исключение всплывает при future.result().
  • Сигналы приходят только в главный поток. Ctrl+C в воркере не сработает; signal.signal() из не-главного потока бросит ValueError.
  • Потоки не ускоряют импорт. Импорт защищён своими локами; параллельный импорт из нескольких потоков — известный источник загадочных зависаний.

Диагностика зависшего многопоточного процесса: faulthandler.dump_traceback_later(30) в коде или снаружи — py-spy dump --pid <PID>, который покажет стеки всех потоков без остановки процесса. Это первый инструмент, который стоит поставить в продакшн-образ.

concurrent.futures: фасад, с которого стоит начинать

threading и multiprocessing — низкий уровень. concurrent.futures даёт один API поверх обоих, и в прикладном коде почти всегда нужен именно он: смена одной строки переводит задачу с потоков на процессы.

from concurrent.futures import ThreadPoolExecutor, as_completed
import urllib.request

URLS = [f"https://httpbin.org/delay/1?i={i}" for i in range(20)]

def fetch(url: str) -> tuple[str, int]:
    with urllib.request.urlopen(url, timeout=10) as resp:
        return url, len(resp.read())

with ThreadPoolExecutor(max_workers=8, thread_name_prefix="fetch") as pool:
    futures = {pool.submit(fetch, u): u for u in URLS}
    for fut in as_completed(futures):          # результаты по мере готовности
        url = futures[fut]
        try:
            _, size = fut.result()             # здесь всплывёт исключение воркера
        except Exception as exc:               # noqa: BLE001 — логируем и идём дальше
            print(f"{url}: упало — {exc!r}")
        else:
            print(f"{url}: {size} байт")

Нюансы, которые стоят инцидентов:

  • pool.map() возвращает результаты в порядке аргументов и бросает исключение при итерации — удобно, но одна ошибка прерывает разбор всего. as_completed даёт результаты по готовности и позволяет обработать сбои поштучно.
  • Дефолт max_workers для потоков — min(32, cpu_count + 4). В контейнере с лимитом 0.5 CPU os.cpu_count() вернёт число ядер хоста, и вы получите 36 воркеров на полядра. С 3.13 есть os.process_cpu_count() (учитывает affinity), но cgroup-квоту он тоже не видит — берите лимит из переменной окружения, которую задаёт оркестратор.
  • Executor как контекстный менеджер ждёт все задачи. Нужен быстрый выход — pool.shutdown(wait=False, cancel_futures=True) (3.9+).
  • Гарантии Future — не транзакция. Про повторы и идемпотентность — в «Идемпотентности и доставке».

multiprocessing: параллелизм ценой копирования

Раз GIL один на интерпретатор — заведём несколько интерпретаторов, каждый в своём процессе. Это работает, даёт настоящее линейное ускорение на CPU-задачах и приносит целый класс новых проблем: у процессов нет общей памяти, всё, что пересекает границу, проходит через pickle.

Три способа породить процесс

Метод Как работает Где по умолчанию Плюсы и минусы
fork fork(2), потомок — копия родителя Linux до 3.14 Мгновенно, наследует всё; несовместим с потоками
spawn Новый python, импорт __main__, передача аргументов через pickle Windows, macOS Чисто и предсказуемо; старт ~100 мс, всё должно пиклиться
forkserver Один чистый процесс-«роддом», от него форки Linux с 3.14 Быстрый форк без наследования потоков

Смена дефолта на Linux в Python 3.14 — не косметика. fork в многопоточном процессе опасен принципиально: потомок получает копию памяти, но только один поток. Если в момент форка другой поток держал внутренний лок аллокатора или logging, в потомке этот лок останется захваченным навсегда — процесс повиснет на первом же print(). С 3.12 CPython предупреждает об этом DeprecationWarning. А в вашем процессе потоки почти наверняка есть: их заводят gRPC, OpenTelemetry, драйверы БД и сам ProcessPoolExecutor.

import multiprocessing as mp

if __name__ == "__main__":
    mp.set_start_method("forkserver", force=True)   # явно, до создания пулов

Диаграмма объясняет два самых частых сбоя. Первый: при spawn дочерний процесс импортирует ваш модуль, и весь код верхнего уровня выполняется заново. Без if __name__ == "__main__" это бесконечная рекурсия создания процессов. Второй: всё, что летает между процессами, должно быть picklable — лямбды, локальные функции, открытые сокеты, соединения с БД и объекты с __weakref__ не пройдут.

Обмен данными: от дорогого к дешёвому

from multiprocessing import shared_memory
import numpy as np

# Дорого: 400 МБ уедут через pickle в каждый воркер
# pool.map(process, [big_array] * 8)

# Дёшево: массив лежит в разделяемой памяти, воркеры видят одни и те же страницы
def make_shared(a: np.ndarray) -> tuple[shared_memory.SharedMemory, np.ndarray]:
    shm = shared_memory.SharedMemory(create=True, size=a.nbytes)
    view = np.ndarray(a.shape, dtype=a.dtype, buffer=shm.buf)
    view[:] = a[:]
    return shm, view
Механизм Стоимость передачи Когда
Аргументы map/submit pickle + копия, O(размер) Мелкие задания
mp.Queue, mp.Pipe pickle + копия Потоковая передача сообщений
mp.Value, mp.Array Общая память, C-типы Счётчики, флаги
shared_memory Ноль копий Большие numpy-массивы, кадры
Manager() pickle + сетевой round-trip к процессу-менеджеру Удобно, но в 100× медленнее — не для горячего пути
Файл / Arrow / Parquet на tmpfs Одна запись, mmap на чтение Крупные датасеты, см. «Работу с данными»

Отдельная тонкость про fork и copy-on-write. Кажется, что после форка большой глобальный словарь «бесплатно» доступен потомкам. На практике любое чтение объекта меняет его счётчик ссылок, а значит — записывает в страницу памяти, а значит — вызывает копирование. Так 4 ГБ «разделяемых» данных превращаются в 4 ГБ на воркер и OOM-kill. Инстаграм описал эту историю и обход через gc.freeze() в Dismissing Python Garbage Collection at Instagram.

Грабли multiprocessing, отсортированные по частоте

  1. Нет if __name__ == "__main__" → рекурсивный запуск (Windows, macOS, 3.14 Linux).
  2. PicklingError на лямбде или локальной функции. Обход: функция верхнего уровня, functools.partial, либо pathos/cloudpickle, если совсем никак.
  3. Дедлок q.put(big) → p.join(). Дочерний процесс блокируется, пока родитель не вычитает очередь; родитель ждёт join(). Всегда читайте очередь до join.
  4. BrokenProcessPool без объяснений. Обычно воркера убил OOM-killer. Смотрите dmesg, уменьшайте chunksize и объём аргументов.
  5. Логи вперемешку или потерянные. Несколько процессов пишут в один файл без блокировки. Решение — logging.handlers.QueueHandler в воркерах и один QueueListener в родителе (Logging Cookbook).
  6. Ctrl+C не работает. KeyboardInterrupt прилетает во все процессы группы; воркеры должны игнорировать SIGINT, а останавливать их должен родитель.
  7. Слишком много процессов. Больше, чем ядер, — только вред: память × N плюс конкуренция за кеш. Разумный дефолт — min(cpu_limit, len(tasks)).

asyncio: конкурентность без потоков

Третья модель отличается принципиально: задачи выполняются в одном потоке, а переключение происходит не по таймеру ОС, а в явных точках — на await. Это кооперативная многозадачность. Планировщик — цикл событий, живущий внутри вашего процесса.

Устройство цикла событий asyncio: очередь готовых, таймеры, селектор, мост в пул потоков

Цена входа — async def и await в сигнатурах; выгода — десятки тысяч одновременных соединений при памяти в килобайты на задачу вместо мегабайтов на поток.

import asyncio
import time

import httpx     # pip install httpx — асинхронный HTTP-клиент

URLS = [f"https://httpbin.org/delay/1?i={i}" for i in range(200)]

async def fetch(client: httpx.AsyncClient, url: str, sem: asyncio.Semaphore) -> int:
    async with sem:                                  # ограничиваем параллелизм
        resp = await client.get(url, timeout=10.0)
        return len(resp.content)

async def main() -> None:
    sem = asyncio.Semaphore(50)                      # не больше 50 одновременных запросов
    started = time.perf_counter()
    async with httpx.AsyncClient() as client:
        async with asyncio.TaskGroup() as tg:        # структурная конкурентность, 3.11+
            tasks = [tg.create_task(fetch(client, u, sem)) for u in URLS]
    total = sum(t.result() for t in tasks)
    print(f"{len(URLS)} запросов, {total} байт, {time.perf_counter() - started:.2f} c")

asyncio.run(main())
# 200 запросов, 1043200 байт, 4.61 c   (последовательно было бы ~200 c)

Три вещи в этом коде обязательны в проде и часто отсутствуют в туториалах: Semaphore (иначе вы уроните и себя, и чужой сервис), timeout на каждом запросе (без него зависшее соединение висит вечно) и TaskGroup вместо gather.

Жизненный цикл задачи и отмена

Отмена — самая недооценённая часть asyncio. task.cancel() не останавливает задачу мгновенно: он планирует выброс CancelledError в ближайшей точке await. Отсюда правила:

async def worker(queue: asyncio.Queue[str]) -> None:
    try:
        while True:
            item = await queue.get()
            await handle(item)
    except asyncio.CancelledError:
        await flush_partial_state()      # успеваем прибраться
        raise                            # ОБЯЗАТЕЛЬНО пробросить дальше
    finally:
        await close_connection()         # выполнится в любом случае

CancelledError наследуется от BaseException именно для того, чтобы её не съел случайный except Exception. Проглотить её и не пробросить — значит сломать TaskGroup, asyncio.timeout и корректное завершение сервиса.

TaskGroup против gather

# gather: при исключении в одной задаче остальные ПРОДОЛЖАЮТ работать —
# вы получаете первую ошибку и утечку «висящих» задач
results = await asyncio.gather(a(), b(), c())

# TaskGroup: исключение отменяет соседей, ждёт их завершения
# и отдаёт ExceptionGroup со всеми ошибками сразу
async with asyncio.TaskGroup() as tg:
    ta, tb, tc = tg.create_task(a()), tg.create_task(b()), tg.create_task(c())
results = [ta.result(), tb.result(), tc.result()]

Это и есть структурная конкурентность: задача не может пережить блок, в котором создана. Идея пришла из trio и статьи Натаниэля Смита «Notes on structured concurrency, or: Go statement considered harmful» — её стоит прочитать целиком, она меняет способ думать о конкурентности вообще.

Таймауты — тем же способом:

async with asyncio.timeout(2.5):        # 3.11+, отменяет всё внутри блока
    data = await slow_call()

Мост между синхронным и асинхронным миром

# Из корутины вызвать блокирующую функцию:
row = await asyncio.to_thread(legacy_db.query, sql)          # 3.9+

# Из обычного потока отдать работу работающему циклу:
fut = asyncio.run_coroutine_threadsafe(coro, loop)           # -> concurrent.futures.Future
result = fut.result(timeout=5)

# CPU-задача из корутины — через процессы, не через потоки:
loop = asyncio.get_running_loop()
with ProcessPoolExecutor() as pool:
    value = await loop.run_in_executor(pool, heavy_computation, payload)

Грабли asyncio

  1. Блокирующий вызов внутри корутины. time.sleep, requests.get, psycopg2, open().read() по сети, bcrypt.hashpw, json.loads на 100 МБ — останавливают все задачи. Ловится через loop.set_debug(True) и loop.slow_callback_duration = 0.1: медленные колбэки начнут логироваться.
  2. Забытый await. foo() без await создаёт объект корутины и ничего не делает. Вы получите RuntimeWarning: coroutine was never awaited — и молча неверный результат. Лечится mypy/ruff (правило RUF006, ASYNC), см. «Аннотации типов».
  3. Потерянная задача. Цикл хранит только слабые ссылки на задачи. Классика:
    asyncio.create_task(background_job())     # ссылки нет — GC может убить задачу
    
    Правильно — держать ссылку в множестве или использовать TaskGroup:
    _tasks: set[asyncio.Task] = set()
    t = asyncio.create_task(background_job())
    _tasks.add(t)
    t.add_done_callback(_tasks.discard)
    
  4. Смешивание циклов. asyncio.run() создаёт и закрывает свой цикл; объект, созданный в одном цикле (пул соединений, Lock), нельзя использовать в другом.
  5. CPU-нагрузка в цикле. Асинхронность не даёт параллелизма по CPU вообще. Тяжёлый счёт — только в ProcessPoolExecutor.
  6. «Заражение» цветом функции. Async-код вызывается только из async-кода. Это реальная архитектурная цена: половина библиотек существует в двух версиях. Проблему хорошо описал Боб Нистром в «What Color Is Your Function?».
  7. threading.local не работает. Для контекста запроса — contextvars.ContextVar (PEP 567), он корректно копируется в каждую задачу.

Ускорить цикл событий в 2–4 раза можно заменой реализации на uvloop (обёртка над libuv) — одна строка asyncio.set_event_loop_policy(uvloop.EventLoopPolicy()). Альтернативный подход к структурной конкурентности целиком — trio и совместимый слой AnyIO, на котором стоят Starlette и FastAPI (см. «Веб и API»).

Free-threading и субинтерпретаторы: что изменилось к 2026 году

Free-threading (PEP 703) — отдельная сборка интерпретатора (python3.14t), в которой GIL убран, а счётчики ссылок сделаны атомарными с оптимизациями (biased reference counting, immortal objects из PEP 683). В 3.13 это был эксперимент, в 3.14 сборка получила официально поддерживаемый статус (PEP 779).

Что это значит на практике в 2026 году:

  • Это не дефолтный Python. Обычный python3.14 по-прежнему с GIL. Free-threaded нужно ставить отдельно (uv python install 3.14t).
  • Однопоточный код в ней медленнее — разработчики свели просадку с десятков процентов в 3.13 до единиц процентов в 3.14, но она есть.
  • C-расширения обязаны быть пересобраны и помечены как совместимые. Если загружено несовместимое, интерпретатор сам включит GIL обратно (проверяется через sys._is_gil_enabled(), принудительно — PYTHON_GIL=1 или -X gil=1).
  • Ваши гонки, которые прятал GIL, станут видимыми. Код, полагавшийся на «атомарность» d[k] += 1, начнёт ломаться по-настоящему.

Субинтерпретаторы (PEP 734, модуль concurrent.interpreters и InterpreterPoolExecutor в 3.14) — средний путь: несколько интерпретаторов в одном процессе, у каждого свой GIL (это дал PEP 684). Изоляция как у процессов, старт дешевле, память общая на уровне процесса, но объекты между интерпретаторами не разделяются — обмен через очереди и разделяемые буферы.

# Python 3.14+
from concurrent.futures import InterpreterPoolExecutor

def work(n: int) -> int:
    return sum(i * i for i in range(n))

with InterpreterPoolExecutor(max_workers=4) as pool:
    print(list(pool.map(work, [10**6] * 4)))   # 4 GIL, 4 ядра, один процесс

Честная оценка: и то, и другое — правильное направление, но в 2026 году это ещё не то, на чём строят продакшн по умолчанию. Планируйте архитектуру исходя из наличия GIL и радуйтесь ускорению, когда сможете переехать.

Как выбирать: дерево решений

Сводная таблица — то, что стоит держать в голове:

Критерий threading multiprocessing asyncio
Параллелизм по CPU Нет Да Нет
Ускорение на I/O Да Да (дорого) Да, максимальное
Стоимость одной единицы ~8 МБ стека, ~50 мкс старт ~10–40 МБ, 20–100 мс старт ~2–10 КБ, ~1 мкс
Практический потолок сотни ядра × 1–2 десятки тысяч
Передача данных Общая память pickle / shared memory Общая память
Переключение Вытесняющее, ОС Вытесняющее, ОС Кооперативное, на await
Гонки данных Да Почти нет (изоляция) Только на await
Отладка Тяжёлая Средняя Средняя, стеки читаемые
Требует переписывать код Нет Немного Да, весь стек

Про измерение выигрыша: закон Амдала беспощаден. Если 20 % работы принципиально последовательны, максимальное ускорение — пятикратное, сколько ядер ни добавляй (1 / (s + p/N), где s — доля последовательной части). Плюс накладные расходы: сериализация, старт процессов, синхронизация. Отсюда типичная картина, когда ProcessPoolExecutor на восьми ядрах даёт ускорение в 3.5 раза, а на шестнадцати — в 3.7. Подробный разбор — в «Производительности конкурентного кода».

Честно о месте Python в конкурентном мире

Где Python выигрывает:

  • I/O-bound-задачи — интеграции, скрейперы, API-гейтвеи, оркестраторы, ETL-координация. Здесь GIL не мешает вообще, а скорость разработки — решающий фактор.
  • Обвязка вокруг нативных вычислений. numpy, PyTorch, Polars, DuckDB отпускают GIL и используют все ядра сами. Python здесь — язык склейки, и это его сильнейшая роль.
  • Пакетная обработка процессами. ProcessPoolExecutor или Celery/Dramatiq по одному воркеру на ядро — простая, надёжная и понятная архитектура для CPU-нагрузки.

Где проигрывает честно и заметно:

  • Многопоточные CPU-серверы с общим состоянием. Классическая архитектура «пул потоков над общим кешем в памяти» в CPython не работает. В Java или Go это дефолт.
  • Миллионы лёгких процессов с изоляцией отказов. BEAM (Erlang/Elixir) держит миллионы процессов с вытесняющим планировщиком и супервизорами; asyncio при этом кооперативен — одна «жадная» корутина стопорит всех. Сравните с «Конкурентностью в Elixir».
  • Предсказуемая латентность под нагрузкой. Кооперативное переключение плюс паузы GC дают длинный хвост p99. Для торговых систем и низколатентных прокси берут Go или Rust; каналы и горутины — в «Конкурентности в Go».
  • Data-race-безопасность на уровне компилятора. Rust ловит гонки типами; Python ловит их в проде.

Прагматичный вывод: не тащите в Python конкурентную нагрузку, которая упирается в CPU и требует общего изменяемого состояния между потоками. Всё остальное — его территория. И почти всегда правильный ответ на «нам нужно быстрее» — не «добавим потоков», а «вынесем горячий цикл в numpy/Rust» или «раскидаем по процессам и горизонтально масштабируем», см. «Продакшн-архитектуру».

Типичные ошибки: сводка

Симптом Причина Что делать
Потоки не ускоряют счёт GIL, чистый Python в цикле Процессы, numpy или C-расширение
Счётчик показывает не то x += 1 не атомарна Lock, queue, локальная агрегация
Программа зависла молча Дедлок на двух локах или очередь не вычитана py-spy dump, единый порядок захвата
Данные потерялись при выходе daemon=True прервал поток посреди записи Обычные потоки + явная остановка
Исключение исчезло Поток упал, main не знает concurrent.futures + future.result()
Бесконечный запуск процессов Нет if __name__ == "__main__" Добавить guard
PicklingError Лямбда, локальная функция, сокет в аргументах Функция верхнего уровня, partial
Процесс висит после fork Форк из многопоточного процесса forkserver или spawn
Пул умирает: BrokenProcessPool OOM-killer убил воркера Меньше данных на задачу, лимиты памяти
Память × N вместо разделяемой copy-on-write ломается о refcount shared_memory, gc.freeze()
asyncio-сервис «замирает» Блокирующий вызов в корутине to_thread, async-драйвер, set_debug(True)
Задача не выполнилась create_task без сохранённой ссылки TaskGroup или множество ссылок
RuntimeWarning: never awaited Забыт await Линтер, аннотации типов
Отмена не срабатывает Кода без await отменить нельзя Разбить долгий блок, вынести в поток
Сервис не выключается Проглочен CancelledError except CancelledError: ... ; raise
36 воркеров на 0.5 CPU os.cpu_count() вместо cgroup-лимита Читать лимит из окружения

Мини-итог

GIL — не «поломка Python», а конкретный инженерный компромисс: простота C-API и скорость однопоточного кода в обмен на параллелизм байт-кода. Из него следует всё остальное.

  • Ждёте I/O — берите asyncio при тысячах операций и наличии async-драйверов, ThreadPoolExecutor при десятках или при отсутствии асинхронных библиотек.
  • Считаете на чистом Python — только процессы, и следите, чтобы pickle не съел выигрыш.
  • Считаете внутри numpy/C — потоки уже работают, GIL там отпущен.
  • Разделяемое изменяемое состояние — либо явные локи, либо (лучше) очереди и изоляция; GIL вас не защитит.
  • Free-threading и субинтерпретаторы — реальное будущее, но проектируйте так, будто GIL есть, и получайте ускорение как бонус.

И главное правило, которое экономит недели: сначала профилировщик, потом конкурентность. Половина задач, которые «нужно распараллелить», решаются заменой квадратичного алгоритма, одним индексом в БД или убранным N+1-запросом — без единого потока.

Источники

Что дальше

Тестирование: pytest, фикстуры, моки, property-based, покрытие — как проверять код, который вы только что научились писать параллельно: фикстуры и их области видимости, pytest-asyncio для корутин, честные и нечестные моки, property-based тесты через Hypothesis и что на самом деле означает цифра покрытия.

Нашли неточность? Выделите фрагмент текста — рядом появится жучок.

Нужен разбор именно вашей ситуации?

Статья описывает общий случай. Если у вас частный — можно разобрать его отдельно, платно. А если не хватает целого материала, предложите тему: её оплачивают вскладчину, и она выходит открытой для всех.

Доска запросов