Конкурентность для I/O-скриптов: потоки, async, процессы
GIL пускает выполнять байткод по одному потоку за раз, но отпускается на блокирующем I/O — поэтому потоки и asyncio ускоряют I/O-скрипты, а CPU-работе нужны отдельные процессы. Подбирай инструмент под узкое место: ждём или жжём CPU.
Ночной скрипт отчёта забирал 500 страниц API, одну за другой, и тратил примерно восемь минут — почти всё это время процессор простаивал, ожидая сеть. Доброжелательный инженер «распараллелил» его так, как параллелят всё тяжёлое: multiprocessing. Время почти не сдвинулось — всё те же минуты, — а теперь машина ела гигабайты RAM, поднимая флот интерпретаторов, которые не делали ничего, кроме ожидания сокетов. Он схватил самый тяжёлый инструмент, какой есть в Python, для задачи, требовавшей самого лёгкого. Замена на ThreadPoolExecutor уронила тот же скрипт примерно до двадцати секунд, потому что 500 сетевых ожиданий наконец стали перекрываться, а не выстраиваться в очередь. Та же машина, та же сеть, два порядка разницы — целиком из-за выбора модели конкурентности, совпадающей с узким местом.
GIL: байткод выполняет один поток за раз
Прежде чем тянуться к любому инструменту конкурентности, нужна одна ментальная модель — иначе можно потратить час на рефакторинг и не получить никакого ускорения, ровно как инженер из вступления. Всё запутанное в конкурентности Python сводится к одному правилу. У CPython есть Global Interpreter Lock — GIL (глобальная блокировка интерпретатора, пускающая выполнять байткод ровно одному потоку за раз), — и он позволяет ровно одному потоку выполнять байткод Python в любой момент. Подними десять потоков с чистой Python-математикой — и они не побегут на десяти ядрах; они будут чередоваться на одном, нарезанные по времени. Вот почему потоки не ускоряют CPU-bound Python.
Поворот, который всё-таки делает потоки полезными: GIL отпускается, когда поток блокируется на I/O — чтение сокета, чтение диска, time.sleep, round trip к базе, большинство вызовов C-расширений. Пока один поток сидит и ждёт байты, GIL свободен, и работает другой поток. Поэтому для работы, тяжёлой по I/O — а это большинство скриптинга: HTTP, файлы, базы, — потоки реально перекрывают ожидание, а именно в ожидании и уходит всё время.
# Это вся ментальная модель:
# CPU-bound Python -> GIL УДЕРЖИВАЕТСЯ -> потоки нарезают одно ядро по времени (прироста нет)
# блокирующий I/O -> GIL ОТПУСКАЕТСЯ -> потоки перекрывают ожидания (большой прирост)
#
# Поэтому единственный вопрос, выбирающий инструмент:
# «Этот скрипт ЖДЁТ I/O или СЖИГАЕТ CPU?»(Python 3.13 поставляет экспериментальную free-threaded-сборку, которая может отключить GIL, но она opt-in и пока не дефолт — считай, что GIL держится.)
Потоки: лёгкий выигрыш на I/O
Для I/O-bound-задачи concurrent.futures.ThreadPoolExecutor — выигрыш с наименьшими усилиями во всём языке. Ты отправляешь работу; пул прогоняет её через горстку потоков; каждый отпускает GIL в момент, когда блокируется на сети, так что ожидания складываются параллельно.
import concurrent.futures
import requests
def fetch(url: str) -> int:
return len(requests.get(url, timeout=10).content)
def fetch_all(urls: list[str]) -> list[int]:
# 100 URL перекрывают сетевые ожидания в ~16 потоках.
# GIL отпускается во время каждого requests.get(), поэтому они реально перекрываются.
with concurrent.futures.ThreadPoolExecutor(max_workers=16) as pool:
return list(pool.map(fetch, urls))Подвох — накладные расходы на поток: каждый несёт настоящий OS-поток со своим стеком и ценой переключения контекста. Пул из десятков-сотен — нормально; десятки тысяч потоков — нет, ты захлебнёшься в памяти и в свистопляске планировщика. Когда тебе нужно столько одновременных соединений, ты перерос потоки и хочешь asyncio.
Asyncio: тысячи соединений на одном потоке
asyncio — это однопоточная кооперативная конкурентность. Один event loop перемежает тысячи I/O-операций в полёте, и каждый await — это точка, где корутина добровольно уступает loop, пока ждёт, так что loop запускает кого-то ещё. Никакого OS-потока на задачу, куда меньше памяти, поэтому масштабируется до тысяч одновременных соединений там, где потоки бы задохнулись.
import asyncio
import httpx # ASYNC HTTP-клиент — requests синхронный и блокировал бы цикл
async def fetch(client: httpx.AsyncClient, url: str) -> int:
resp = await client.get(url, timeout=10) # уступает цикл во время ожидания
return len(resp.content)
async def fetch_all(urls: list[str]) -> list[int]:
async with httpx.AsyncClient() as client:
# gather планирует все корутины на одном цикле; тысячи перекрываются дёшево
return await asyncio.gather(*(fetch(client, u) for u in urls))Цена в том, что asyncio — это всё-или-ничего. Кооперативность означает, что одна корутина, которая не уступает, морит голодом все остальные. Блокирующий синхронный вызов (requests.get, обычный time.sleep, тяжёлый json.loads над огромным payload) внутри корутины застопорит весь loop — каждое другое соединение замрёт, пока он не вернётся. Так что либо ты идёшь async до самого низа (async HTTP-клиент, async-драйвер БД), либо выталкиваешь блокирующую/CPU-работу с loop через loop.run_in_executor(...), чтобы loop оставался отзывчивым.
▸Почему это работает
Почему потоки ускоряют 500 HTTP-запросов, но ничего не дают на ресайзе 500 изображений в том же CPython? Потому что GIL пускает выполнять байткод Python лишь по одному потоку за раз, но он отпускается, пока поток блокируется на I/O — на чтении сокета. Во время HTTP-запроса поток просто ждёт байты; процессор и так простаивает, поэтому 500 потоков перекрывают свои простои-ожидания, и wall-clock схлопывается примерно до одного ожидания. Ресайз изображения — наоборот: это чистый Python/нативный compute, который держит GIL всё время, так что 500 потоков просто нарезают одно ядро по времени и заканчивают не быстрее одного. Только отдельные процессы — каждый со своим интерпретатором и своим GIL — гоняют этот compute на нескольких ядрах сразу. Решает рабочая нагрузка, а не синтаксис, купит ли тебе конкурентность хоть что-то.
Процессы: настоящий параллелизм для CPU-работы
Когда узкое место — процессор (ресайз изображений, парсинг больших файлов, хеширование, числодробление), никакое количество потоков не поможет, потому что GIL сериализует compute. Тебе нужен настоящий параллелизм, что в CPython означает настоящие OS-процессы через ProcessPoolExecutor (или multiprocessing). Каждый воркер — отдельный интерпретатор со своим GIL, так что они бегут на отдельных ядрах одновременно.
import concurrent.futures
from PIL import Image
def resize(path: str) -> str:
img = Image.open(path) # CPU-bound работа, УДЕРЖИВАЮЩАЯ GIL
img.thumbnail((256, 256))
out = path.replace(".jpg", "_thumb.jpg")
img.save(out)
return out
def resize_all(paths: list[str]) -> list[str]:
# ProcessPoolExecutor -> N интерпретаторов на N ядрах; GIL больше не сериализует.
with concurrent.futures.ProcessPoolExecutor() as pool:
return list(pool.map(resize, paths))Процессы не бесплатны. Каждый платит стоимость старта, а аргументы и возвращаемые значения пересекают границу процесса, будучи запикленными (сериализованными) и отправленными через IPC. Эти накладные окупаются только на увесистой CPU-работе; запустить пул процессов по тысячам крошечных задач или по I/O-работе — значит купить накладные без всякой выгоды.
| Инструмент | Лучше для | Масштаб | Подвох |
|---|---|---|---|
| ThreadPoolExecutor | I/O-bound (HTTP, диск, БД) | Десятки-сотни | Память на поток + цена переключения |
| asyncio | Высококонкурентный I/O | Тысячи соединений | Один блокирующий/CPU-вызов стопорит весь loop |
| ProcessPoolExecutor | CPU-bound (ресайз, парсинг, крипто) | ~N ядер | Старт + pickle/IPC на задачу |
Две классические ошибки
Обе настоящие, обе частые, обе — один и тот же диагноз, прокрученный в обратную сторону. Ошибка первая: использовать multiprocessing для I/O-bound-задачи — тот восьмиминутный fetch-скрипт из вступления. Ты платишь тяжёлый старт процессов и pickle-накладные и ешь RAM, без выигрыша над потоками, потому что узким местом никогда не был процессор; это была сеть, а процессы не заставят сокеты возвращаться быстрее. Ошибка вторая: использовать потоки (или asyncio) для CPU-bound-задачи — ресайз 1000 изображений или скрипт, дробящий CSV, — и увидеть нулевое ускорение, а потом тупо пялиться в это. GIL сериализовал твой Python-compute всё время; потоки только и умеют перекрывать ожидание, а перекрывать было нечего. Тот CSV-скрипт остался ровно таким же медленным с потоками, как и без них, пока кто-то не переключил его на ProcessPoolExecutor — и он наконец растёкся по ядрам.
Диагноз всегда один и тот же единственный вопрос: скрипт ждёт I/O или жжёт CPU? Ждёт → потоки или asyncio. Жжёт → процессы. Классифицируй верно — и инструмент очевиден; ошибись — и добавишь сложность без ускорения.
Скрипт отчёта забирает 500 страниц API и сейчас гоняет их последовательно за ~8 минут. Узкое место — сетевые round trip, а не локальные вычисления. Как сделать его быстрым?
В CPython какую рабочую нагрузку потоки реально ускоряют и почему?
Нужно забрать 5000 URL из скрипта с минимальными накладными. Какая модель подходит и какова единственная оговорка?
- 01Что такое GIL, почему потоки НЕ ускоряют CPU-bound Python и почему ускоряют I/O-bound-работу?
- 02Сравни ThreadPoolExecutor, asyncio и ProcessPoolExecutor: для чего каждый, как далеко масштабируется и его главная цена — и назови две классические ошибки.
Каждое решение о конкурентности в Python сводится к GIL: CPython пускает выполнять байткод Python ровно одному потоку за раз, но он ОТПУСКАЕТ GIL, когда поток блокируется на I/O — чтение сокета, чтение диска, time.sleep, round trip к БД. Поэтому потоки нарезают одно ядро по времени для CPU-bound Python (без ускорения), но реально перекрывают ОЖИДАНИЕ для I/O-bound-работы, куда уходит большая часть времени скриптинга. Это даёт три инструмента под нагрузку. ThreadPoolExecutor — лёгкий I/O-bound-выигрыш — перекрыть HTTP/диск/БД-ожидания через горстку потоков — хорош в десятках-сотнях, пока не кусаются память на поток и цена переключения контекста. asyncio — однопоточная кооперативная конкурентность: один event loop дёшево перемежает тысячи соединений в полёте, но это всё-или-ничего, так что блокирующий синхронный вызов или CPU-тяжёлый шаг внутри корутины стопорит весь loop — используй async-библиотеки повсюду или выноси через loop.run_in_executor. ProcessPoolExecutor даёт настоящий параллелизм для CPU-bound-работы (ресайз, парсинг, крипто), потому что каждый воркер — отдельный интерпретатор со своим GIL на N ядрах, ценой старта процесса и pickle аргументов и результатов через IPC. Две классические ошибки — зеркальные: multiprocessing для I/O-bound fetch (тяжёлые накладные и RAM, без выигрыша — узкое место сеть) и потоки или asyncio для CPU-bound-работы (без ускорения — GIL сериализует compute, нужны были процессы). Один вопрос диагностирует оба: скрипт ждёт I/O или жжёт CPU? Теперь, когда увидишь медленный скрипт, — задай этот вопрос первым: ответ сам подсказывает инструмент.
Практика
Начни сверху. Задачи идут от простого к сложному: вспомнить факт, применить к случаю, затем senior-уровень. Открой, попробуй, потом открой ответ.
Что-то непонятно?
Задай вопрос по этому уроку. Вопросы анонимны и попадают напрямую автору — урок станет лучше.