open atlas
↑ К треку
Python для JS/TS-разработчиков PY · 11 · 04

Мосты между sync и async: to_thread и экзекьюторы в одну сторону, долгоживущий цикл и run_coroutine_threadsafe в другую

Async в sync: to_thread уносит блокирующую работу с цикла — потоки разблокируют I/O, GIL всё ещё сериализует CPU, процессы платят pickle. Sync в async: asyncio.run для скриптов, run_coroutine_threadsafe в долгоживущий цикл для серверов — цикл на каждый вызов пересоздаёт все пулы.

PY Senior ◷ 18 min
Уровень
ОсновыJuniorMiddleSenior

Команда поиска мигрировала на новый SDK вендора — async-only, как большинство современных клиентов, — но приложение было десятилетним Django-монолитом на синхронных WSGI-воркерах. Выбранный мост выглядел невинно: asyncio.run(client.search(q)) внутри вью. На демо работало. В продакшене латентность поиска утроилась, и waterfall в APM рассказал историю с одного взгляда: спан TLS-хендшейка на каждом запросе. asyncio.run строит свежий event loop, прогоняет корутину и сносит цикл — а пул соединений SDK, его keep-alive-сокеты, его TLS-сессии были привязаны к этому циклу и умирали вместе с ним, каждый запрос, две тысячи раз в минуту. Переиспользование соединений: ноль процентов. CPU на хендшейки: плюс сорок процентов. Фиксом было не «переписать монолит»: один event loop в выделенном рабочем потоке, стартующий при загрузке процесса, и вью, передающие корутины через run_coroutine_threadsafe, — переиспользование пула вернулось, латентность упала ниже старого синхронного клиента. У направлений моста есть точные инструменты, и неправильный невидим, пока не прочитаешь waterfall.

Async в sync: убрать блокирующую работу с цикла

Урок 1 доказал, почему цикл не должен ждать: один блокирующий вызов морозит опрос селектора. Клапанов давления два, и выбор между ними — GIL-математика из юнита про конкурентность. await asyncio.to_thread(fn, *args) (3.9+) выполняет fn в дефолтном ThreadPoolExecutor (пул потоков из стандартной библиотеки concurrent.futures) цикла и протаскивает contextvars (контекстные переменные, аналог thread-local для корутин); loop.run_in_executor(pool, fn) — старшая форма с явным пулом. Для блокирующего I/O — синхронный драйвер БД, requests, чтение файлов — поток решает задачу целиком: он паркуется в сисколле, ничего не держа, цикл бежит свободно. Для CPU-bound работы GIL по-прежнему сериализует Python-байткод между потоками: to_thread оставляет цикл более-менее отзывчивым (GIL переходит из рук в руки каждые ~5 мс, и цикл продвигается между слайсами), но не покупает ни грамма пропускной способности и портит латентность цикла под контеншном. Настоящий CPU-параллелизм — это ProcessPoolExecutor: налог pickle в обе стороны и старт процессов, окупается от десятков миллисекунд CPU на единицу работы.

Ловушка размера — дефолтный экзекьютор: min(32, cpu_count + 4) потоков. Сорок одновременных to_thread(requests.get, ...) на 8-ядерной машине — это 12 работающих потоков и 28 вызовов, невидимо стоящих в очереди: у вашего «асинхронного» эндпоинта теперь скрытое ожидание тред-пула в p99. Та же форма, что в юните про веб-сервисы, где фреймворк выгружает синхронные эндпоинты в дефолтный пул на 40 потоков: у каждого моста есть ширина, и неизмеренная ширина — это обрыв латентности. Размеряйте явный пул под зависимость, которую он защищает (БД на 10 соединений нужны ~10 потоков, а не 32).

pool = concurrent.futures.ThreadPoolExecutor(max_workers=10, thread_name_prefix="db")

async def get_user(uid):
    loop = asyncio.get_running_loop()
    return await loop.run_in_executor(pool, db.fetch_user, uid)   # ограниченный мост
Викторина

Вы уносите чистопитоновый рендер отчёта на 300 мс из инлайна в asyncio.to_thread. Останется ли цикл отзывчивым и вырастет ли пропускная способность рендера?

Sync в async: три инструмента, три ситуации

У обратного направления — инструмент на ситуацию, и их перепутывание — инцидент из Хука. asyncio.run(coro) создаёт свежий цикл, прогоняет до конца, закрывает цикл — корректно примерно один раз за жизнь процесса: скрипты, main(), тесты. Всё, что привязано к циклу — пулы соединений, сессии, транспорты, — умирает вместе с ним; поэтому по-запросный asyncio.run пересобирал мир SDK две тысячи раз в минуту. asyncio.run_coroutine_threadsafe(coro, loop) отправляет корутину в работающий цикл из другого потока и возвращает concurrent.futures.Future (синхронный Future из стандартной библиотеки, не asyncio.Future), чей .result(timeout) блокирует вызывающий поток, а не цикл. Это серверный мост: один долгоживущий цикл в выделенном потоке, и каждый sync-вызывающий передаёт корутины через него:

class AsyncBridge:
    def __init__(self):
        self.loop = asyncio.new_event_loop()
        threading.Thread(target=self.loop.run_forever, daemon=True, name="bridge").start()

    def call(self, coro, timeout=None):
        fut = asyncio.run_coroutine_threadsafe(coro, self.loop)   # потокобезопасная передача
        return fut.result(timeout)        # блокирует ЭТОТ поток; цикл бежит свободно

bridge = AsyncBridge()                    # один цикл, один пул соединений на процесс
def view(request):                        # синхронная WSGI-вью, фреймворк не тронут
    return bridge.call(client.search(request.GET["q"]), timeout=5)

Третья ситуация — ошибка, которую вы встретите первой: вызов asyncio.run (или loop.run_until_complete) при уже работающем цикле поднимает RuntimeError: asyncio.run() cannot be called from a running event loop — цикл нереентерабелен by design; вложенный запуск должен был бы поставить итерацию на паузу посреди разгрузки. Jupyter — классическое столкновение (ноутбук сам крутит цикл — потому там и работает await на верхнем уровне). nest_asyncio, честно: он манкипатчит цикл, разрешая реентерабельный run_until_complete, — работает в ноутбуке и нарушает инварианты цикла везде остальном; в проде это диагностическая улика («кто-то воюет с архитектурой»), а не фикс.

Менеджмент заразности и правила на границе

Async заразен вверх — каждый вызывающий корутину обязан сам await-ить или мостить, — и каждое пересечение моста стоит прыжка между потоками, бухгалтерии футур и побудки целевого потока: десятки микросекунд в лучшем случае, плюс очередь на ширине пула в худшем. Архитектурное правило: одна граница. Синхронное ядро с асинхронным краем (Django-мост выше) или асинхронное ядро с синхронными листовыми вызовами через to_thread — и отказ от посыпки посреди стека, где слой три асинхронный, слой четыре синхронный, слой пять снова асинхронный, и каждый переход — мост со своей шириной, таймаутом и режимом отказа.

На самой границе потокобезопасность абсолютна: объекты цикла не потокобезопасны, и единственные законные входы из чужого потока — loop.call_soon_threadsafe(cb) и run_coroutine_threadsafe. Механизм замыкает круг урока 1: цикл может быть запаркован в selector.select(timeout) — спит в ядре. call_soon_threadsafe добавляет колбэк и пишет байт в self-pipe, за которым следит селектор, — цикл просыпается немедленно. Обычный call_soon из чужого потока пропускает побудку: колбэк лежит незамеченным до следующего естественного пробуждения — или прямо портит внутреннее состояние. Никогда не вызывайте task.cancel(), future.set_result() или любой метод цикла напрямую из другого потока; заворачивайте в call_soon_threadsafe.

Почему это работает

Почему цикл просто не сделали реентерабельным, убив класс RuntimeError? Потому что инвариант цикла — одна итерация за раз, колбэки строго последовательно — и есть то, что делает asyncio-код безопасным без локов. Реентерабельный запуск означал бы, что колбэк можно приостановить посреди исполнения, пока внутри него крутится другая разгрузка: однопоточная гарантия атомарности из урока 2 (код между await непрерываем) тихо испарилась бы. nest_asyncio делает ровно это — поэтому он приемлем в ноутбуке, исследующем данные, и является футганом в сервере, держащем инварианты.

Викторина

Корутина, работающая НА потоке цикла, вызывает run_coroutine_threadsafe(other(), loop) и затем fut.result(5). Что произойдёт?

Вспомните перед уходом
  1. 01
    Разложите оба направления моста с точными инструментами и GIL-осознанный выбор между потоком и процессом для стороны async-в-sync.
  2. 02
    Почему по-запросный asyncio.run утроил латентность в Django-инциденте, какова корректная архитектура и каковы правила потокобезопасности на границе?
Итог

У двух направлений моста точные инструменты, и каждый инцидент этого урока — кто-то взял правильный инструмент не в ту сторону или не той ширины. Async в sync: to_thread (или run_in_executor с явным пулом) уносит блокирующую работу с цикла — полный фикс для блокирующего I/O, где поток паркуется в сисколле; полуфикс для чистопитонового CPU, где GIL по-прежнему сериализует байткод, и только ProcessPoolExecutor покупает настоящий параллелизм ценой pickle и старта процессов. Дефолтный экзекьютор шириной min(32, cpu+4) потоков, и переподписанный мост невидимо копит очередь внутри вашего p99 — размеряйте явные пулы под зависимость, которую они защищают: та же дисциплина ширины, что у 40-поточного пула выгрузки в веб-фреймворке. Sync в async: asyncio.run — для одноразовых процессов, потому что цикл и всё к нему привязанное — пулы, сессии, транспорты — умирает при сносе; по-запросное использование — Django-инцидент, 0% переиспользования соединений и TLS-хендшейк на вызов. Серверы держат один живой цикл в выделенном потоке и переходят через run_coroutine_threadsafe, блокируясь на возвращённой concurrent-футуре с таймаутом — и никогда с самого потока цикла: это само-дедлок. RuntimeError на вложенный asyncio.run — цикл, защищающий свои инварианты (nest_asyncio меняет их на ноутбучное удобство). А граница — место, где замыкается урок 1: call_soon_threadsafe существует потому, что цикл может спать в selector.select, — он пишет байт побудки; всё остальное из чужого потока — ожидающая своего часа порча. Одна граница на стек; измеряйте её ширину; ограничивайте её ожидания. Теперь, когда интегрируете современный async-SDK в синхронную кодовую базу и видите TLS-хендшейк в каждом спане APM — знаете: мост создаёт свежий цикл на каждый вызов, и run_coroutine_threadsafe в один долгоживущий цикл — это однострочный фикс.

Практика

Начни сверху. Задачи идут от простого к сложному: вспомнить факт, применить к случаю, затем senior-уровень. Открой, попробуй, потом открой ответ.

вспомнитьприменитьуглубить0 из 6 завершено

Что-то непонятно?

Задай вопрос по этому уроку. Вопросы анонимны и попадают напрямую автору — урок станет лучше.

Примени это

Примени этот урок в реальном проекте.

хоткеи развернуть
поиск
K
пред. пьеса
k
след. пьеса
j
тиры
t
это меню
?
sources3
expand
  1. 01
  2. 02
  3. 03

Trademarks belong to their respective owners. Editorial reference only.