Перейти к основному содержимому

Асинхронность и многопоточность в Python

Разработчику Архитектору

Асинхронность и многопоточность

Поточность в Python

Python, как и JavaScript, изначально проектировался как однопоточный язык в контексте выполнения пользовательского кода. Однако это утверждение требует уточнений: оно справедливо только в рамках одного процесса и при работе с глобальной блокировкой интерпретатора (GIL). Чтобы понять архитектурные возможности Python в области параллелизма и конкурентности, необходимо разграничить три ключевые концепции: многопоточность, многопроцессность и асинхронность. Каждая из них решает задачи эффективного использования ресурсов, но делает это разными способами, с разной семантикой и в разных условиях.


Процессы и потоки

Процесс — это независимое пространство выполнения, обладающее собственной памятью, файловыми дескрипторами и системными ресурсами. Процессы изолированы друг от друга — обмен данными между ними требует явных механизмов межпроцессного взаимодействия (IPC), таких как каналы, очереди или общая память.

Поток (thread) — это последовательность исполнения внутри процесса. Все потоки одного процесса разделяют его адресное пространство, что позволяет им легко обмениваться данными через общие переменные. Однако эта общность также порождает риски гонок данных (race conditions), требующие синхронизации с помощью блокировок (locks) и других примитивов.


GIL

В большинстве языков высокого уровня (например, Java, C#) потоки являются настоящими системными потоками (native threads), управляемыми операционной системой. Python предоставляет доступ к таким потокам через модуль threading. Однако здесь возникает важная особенность. CPython — стандартная и наиболее распространённая реализация Python — содержит глобальную блокировку интерпретатора (Global Interpreter Lock, GIL). Это мьютекс, который гарантирует, что в каждый момент времени только один поток выполняет байт-код Python.

На практике это означает то, что в программе с несколькими потоками не может происходить истинный параллелизм выполнения Python-кода. Даже если система имеет несколько ядер CPU, все потоки CPython поочерёдно захватывают GIL, выполняя инструкции по одной. Параллелизм достигается только на уровне переключения контекста между потоками, но не на уровне одновременного выполнения.

GIL существует для защиты внутренних структур данных интерпретатора (например, счётчика ссылок) от повреждения при одновременном доступе из нескольких потоков. Он является компромиссом между производительностью в однопоточных сценариях и сложностью многопоточной безопасности.

GIL не блокирует выполнение всех операций. Когда поток выполняет операции, не связанные с интерпретатором (например, ввод-вывод, вызовы C-расширений, работу с сетью), он освобождает GIL, позволяя другим потокам работать. Таким образом, GIL не делает многопоточность бесполезной — она остаётся полезной для I/O-bound задач, но почти бесполезна для CPU-bound задач.

Free-threaded CPython (сборка без GIL, PEP 703/779) — отдельный вариант интерпретатора, не замена стандартной установки по умолчанию. См. историю и современные релизы.


threading

Модуль threading предоставляет высокоуровневый API для работы с потоками. Создание потока:


import threading

def worker():
print(f"Работаю в потоке {threading.current_thread().name}")

thread = threading.Thread(target=worker)
thread.start()
thread.join()

Разбор — Thread(target=worker) создаёт поток, start() запускает его выполнение, join() заставляет текущий поток дождаться завершения работы. Потоки в Python подходят для задач, где основное время уходит на ожидание внешних событий, таких как операции ввода-вывода (чтение файлов, сетевые запросы), взаимодействие с медленными устройствами, ожидание ответа от API, баз данных и т.д.

Однако из-за GIL они неэффективны для распараллеливания вычислений. Например, расчёт миллиона значений функции в десяти потоках не ускорится — все потоки будут по очереди захватывать GIL, и суммарное время выполнения будет близко к однопотовому варианту.


multiprocessing

Для CPU-интенсивных задач используется альтернатива — многопроцессность.

Модуль multiprocessing позволяет запускать Python-код в отдельных процессах. Каждый процесс имеет свой собственный интерпретатор и, соответственно, свой GIL. Это позволяет достичь истинного параллелизма на многоядерных системах.

from multiprocessing import Process

def compute_square(n):
result = n ** 2
print(f"{n}^2 = {result}")

p = Process(target=compute_square, args=(5,))
p.start()
p.join()

Разбор — Process запускает отдельный процесс с собственным интерпретатором, args передаёт параметры, join() синхронизирует завершение.

Каждый процесс живёт в своей памяти, поэтому обмен данными требует сериализации (например, через Queue, Pipe или Manager). Это накладывает издержки, но оправдано для задач, где выигрыш от параллелизма превышает затраты на передачу данных.

Таким образом, многопроцессность — это путь к параллелизму в Python, особенно для CPU-bound задач. Однако она требует больше ресурсов и сложнее в управлении по сравнению с потоками.


Асинхронность

Многопоточность и многопроцессность переключают задачи через операционную систему (вытесняющая многозадачность). asyncio чередует задачи в одном потоке на точках await (кооперативная многозадачность). Это конкурентность без загрузки нескольких ядер CPU.

В Python асинхронный код строят на event loop (цикле событий) и корутинах. Стандартный модуль — asyncio.

Продолжение по asyncio

Основы asyncio в Pythoncreate_task, gather, таймауты, Task и Future, синхронизация и типичные ошибки.


Кооперативная многозадачность

Корутина сама уступает управление через await. Пока она ждёт сеть, диск или таймер, event loop выполняет другие корутины.

Синхронный участок кода без await блокирует весь loop — остальные корутины в процессе ждут.

Практические следствия:

  • меньше накладных расходов, чем у потоков при большом числе соединений;
  • блокирующие вызовы (time.sleep, requests.get) нужно заменять на async-библиотеки или выносить в executor — см. Основы asyncio;
  • общая теория — Асинхронное выполнение.

Корутина

Корутина — это специальная функция, которая может приостанавливать своё выполнение и передавать управление обратно в событийный цикл, не блокируя поток. Она объявляется с ключевым словом async:


import asyncio

async def fetch_data():
print("Начинаю загрузку...")
await asyncio.sleep(1) # Имитация сетевой задержки
print("Данные получены")
return {"data": 42}

Разбор — async def объявляет корутину, await приостанавливает её и отдаёт управление event loop; пока одна корутина ждёт I/O, цикл может выполнять другие.

Вызов fetch_data() не запускает тело функции — возвращается объект корутины. Запуск через event loop:

async def main():
task = asyncio.create_task(fetch_data())
print("Задача запущена, продолжаю...")
result = await task
print("Результат:", result)

asyncio.run(main())

Разбор — create_task() ставит корутину в очередь цикла событий, await task получает итог, а asyncio.run() создаёт и закрывает event loop.

Несколько await подряд идут по очереди. Для одновременного старта нескольких операций сначала создайте задачи, затем дождитесь их:

async def main():
t1 = asyncio.create_task(fetch_data())
t2 = asyncio.create_task(fetch_data())
r1, r2 = await asyncio.gather(t1, t2)

Разбор — обе fetch_data начинают ожидание одновременно. Подробнее — Последовательное и конкурентное ожидание.


Событийный цикл

Событийный цикл — это центральный диспетчер, управляющий выполнением корутин. Он работает по следующему принципу:

  1. Поддерживает очередь готовых к выполнению задач (корутин).
  2. Запускает одну задачу.
  3. Если задача встречает await на незавершённой асинхронной операции (например, ожидание сети), цикл приостанавливает её и сохраняет состояние.
  4. Переключается к другой готовой задаче.
  5. Когда асинхронная операция завершается (например, получен ответ от сервера), цикл возобновляет соответствующую корутину.

Это аналогично Event Loop в JavaScript, но с одним важным отличием: в Python вы управляете циклом явно (через asyncio.run, get_event_loop и т.д.), и он работает в одном потоке.


Операции в асинхронном коде

Синхронный и асинхронный код различаются тем, как поток выполнения ждёт внешние ресурсы.

Блокирующая операция останавливает поток до конца работы. Примеры:

  • time.sleep(1)
  • requests.get(...)

Неблокирующая операция регистрирует запрос и сразу возвращает управление. Результат приходит позже, через await. Примеры:

  • await asyncio.sleep(1)
  • await client.get(...) в httpx/aiohttp

Блокирующий вызов внутри корутины останавливает весь event loop. Варианты:

  • async-библиотека вместо синхронной;
  • вынос в поток или процесс:
# Python 3.9+ — предпочтительный способ для блокирующего I/O
result = await asyncio.to_thread(blocking_function, arg)

# Универсальный вариант (в т.ч. ProcessPoolExecutor для CPU)
loop = asyncio.get_running_loop()
result = await loop.run_in_executor(None, blocking_function, arg)

Разбор — to_thread и run_in_executor выполняют синхронную функцию вне loop; подробнее — Типичные ошибки.


Когда выбирать асинхронность

asyncio хорошо подходит для I/O-bound нагрузки с большим числом одновременных операций ожидания:

  • веб-серверы на тысячи соединений — FastAPI, aiohttp;
  • клиенты с пачкой HTTP-запросов — Основы asyncio;
  • асинхронные драйверы БД — asyncpg, aiomysql — FastAPI и база данных;
  • WebSocket и долгоживущие соединения.

Весь стек вызовов должен быть согласован: синхронная библиотека внутри async def блокирует event loop.

Для CPU-bound задач (вычисления, обработка данных на CPU) asyncio не ускоряет работу — вычисление без await не отдаёт управление циклу. Здесь уместен multiprocessing.

Стандартный event loop написан на Python и имеет накладные расходы. Пакет uvloop — реализация на Cython и libuv (тот же низкий уровень, что у Node.js):


import asyncio
import uvloop

uvloop.install() # Устанавливает uvloop как default event loop
asyncio.run(main())

uvloop может ускорить выполнение асинхронных программ в 2–4 раза, особенно при большом числе одновременных соединений.

Краткая шпаргалка по выбору модели:

  • CPU-boundmultiprocessing
  • I/O-bound, мало параллельных задачthreading
  • I/O-bound, тысячи соединений в одном процессеasyncio
  • Веб плюс тяжёлые вычисления — asyncio для сети, отдельные процессы для CPU

asyncio — отдельная модель конкурентности, не замена многопоточности на уровне ОС.


Основа по протоколу

Базовый разбор HTTP и HTTPS находится в отдельной статье — HTTP как основа веб-интеграций.


Быстрый выбор подхода на практике

Если нужно принять решение за 30 секунд, используйте правило:

  • threading — когда много ожидания I/O и немного задач.
  • asyncio — когда много одновременных соединений и важна масштабируемость одного процесса.
  • multiprocessing — когда упираетесь в CPU и нужна загрузка нескольких ядер.

В реальных системах часто применяется гибрид: асинхронный веб-слой плюс отдельные процессы для тяжёлых вычислений.


Распространённые ошибки в первых проектах

  1. Использование requests внутри async def без run_in_executor или асинхронного клиента.
  2. Попытка ускорить CPU-задачу потоками в CPython.
  3. Смешивание sync/async без явных границ, из-за чего блокируется event loop.
  4. Отсутствие таймаутов и ограничений параллелизма.

Минимальная дисциплина для сетевого кода:


import asyncio

sem = asyncio.Semaphore(50)

async def bounded_task(coro):
async with sem:
return await coro

Разбор — Semaphore(50) ограничивает число одновременных операций. Подробнее о примитивах — Синхронизация в asyncio.


Практикум — последовательное и параллельное выполнение

Общий сценарий раздела Практикум по параллелизму — пять учебных URL с разной задержкой. Ниже четыре способа запустить те же "загрузки" и сравнить время.

Последовательно

Каждый вызов download блокирует поток до конца time.sleep. Второй URL начинается только после первого.

import time

def download(url, delay):
print(f"Загрузка {url}...")
time.sleep(delay)
return f"Данные с {url}"

urls = [("https://example.com/page1", 2.0), ("https://example.com/page2", 3.5)]

start = time.time()
for url, delay in urls:
download(url, delay)
print(f"Итого: {time.time() - start:.2f} с") # ~5.5 с

Потоки (I/O-bound)

Модуль threading создаёт потоки ОС внутри одного процесса. Память общая — см. Управление потоками.

  • Thread(target=...) — описание задачи;
  • start() — реальный запуск потока;
  • join() — главный поток ждёт завершения.
import threading

def run_threaded(urls):
threads = []
for url, delay in urls:
t = threading.Thread(target=download, args=(url, delay),
name=f"Поток-{url[-5:]}")
threads.append(t)
t.start()
for t in threads:
t.join()

Пять задержек 2 + 3.5 + 1.5 + 2.5 + 1.0 с последовательно дают 10.5 с. В потоках — около 3.5 с (максимум одной задержки).

asyncio

asyncioevent loop в одном потоке. await asyncio.sleep отдаёт управление циклу, пока "сеть" ждёт.

import asyncio

async def download_async(url, delay):
await asyncio.sleep(delay)
return f"Данные с {url}"

async def main():
tasks = [asyncio.create_task(download_async(u, d)) for u, d in urls]
await asyncio.gather(*tasks)

asyncio.run(main())

Подробнее — Основы asyncio.

concurrent.futures

ThreadPoolExecutor — пул потоков с удобным API submit и Future:

from concurrent.futures import ThreadPoolExecutor

with ThreadPoolExecutor(max_workers=5) as executor:
futures = [executor.submit(download, url, d) for url, d in urls]
results = [f.result() for f in futures]

Полная программа

Сравнение I/O, CPU, multiprocessing и ASCII-график — в каталоге примеров и в практикуме раздела:

Код ITЗагрузка примера кода…


Дальнейшее чтение