Конкурентность: 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 отпускается
Есть ровно три момента:
- По таймеру. Каждые
sys.getswitchinterval()секунд (по умолчанию 0.005 — 5 мс) владелец получает запрос «отдай GIL» и отдаёт его на ближайшей границе байт-код-инструкции. - На блокирующем системном вызове.
socket.recv,file.read,time.sleep,os.stat— весь такой код в CPython обрамлён макросамиPy_BEGIN_ALLOW_THREADS/Py_END_ALLOW_THREADSи работает без GIL. - Внутри C-расширений, которые явно об этом позаботились:
numpyв большинстве ufunc,hashlibна буферах больше пары килобайт,zlib,lxml,psycopg,cryptography.
Отсюда следует всё практическое поведение. Проверим измерением — это, кстати, единственный честный способ спорить о производительности (подробнее в «Производительности»).
# 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 CPUos.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, отсортированные по частоте
- Нет
if __name__ == "__main__"→ рекурсивный запуск (Windows, macOS, 3.14 Linux). PicklingErrorна лямбде или локальной функции. Обход: функция верхнего уровня,functools.partial, либоpathos/cloudpickle, если совсем никак.- Дедлок
q.put(big) → p.join(). Дочерний процесс блокируется, пока родитель не вычитает очередь; родитель ждётjoin(). Всегда читайте очередь доjoin. BrokenProcessPoolбез объяснений. Обычно воркера убил OOM-killer. Смотритеdmesg, уменьшайтеchunksizeи объём аргументов.- Логи вперемешку или потерянные. Несколько процессов пишут в один файл без блокировки.
Решение —
logging.handlers.QueueHandlerв воркерах и одинQueueListenerв родителе (Logging Cookbook). Ctrl+Cне работает.KeyboardInterruptприлетает во все процессы группы; воркеры должны игнорироватьSIGINT, а останавливать их должен родитель.- Слишком много процессов. Больше, чем ядер, — только вред: память × N плюс
конкуренция за кеш. Разумный дефолт —
min(cpu_limit, len(tasks)).
asyncio: конкурентность без потоков
Третья модель отличается принципиально: задачи выполняются в одном потоке, а
переключение происходит не по таймеру ОС, а в явных точках — на await. Это
кооперативная многозадачность. Планировщик — цикл событий, живущий внутри вашего процесса.
Цена входа — 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
- Блокирующий вызов внутри корутины.
time.sleep,requests.get,psycopg2,open().read()по сети,bcrypt.hashpw,json.loadsна 100 МБ — останавливают все задачи. Ловится черезloop.set_debug(True)иloop.slow_callback_duration = 0.1: медленные колбэки начнут логироваться. - Забытый
await.foo()безawaitсоздаёт объект корутины и ничего не делает. Вы получитеRuntimeWarning: coroutine was never awaited— и молча неверный результат. Лечитсяmypy/ruff(правилоRUF006,ASYNC), см. «Аннотации типов». - Потерянная задача. Цикл хранит только слабые ссылки на задачи. Классика:
Правильно — держать ссылку в множестве или использовать
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) - Смешивание циклов.
asyncio.run()создаёт и закрывает свой цикл; объект, созданный в одном цикле (пул соединений,Lock), нельзя использовать в другом. - CPU-нагрузка в цикле. Асинхронность не даёт параллелизма по CPU вообще. Тяжёлый
счёт — только в
ProcessPoolExecutor. - «Заражение» цветом функции. Async-код вызывается только из async-кода. Это реальная архитектурная цена: половина библиотек существует в двух версиях. Проблему хорошо описал Боб Нистром в «What Color Is Your Function?».
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 и радуйтесь ускорению, когда сможете переехать.
Как выбирать: дерево решений
cProfile, py-spy, time.perf_counter"] A -->|"да"| B{"Где процесс проводит время?"} B -->|"ждёт сеть, диск, БД"| C{"Сколько одновременных операций?"} B -->|"считает на чистом Python"| D{"Можно векторизовать?"} B -->|"считает внутри numpy / C"| E["Потоки работают:
GIL отпущен в C-коде.
Проверить, не многопоточен ли BLAS уже"] C -->|"десятки"| C1["ThreadPoolExecutor —
минимум изменений в коде"] C -->|"сотни и тысячи"| C2{"Есть async-драйверы
для всех зависимостей?"} C2 -->|"да"| C3["asyncio + TaskGroup
+ Semaphore + timeout"] C2 -->|"нет"| C4["asyncio + to_thread
для узких мест,
либо остаться на потоках"] D -->|"да"| D1["numpy / polars / pandas:
цикл уходит в C, GIL отпускается"] D -->|"нет"| F{"Данные большие?"} F -->|"нет"| F1["ProcessPoolExecutor,
workers = лимит CPU"] F -->|"да"| F2["shared_memory / Arrow / файл,
иначе pickle съест выигрыш"] F1 --> G{"Всё равно медленно?"} G -->|"да"| G1["Cython, Rust-расширение,
другой рантайм —
см. статью о производительности"]
Сводная таблица — то, что стоит держать в голове:
| Критерий | 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-запросом — без единого потока.
Источники
- Concurrent Execution — раздел
документации, объединяющий
threading,multiprocessing,concurrent.futures. - asyncio — Asynchronous I/O, особенно Developing with asyncio — список типичных ошибок от самих разработчиков.
- threading, multiprocessing с разделом Programming guidelines — прочитать целиком до первого продакшн-пула.
- David Beazley, Understanding the Python GIL и материалы на dabeaz.com — как GIL устроен изнутри.
- PEP 703 — Making the Global Interpreter Lock Optional, PEP 779 — Criteria for supported status for free-threaded Python, PEP 684 — A Per-Interpreter GIL, PEP 734 — Multiple Interpreters in the Stdlib.
- PEP 492 — Coroutines with async and await syntax, PEP 3156 — Asynchronous IO Support Rebooted, PEP 567 — Context Variables.
- Nathaniel J. Smith, Notes on structured concurrency —
почему
create_taskбез области видимости так же плох, какgoto. - Caleb Hattingh, Using Asyncio in Python — короткая и честная книга именно про asyncio.
- Matthew Fowler, Python Concurrency with asyncio — подробнее, с примерами интеграции потоков и процессов.
- Luciano Ramalho, Fluent Python, 2nd ed. — главы 19–21 про конкурентность, процессы и asyncio.
- py-spy и faulthandler — диагностика зависаний в проде.
- uvloop, trio, AnyIO — альтернативные и дополняющие рантаймы.
- What’s New in Python 3.14 — free-threading,
concurrent.interpreters, смена дефолтного start method.
Что дальше
Тестирование: pytest, фикстуры, моки, property-based, покрытие —
как проверять код, который вы только что научились писать параллельно: фикстуры и их
области видимости, pytest-asyncio для корутин, честные и нечестные моки, property-based
тесты через Hypothesis и что на самом деле означает цифра покрытия.