Короткий ответ
GIL (Global Interpreter Lock) в CPython разрешает только одному потоку выполнять Python-байткод в любой момент времени, поэтому потоки (threading) не ускоряют CPU-bound вычисления, но хорошо подходят для I/O-bound ожидания (сеть, диск), где GIL освобождается во время блокирующего вызова; процессы (multiprocessing) дают реальный параллелизм для CPU-bound кода за счёт отдельных интерпретаторов и памяти на каждый процесс, а asyncio даёт высокую конкурентность для I/O-bound задач в одном потоке без накладных расходов на переключение ОС-потоков, но не распараллеливает CPU-bound вычисления и требует неблокирующих (async-совместимых) библиотек на всём пути выполнения.
Как это работает подробнее
GIL — это механизм CPython (не свойство языка Python как спецификации), гарантирующий потокобезопасность интерпретатора за счёт того, что байткод одного потока выполняется, пока другие ждут; большинство операций блокирующего I/O (сетевой сокет, чтение файла) реализованы так, что GIL временно освобождается на время ожидания ОС, поэтому несколько потоков могут параллельно "ждать" ответ от разных внешних сервисов, даже не выполняя Python-код параллельно. CPU-интенсивный парсинг (регулярные выражения на больших данных, числовые вычисления на чистом Python) выполняется байткодом, который держит GIL почти всё время, поэтому добавление потоков для такой работы не даёт ускорения на многоядерной машине — для реального параллелизма вычислений нужны отдельные процессы (каждый со своим интерпретатором и GIL) или C-расширения, освобождающие GIL на время вычислений (например, некоторые операции NumPy).
Пример кода
import asyncio
from concurrent.futures import ProcessPoolExecutor
def cpu_heavy_parse(raw_text: str) -> dict:
# представим затратный CPU-bound парсинг на чистом Python
return {"length": len(raw_text)}
async def fetch_external(url: str) -> str:
await asyncio.sleep(0.1) # условная имитация сетевого I/O без блокировки GIL
return f"data from {url}"
async def handle_request(url: str, raw_text: str, pool: ProcessPoolExecutor):
loop = asyncio.get_running_loop()
# CPU-bound работа уходит в отдельный процесс, не блокируя event loop
parse_task = loop.run_in_executor(pool, cpu_heavy_parse, raw_text)
fetch_task = fetch_external(url)
parsed, fetched = await asyncio.gather(parse_task, fetch_task)
return parsed, fetchedЧто использовать на практике
- Практика. Для сервиса с сочетанием CPU-bound парсинга и I/O-bound сетевых вызовов разумный подход часто комбинированный: asyncio (или много лёгких потоков) для конкурентных сетевых запросов к внешним API, и выделение CPU-тяжёлого парсинга в отдельный пул процессов (
concurrent.futures.ProcessPoolExecutor) или отдельный сервис/воркер, чтобы не блокировать event loop и не держать GIL во время тяжёлых вычислений. Выбор зависит от объёма CPU-работы относительно I/O-работы, доступного числа ядер, накладных расходов на межпроцессное взаимодействие (сериализация данных между процессами) и от того, насколько библиотеки, нужные для сетевых вызовов, имеют async-совместимые версии.
Подводные камни
- Риски. Добавляют потоки, ожидая ускорения CPU-bound кода, и не видят прироста производительности на многоядерной машине, не понимая, что причина — GIL, а не плохая реализация потоков. Также переносят CPU-тяжёлую функцию внутрь async-обработчика без вынесения в отдельный процесс/executor, из-за чего она блокирует единственный поток event loop и замораживает обработку всех остальных конкурентных запросов на время её выполнения — это частая и опасная для продакшена ошибка "блокирующий код внутри асинхронного приложения".
- Компромисс. asyncio даёт лучшую масштабируемость по числу одновременных I/O-ожиданий при низких накладных расходах, но требует переписывания или оборачивания блокирующих вызовов и усложняет код (async/await всюду по цепочке вызовов). Процессы дают честный параллелизм для CPU-bound работы, но стоят дороже по памяти (отдельный интерпретатор на процесс) и требуют сериализации данных между процессами, что добавляет накладные расходы и ограничивает типы передаваемых объектов. Выбор правильной комбинации зависит от профиля нагрузки конкретного сервиса, а не от универсального правила "asyncio всегда быстрее" или "многопроцессность всегда лучше для надёжности".