Прямой ответ
Потоки в Python живут под GIL (глобальная блокировка интерпретатора — байткод исполняется одним потоком за раз — подробно), но это не делает их бесполезными: во время IO-ожиданий (сеть/диск/БД — поток отпускает GIL) другие потоки работают — поэтому пул потоков даёт почти линейное ускорение на IO-параллелизме (100 HTTP-запросов по 200 мс последовательно — 20 с; ThreadPoolExecutor(20) — ~1.2 с). Рабочая лошадка — concurrent.futures.ThreadPoolExecutor: with ThreadPoolExecutor(max_workers=10) as ex: results = list(ex.map(fetch, urls)) — пул, map/submit, futures с результатами/исключениями, контекст-менеджер ждёт завершения (выход из with без ожидания — «результаты потерялись/приложение вышло раньше» — классика); submit+as_completed — обработка по мере готовности (не в порядке запуска — кто быстрее); исключения внутри задачи не валят пул — прилетают в future (fut.result() перебросит — «молча проглоченная ошибка в потоке» — проверять result обязателен). Когда потоки — правильный инструмент: блокирующие библиотеки без async-аналогов (драйверы/SDK/legacy — requests в асинхронном сервисе через executor, или просто параллельный скрипт), скрипты и батчи (собрать 500 URL, обработать файлы — asyncio избыточен — потоки проще), вызовы внешних сервисов с ограниченной конкурентностью (пул = ограничение — «не более 5 одновременных к партнёру» — max_workers как rate-limit); когда не потоки: CPU-bound (GIL — ускорения нет — процессы (multiprocessing)/C-расширения, отпускающие GIL (numpy/полимирование изображений частично)), высококонкурентные серверные приложения (10k соединений — потоки дорогие (~8 МБ стека по умолчанию, контекст-переключения) — asyncio (асинхронность); гибрид — стандарт: asyncio-ядро + executor для блокирующего («asyncio.to_thread(func, arg)» — одна строка — мост в пул). Дисциплина: размер пула под задачу (IO: 10–50 типично, не «1000» — переключение контекста и лимиты серверов-партнёров; формула-эвристика N×(1+wait/compute) — отправная точка, мерить обязательно), общие ресурсы под замками (счётчики/кеш — threading.Lock — гонки в многопоточке реальны как везде), и никакого разделяемого изменяемого состояния без нужды (результаты через futures/очереди — не через «общий список, в который все append» — append атомарен случайно, составные операции — нет).
ThreadPoolExecutor: паттерны использования
Базовые: map (порядок входа сохранён, ленивая выдача, исключения при итерации — удобно для «обработать список одинаково»); submit + as_completed (кто готов — того обрабатываем — «первые 10 готовых»/прогресс-бар по факту); wait с timeout (concurrent.futures.wait(futs, timeout=30, return_when=FIRST_EXCEPTION) — контроль зависших (future не отменяется таймаутом автоматически — «отмена» запущенной задачи в потоках невозможна (поток не прервать безопасно — только флаг-кооперация if stop_event.is_set(): return — долгая задача проверяет флаг — отмена в asyncio честнее — ещё один аргумент async для отменяемых сценариев); initializer (ThreadPoolExecutor(initializer=setup_conn) — настройка на поток (соединение/thread-local) — паттерн «свой ресурс на воркер»); thread_name_prefix (имена потоков в логах/трейсах — отладка «какой поток завис» — профилирование — dump стеков по потокам сразу читаем); thread-local данные (threading.local() — соединение/контекст на поток — не передавать аргументами всё — но и не злоупотреблять (невидимые зависимости); в asyncio-мире аналог — contextvars (asyncio); очереди (queue.Queue — потокобезопасная — producer/consumer между потоками — классика до executor'ов, жива в событийных архитектурах); границы с asyncio: loop.run_in_executor(pool, fn, *args) / asyncio.to_thread(fn, *args) (блокирующее в пул — event loop свободен — httpx-синхронный внутри async-сервиса — мост; обратно — asyncio.run_coroutine_threadsafe(coro, loop) — из потока в луп — редко нужно, но знать); размер пула executor'а в asyncio-приложении — отдельная настройка (дефолт min(32, cpu+4) — для тяжёлого блокирующего — свой пул с своим размером — не смешивать «быстрые» to_thread и «долгие» загрузки в одном пуле — head-of-line блокировка внутри пула реальна).
GIL, гонки и отладка
Что GIL не делает: не делает операции атомарными (counter += 1 — чтение+сложение+запись — между ними переключение — потерянные обновления; d[k] = d.get(k,0)+1 — то же; атомарно случайно — отдельные байткоды (append в list) — полагаться нельзя — CPython-деталь, не гарантия языка); защита — threading.Lock (критическая секция), RLock (реентерабельность), атомарные структуры из queue (Queue/Counter с замком), неизменяемость (данные не делятся — не защищаются); deadlock — два потока, два замка в разном порядке — классика (фикс: единый порядок захвата/timeout на acquire — lock.acquire(timeout=5)); производительность замков — мелкие секции (замок на весь обработчик — сериализация — пул теряет смысл); отладка: faulthandler.dump_traceback_later(10) (зависание — дампы стеков всех потоков — «где stuck»), threading.enumerate() + стек-трейсы (живые потоки), логи с именами потоков (thread_name_prefix — логи — threadName в форматтере), race-баги — воспроизведение стрессом (1000 итераций × пул — гонка вылезает; тесты на гонки — отдельный жанр — pytest); future-proof: PEP 703 (free-threaded CPython — 3.13+ экспериментальный режим без GIL — «потоки как в других языках» — горизонт есть, но код с невыявленными гонками на free-threading развалится — замки/дисциплина не устаревают — писать потокобезопасно нужно уже сейчас — миграция на 3.13 — free-threading как следующий шаг экосистемы). Процессы против потоков — сводка выбора: IO-параллелизм — потоки/asyncio (дешевле); CPU — процессы (GIL обходится — память дороже — fork/IPC); блокирующая библиотека в async — потоковый мост; «всё в одном» сценарии — комбинирование (asyncio-оркестрация + process-pool для тяжёлого + thread-pool для блокирующего — три инструмента на своих местах — процессы).
Практический сценарий: выгрузка 40 отчётов из чужого API
Исходник: скрипт ежемесячной сверки — 40 отчётов из SaaS-партнёра (REST, лимит 10 одновременных соединений, каждый 5–40 с) — последовательно — 18 минут (окно выгрузки — 10 минут — не укладывается); API асинхронного клиента не имеет (SDK синхронный — requests-обёртка — клиенты); asyncio заводить ради скрипта — избыточно. Решение на потоках: (1) ThreadPoolExecutor(max_workers=8) — под лимит партнёра с запасом (8 < 10 — ретраи на 429 уже были — пул+ретраи не конфликтуют); (2) submit на отчёт + as_completed (готовые сразу в обработку — парсинг+запись в Postgres потокобезопасны: каждая задача — своё соединение из пула (сессии/пул — connection per thread — SQLAlchemy-пул рассчитан на это), общих изменяемых структур нет (результаты — через futures); (3) прогресс и ошибки — as_completed с логированием (имена потоков в логе — «report-3 в потоке sync_2 failed: timeout» — диагностика мгновенна); зависшие — wait(timeout=300) на весь набор + флаг-кооперация в обёртке запроса (отмена «вечно висящего» отчёта — пропуск с пометкой — добор следующим прогоном); (4) замеры: 18 мин → 2 мин 40 с (×7 при 8 воркерах — почти линейно — чистый IO-профиль; попытка 16 воркеров — 2 мин 35 с (не быстрее — лимит партнёра уперся + 429 участились — 8 — оптимум — замер решил, не формула); (5) регресс-стражи: тест на пул-обёртку (мок-API с задержками — «8 одновременных максимум» проверяется счётчиком в моке), тест на ошибку задачи (exception в future — обработан/заложен в результат — не проглочен); (6) рост — в план: при 400 отчётах — тот же скрипт с очередью (Celery) вместо пула — скриптовый пул живёт до реальной необходимости инфраструктуры (не раньше — инфраструктурная дисциплина). Итог: окно выгрузки закрыто с ×4 запасом, ноль новых зависимостей, код — 40 строк с executor'ом; урок в заметки команды: «IO-скрипт — потоки прежде asyncio — сложность должна соответствовать задаче» (и обратное — сервер на 5k соединений — asyncio прежде потоков — выбор по контексту).
FAQ
Сколько потоков в пуле — правильно? Отправные точки: чисто IO (сеть) — 10–50 (ограничители — лимиты целевых сервисов/файловые дескриторы, не CPU); формула-эвристика workers = N_cpu × (1 + время_ожидания/время_счёта) (запрос 200 мс ждёт, 5 мс считает — соотношение 40 — теоретически сотни потоков — практически упираемся в партнёра/память — десятки); смешанные — мерить ступенями (4/8/16/32 — график время-воркеры — плато — оптимум — замеры в миниатюре); «max_workers=1000» — переключения контекста, память стеков (8 МБ × 1000 — виртуально много, физически меньше, но fd/лимиты — реальны), троттлинг партнёров — почти всегда хуже умеренного пула.
Почему мои потоки не ускоряют вычисления? GIL: CPU-bound байткод исполняется одним потоком (пул из 8 потоков на числодроблении — то же время ±накладные — даже медленнее); исключения — C-расширения, отпускающие GIL (numpy/pandas-векторизация частично, zlib/хеширование — внутри C-вызовов параллелизм есть — «numpy в 4 потока быстрее» — не противоречие, а C-уровень); решения CPU-bound: ProcessPoolExecutor (процессы — тот же API — drop-in замена), векторизация (одно ядро быстрее 8 наивных — pandas-векторизация), C/Rust-расширения, free-threaded 3.13+ (экспериментально — PEP 703); проверка «а точно ли это CPU» — профилировщик (py-spy) — часто «вычисления» на деле IO или ожидание замка.
Потоки против asyncio — что выбрать для нового сервиса? По профилю: тысячи одновременных соединений/долгих потоков данных (WebSocket/SSE/медленные клиенты) — asyncio (поток на соединение не масштабируется); умеренная конкурентность (десятки-сотни) с блокирующим стеком (ORM/SDK без async) — потоки проще и честнее (uvicorn/gunicorn sync-воркеры — веб-серверы); смешанный стек — asyncio-ядро + to_thread/executors (блок-зоны изолированы, ядро масштабируется); миграция существующего — постепенная (блокирующее в executor — ядро на async по мере надобности — «переписать всё на async за спринт» — источник аварий — миграционная дисциплина).
Как безопасно останавливать потоки?
Принудительно — никак (поток не убивается извне безопасно — PyErr_SetAsyncException-класс трюков — нестабильно/запрещено практикой); кооперативно: threading.Event флаг (while not stop.is_set(): — задача проверяет между итерациями), таймауты на всех блокирующих операциях (очередь.get(timeout=) — поток просыпается и видит флаг — «поток висит в get() вечно» — таймаут решает), daemon=True для фоновых (интерпретатор выходит не дожидаясь — но уборка не гарантируется — для «можно потерять»), executor.shutdown(wait=True, cancel_futures=True) (3.9+ — отмена незапущенных — запущенные доделываются — graceful-контракт); в серверах — обработчики сигналов ставят флаг (shutdown) — потоки дорабатывают и выходят — SIGKILL после grace-периода — крайняя мера.
Источники: документация Python (concurrent.futures, threading), PEP 703 — ссылки в шапке.