Python Async vs Threads: когда использовать asyncio
Почему конкурентность важна в Python
У вас есть скрипт на Python, который получает данные из нескольких API. Он работает, но медленно — каждый запрос ждёт завершения предыдущего. Вы слышали о asyncio и потоках, но что использовать? Выбор неправильной модели может привести к коду, который либо излишне сложен, либо мучительно медленный.
Это руководство развеет путаницу. Мы сравним модели async и потоков в Python, покажем, когда именно использовать каждую, и приведём практические примеры, которые вы сможете адаптировать для своих проектов.
Понимание ландшафта конкурентности в Python
Python предлагает три основные модели конкурентности:
- Потоки – несколько потоков выполнения в одном процессе, управляемые ОС. Ограничены глобальной блокировкой интерпретатора (GIL) для задач, нагружающих CPU.
- Async (asyncio) – однопоточный цикл событий, который переключается между задачами в точках
await. Отлично подходит для операций ввода-вывода. - Многопроцессность – отдельные процессы, каждый со своим интерпретатором Python и пространством памяти. Обходит GIL для истинного параллелизма.
GIL — это мьютекс, который предотвращает одновременное выполнение байт-кода Python несколькими потоками. Это означает, что потоки не ускоряют задачи, нагружающие CPU, но они помогают с задачами ввода-вывода, потому что GIL освобождается во время операций ввода-вывода.
Когда использовать asyncio
asyncio блистает, когда ваша программа проводит большую часть времени в ожидании внешних событий: сетевых запросов, файлового ввода-вывода, запросов к базе данных или таймеров. Поскольку он использует один поток, вы избегаете накладных расходов на создание потоков и переключение контекста. Цикл событий эффективно управляет тысячами одновременных соединений.
Используйте asyncio, когда:
- Вы создаёте веб-сервер или клиент, обрабатывающий множество одновременных соединений (например, FastAPI, aiohttp).
- Ваши задачи связаны с вводом-выводом и вам нужна высокая конкурентность (сотни или тысячи задач).
- Вы хотите тонкий контроль над планированием и отменой задач.
- Вы уже используете асинхронный фреймворк или библиотеку.
Вот простой пример, который получает несколько URL одновременно:
import asyncio
import aiohttp
async def fetch(url):
async with aiohttp.ClientSession() as session:
async with session.get(url) as response:
return await response.text()
async def main():
urls = [
'https://example.com',
'https://example.org',
'https://example.net',
]
tasks = [fetch(url) for url in urls]
results = await asyncio.gather(*tasks)
print(f'Fetched {len(results)} pages')
asyncio.run(main())
Когда использовать потоки
Потоки хорошо подходят, когда у вас есть блокирующие операции ввода-вывода, не имеющие асинхронных аналогов, или когда вы работаете с устаревшим кодом, не поддерживающим async. Они также проще для понимания при небольшом количестве задач.
Используйте потоки, когда:
- Вам нужно выполнять блокирующий ввод-вывод параллельно, например, чтение нескольких файлов или выполнение синхронных вызовов API.
- Вы интегрируетесь с библиотекой, не поддерживающей async.
- У вас умеренное количество одновременных задач (десятки, а не тысячи).
- Вы хотите сохранить код синхронным, но всё же перекрыть ввод-вывод.
Пример с использованием concurrent.futures.ThreadPoolExecutor:
from concurrent.futures import ThreadPoolExecutor
import requests
def fetch(url):
response = requests.get(url)
return response.text
urls = [
'https://example.com',
'https://example.org',
'https://example.net',
]
with ThreadPoolExecutor(max_workers=10) as executor:
results = list(executor.map(fetch, urls))
print(f'Fetched {len(results)} pages')
Сравнение asyncio и потоков
Вот сравнение бок о бок, которое поможет вам решить:
| Аспект | asyncio | Потоки |
|---|---|---|
| Модель конкурентности | Однопоточный цикл событий | Несколько потоков ОС |
| Лучше всего для | I/O-задачи, высокая конкурентность | I/O-задачи, блокирующие вызовы |
| Производительность на CPU | Плохая (GIL) | Плохая (GIL) |
| Масштабируемость | Тысячи задач | Сотни потоков |
| Сложность | Требует синтаксиса async/await | Знакомый синхронный стиль |
| Отладка | Может быть сложной с трассировками | Стандартные инструменты отладки |
| Экосистема | Растущая поддержка async | Универсальная |
А как насчёт задач, нагружающих CPU?
Ни asyncio, ни потоки не помогут вам распараллелить ресурсоёмкие вычисления в Python из-за GIL. Для задач, нагружающих CPU — таких как числовые вычисления, обработка изображений или сжатие данных — используйте multiprocessing или concurrent.futures.ProcessPoolExecutor.
Пример:
from concurrent.futures import ProcessPoolExecutor
def cpu_heavy(n):
return sum(i * i for i in range(n))
if __name__ == '__main__':
with ProcessPoolExecutor() as executor:
results = list(executor.map(cpu_heavy, [10**6, 10**6, 10**6]))
print(results)
Лучшие практики и подводные камни
- Не смешивайте блокирующие вызовы с asyncio. Если вы вызываете блокирующую функцию внутри асинхронной задачи, она заблокирует весь цикл событий. Используйте
asyncio.to_thread(), чтобы запустить блокирующий код в пуле потоков. - Ограничивайте размер пула потоков. Создание слишком большого количества потоков приводит к накладным расходам на переключение контекста. Используйте
ThreadPoolExecutor(max_workers=N). - Правильно обрабатывайте исключения. В asyncio необработанные исключения в задачах могут остаться незамеченными. Используйте
asyncio.gather(..., return_exceptions=True)или добавьте колбэки. - Предпочитайте asyncio для новых проектов с интенсивным вводом-выводом. Экосистема зрелая, а такие фреймворки, как FastAPI и aiohttp, упрощают работу.
- Используйте потоки для устаревших или простых скриптов. Если вам просто нужно распараллелить несколько блокирующих вызовов, потоки менее инвазивны.
FAQ
Могу ли я использовать asyncio и потоки вместе?
Да. Вы можете запускать блокирующий код в пуле потоков с помощью asyncio.to_thread() или loop.run_in_executor(). Это полезно, когда вам нужно вызвать синхронную библиотеку из асинхронного кода, не блокируя цикл событий.
Работает ли asyncio с GIL?
Да, asyncio работает в одном потоке и подчиняется GIL. Однако, поскольку он предназначен для задач ввода-вывода, GIL освобождается во время операций ввода-вывода, позволяя другим задачам выполняться. Для задач, нагружающих CPU, asyncio не даёт ускорения.
Что быстрее: asyncio или потоки?
Для задач ввода-вывода с множеством одновременных операций asyncio обычно быстрее и масштабируемее, поскольку избегает накладных расходов на потоки. Для небольшого числа блокирующих задач потоки могут быть проще и работать аналогично. Ни то, ни другое не помогает с задачами, нагружающими CPU.
Делаем правильный выбор
Начните с определения, является ли ваше узкое место вводом-выводом или CPU. Если это ввод-вывод и вам нужна высокая конкурентность, выбирайте asyncio. Если это ввод-вывод, но вы имеете дело с блокирующими библиотеками или более простыми скриптами, потоки — надёжный выбор. Для работы, нагружающей CPU, используйте multiprocessing.
Помните, что вы часто можете комбинировать эти модели — например, использовать asyncio для сетевых операций и пул потоков для файлового ввода-вывода. Ключ в том, чтобы понимать компромиссы и выбирать инструмент, подходящий для вашей конкретной задачи.
Когда вам нужно быстро отформатировать или проверить данные JSON, возвращённые из ваших параллельных вызовов API, попробуйте наш JSON Formatter для удобного форматирования и отладки.