open atlas
↑ К треку
Python для JS/TS-разработчиков PY · 02 · 04

Конкурентность для I/O-скриптов: потоки, async, процессы

GIL пускает выполнять байткод по одному потоку за раз, но отпускается на блокирующем I/O — поэтому потоки и asyncio ускоряют I/O-скрипты, а CPU-работе нужны отдельные процессы. Подбирай инструмент под узкое место: ждём или жжём CPU.

PY Senior ◷ 18 min
Уровень
ОсновыJuniorMiddleSenior

Ночной скрипт отчёта забирал 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-работе — значит купить накладные без всякой выгоды.

ИнструментЛучше дляМасштабПодвох
ThreadPoolExecutorI/O-bound (HTTP, диск, БД)Десятки-сотниПамять на поток + цена переключения
asyncioВысококонкурентный I/OТысячи соединенийОдин блокирующий/CPU-вызов стопорит весь loop
ProcessPoolExecutorCPU-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 из скрипта с минимальными накладными. Какая модель подходит и какова единственная оговорка?

Вспомните перед уходом
  1. 01
    Что такое GIL, почему потоки НЕ ускоряют CPU-bound Python и почему ускоряют I/O-bound-работу?
  2. 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-уровень. Открой, попробуй, потом открой ответ.

вспомнитьприменитьуглубить0 из 5 завершено

Что-то непонятно?

Задай вопрос по этому уроку. Вопросы анонимны и попадают напрямую автору — урок станет лучше.

хоткеи развернуть
поиск
K
пред. пьеса
k
след. пьеса
j
тиры
t
это меню
?
sources2
expand
  1. 01
  2. 02

Trademarks belong to their respective owners. Editorial reference only.