← все задачи

Async Python · задача 7 из 10

Блокирующий код внутри асинхронного

Средний 15 минут to_threadexecutorGIL

Условие

В асинхронном обработчике нужно вызвать синхронную библиотеку: запрос через requests, хеширование пароля через bcrypt и разбор большого JSON. Как сделать это, не заблокировав цикл событий? Ответьте отдельно для операции ввода-вывода и для нагрузки на процессор.

Что требуется

  • Обработчик остаётся отзывчивым для других запросов
  • Решение разное для I/O и для CPU — объясните почему
  • Объясните, что произойдёт, если ничего не делать

Пример

@app.get("/user")
async def handler():
    data = requests.get(url).json()     # блокирует весь сервис
    hashed = bcrypt.hashpw(password)    # блокирует весь сервис

Сначала уточните

Вопросы до кода — половина оценки. Молча начать печатать хуже, чем задать два вопроса.

  • Есть ли асинхронный аналог библиотеки — httpx или aiohttp вместо requests?
  • Насколько тяжёлая операция по процессору: миллисекунды или секунды?
  • Сколько таких вызовов в секунду ожидается — от этого зависит размер пула
Показать решение Скрыть решение

Решение

import asyncio
from concurrent.futures import ProcessPoolExecutor

import bcrypt
import httpx


# 1. Ввод-вывод: лучше всего — асинхронная библиотека
async def fetch(url):
    async with httpx.AsyncClient() as client:
        response = await client.get(url)
        return response.json()


# 2. Если асинхронного аналога нет — поток
async def legacy_call(argument):
    return await asyncio.to_thread(sync_library_call, argument)


# 3. Нагрузка на процессор — процессы, а не потоки
process_pool = ProcessPoolExecutor(max_workers=4)


async def hash_password(password):
    loop = asyncio.get_running_loop()
    return await loop.run_in_executor(
        process_pool, bcrypt.hashpw, password, bcrypt.gensalt(),
    )

Почему так

Что именно происходит при блокирующем вызове

  • Цикл событий — один поток: пока в нём выполняется синхронный код, ни одна другая корутина не продвинется
  • Секундный requests.get означает секунду, в течение которой сервис не отвечает никому, а не только этому клиенту
  • Внешне это выглядит как «иногда тормозит» и почти не ловится без профилировщика — потому и спрашивают

Почему для I/O достаточно потока

  • На времени ожидания ответа GIL освобождается, поэтому поток не мешает основному
  • to_thread — это тот же run_in_executor с пулом потоков по умолчанию, только короче
  • Пул потоков не бесконечен: на тысяче одновременных вызовов это станет узким местом, и лучше искать асинхронную библиотеку

Почему для CPU нужны процессы

  • Вычисления держат GIL, и поток не даст выигрыша — блокировать будет ровно так же, просто в другом месте
  • Отдельный процесс имеет свой интерпретатор и свой GIL, поэтому работает по-настоящему параллельно
  • Цена — сериализация аргументов и результата через pickle: гонять большие объекты туда-обратно дорого

Что спросят дальше

  • Спросят, как найти такие места в проде: asyncio debug mode логирует колбэки дольше 100 мс
  • Спросят про ORM: синхронный Django ORM в async-вью — это sync_to_async, и заодно вопрос про пул соединений
  • Спросят, что нельзя отменить операцию в потоке — задача отменится, поток продолжит работать

Следующая задача

Фоновая задача по расписанию — Периодическое обновление кэша: важно, чтобы одна ошибка не убила задачу навсегда.