Прямой ответ
GIL (global interpreter lock) разрешает одному потоку исполнять байткод CPython в момент времени — поэтому потоки в Python не ускоряют CPU-нагрузку, но отлично работают для блокирующего I/O (сеть, диск, subprocess): пока поток ждёт ответа, GIL отпущен и другие потоки выполняются. Правило выбора: I/O-bound → потоки (ThreadPoolExecutor) или asyncio; CPU-bound → процессы (ProcessPoolExecutor/multiprocessing). Третий путь появился в Python 3.13: экспериментальная free-threaded сборка без GIL (PEP 703) — многопоточный CPU-параллелизм в одном процессе, но в 2026-м это пока опция для экспериментов, а не продакшн-дефолт: экосистема C-расширений догоняет.
Потоки: когда они правда помогают
Сценарий, где потоки дают кратный выигрыш без переписывания под async: десятки-сотни параллельных блокирующих вызовов (загрузка файлов, опрос API синхронным клиентом, запросы к нескольким БД). concurrent.futures.ThreadPoolExecutor с map/submit — весь нужный интерфейс: пул переиспользуется, будущее (Future) собирает результат/исключение. Ограничения: память на поток (стеки, по умолчанию мегабайты — сотни потоков ощутимы), переключение контекста, и главное — разделяемое состояние: потоки делят память процесса, и словари/счётчики без threading.Lock — гонки (GIL не делает операции атомарными «по смыслу»: counter += 1 — три операции). Для асинхронного стека потоки не нужны — там конкурентность даёт event loop (сравнение подходов — в статье про asyncio).
Процессы: настоящая параллельность и её цена
multiprocessing/ProcessPoolExecutor обходят GIL радикально: у каждого процесса свой интерпретатор и своя память — CPU-задача распараллеливается по ядрам почти линейно. Цена — передача данных: аргументы и результаты сериализуются (pickle) между процессами, поэтому «передать DataFrame на 2 ГБ в воркер и получить обратно» может съесть весь выигрыш; тяжёлые общие данные — через shared memory (multiprocessing.shared_memory), memory-mapped файлы или «данные остаются в воркере, наружу — только итог». Ловушки: старт-метод (fork на Linux быстрее, но опасен с потоками/соединениями; spawn — безопасный дефолт macOS/Windows — код воркера должен быть импортируемым, не в __main__-лапше); зависшие воркеры без таймаутов; пул на «все ядра» для задач с большой сериализацией — хуже умеренного.
Выше процесса: распределение и очереди
Когда одного ноутбука/сервера мало или работу надо переживать рестарты — параллелизм поднимается на уровень инфраструктуры: задачи уходят в очередь (Celery с воркерами на машинах — статья про Celery), батчи данных — во внешние движки (Spark/DuckDB), а Python-процессы остаются координаторами. Практический критерий: multiprocessing — внутри одного запуска/машины; очереди — когда нужны ретраи, масштабирование машин и переживание сбоев. Смешивать уровни «потому что можно» (вложенные пулы) — источник труднодиагностируемых зависаний.
Как выбрать: профилирование, а не догадки
Решение «потоки или процессы» принимается по профилю: если процесс спит в ожидании сети (визуально — низкий CPU при медленной работе) — I/O-bound, потоки/async; если CPU прижат к 100% одним ядром — CPU-bound, процессы (инструменты замера — в статье про профилирование). Частый промежуточный случай — «смешанный»: тяжёлый парсинг (CPU) между сетевыми вызовами (I/O) — тогда помогает комбинация: async для сети + to_thread/пул процессов для парсинга. И не забывайте про numpy-уровень: многие «CPU-задачи» уже векторизованы в C и GIL отпускают на время массивных операций — иногда «распараллеливание» не нужно вовсе.
Free-threading: что меняется и когда это брать
PEP 703 (принят, экспериментально в 3.13) — сборка CPython без GIL: настоящий многопоточный параллелизм в одном процессе, без сериализации между воркерами. Состояние на 2026 год: сборка доступна (python3.13t), производительность однопоточного кода слегка ниже, C-расширения постепенно портируются (numpy и крупные пакеты — в процессе), продакшн-кейсы появляются, но консервативный выбор — обычная сборка. Практический вывод: проектировать код так, чтобы параллелизм был изолирован (пулы, очереди) — тогда переход на free-threading в будущем будет заменой исполнения, а не переписыванием; следить за статусом в своих зависимостях, если CPU-параллелизм — узкое место продукта.
Практический сценарий: генерация миниатюр пакетом
Задача: ночной джоб делает миниатюры для пятидесяти тысяч изображений — типичная CPU-нагрузка (декодирование JPEG, ресайз, кодирование). Поточная версия (ThreadPoolExecutor) почти не быстрее однопоточной: PIL большую часть работы проводит в Python-коде и упирается в GIL (частичное освобождение GIL на C-операциях декодирования дело не спасает). Процессная версия: ProcessPoolExecutor(max_workers=8) и map(resize_one, paths, chunksize=64) — восемь процессов по ядрам, линейное ускорение; аргументы и результаты — короткие (путь → путь), сами изображения не сериализуются между процессами (каждый воркер читает свой файл с диска — передача данных сведена к нулю, главная ловушка multiprocessing обойдена). chunksize=64 важен: без него накладные расходы на IPC для каждого из пятидесяти тысяч мелких заданий съедают заметную долю выигрыша. Обработка ошибок — через результаты future (одно битое изображение не роняет прогон, ошибки собираются в лог); таймаут на задание — защита от зависшего декодера. Итог: сорок минут однопоточной работы превращаются в шесть на восьмиядерном ноутбуке. И честная проверка напоследок: профиль показывает, что узкое место переехало на чтение с диска — дальше оптимизировать CPU бессмысленно, следующий шаг — более быстрое хранилище или инкрементальная обработка.
FAQ
Почему «потоки не работают» — у меня же многоядерный CPU? Работают, но не для байткода Python: два потока не выполнят CPU-работу вдвое быстрее из-за GIL. Для CPU — процессы; для I/O потоки масштабируются нормально.
asyncio или потоки? Для тысяч соединений — asyncio (поток на соединение не масштазируется по памяти). Для десятков блокирующих операций и кода без async-стека — ThreadPoolExecutor проще и не требует асинхронных версий библиотек.
Сколько процессов в пуле?
Дефолт ProcessPoolExecutor — число ядер; для CPU-задач это разумный старт (больше — переключения и память без выигрыша). Для задач с ожиданием внутри — можно больше ядер, подбирая замером.
Правда ли, что Python «медленный из-за GIL»? GIL — не про скорость одного потока, а про параллельность нескольких. Одиночный поток с GIL и без — почти одинаков; «медленность» Python относительно C/Go — вопрос интерпретируемости, а не блокировки.
Источники: документация multiprocessing и concurrent.futures, PEP 703 — ссылки в шапке.