Программирование PythonКонкурентность и asyncioPython-разработчик серверных приложений

Разберите ситуацию: синхронная функция уже запустила event loop, а затем пытается вызвать asyncio.run для к...

Разберите ситуацию: синхронная функция уже запустила event loop, а затем пытается вызвать asyncio.run для корутины. Каков результат?

Проходите собеседования с ИИ помощником Hintsage

Краткий ответ

Вызов asyncio.run внутри уже работающего event loop завершается исключением RuntimeError. Эта функция предназначена для верхнеуровневого запуска корутины и сама создаёт, запускает и закрывает event loop, поэтому вложенный запуск в том же потоке запрещён.

Вместо этого вызывающий код должен передать корутину уже работающему циклу: использовать await, asyncio.create_task или другой подход, соответствующий архитектуре приложения.

Исторический контекст

Асинхронные приложения раньше нередко вручную создавали event loop, запускали его, обрабатывали завершение задач и закрывали ресурсы. Такой код был многословным и часто приводил к ошибкам при финализации асинхронных генераторов, задач и пула потоков.

asyncio.run появился как единая точка входа для запуска асинхронной программы из синхронного кода. Он рассчитан на внешний, верхнеуровневый вызов, а не на управление циклом изнутри другой корутины.

Постановка проблемы

Работающий event loop уже управляет очередью задач, обработкой событий и выполнением текущей корутины. Попытка запустить внутри него второй цикл в том же потоке нарушила бы модель владения циклом и сделала бы неочевидным, какой цикл должен обрабатывать события и завершать задачи.

Неверное решение обычно встречается в обработчиках веб-фреймворков, Jupyter и других системах, где цикл уже запущен. Последствие — немедленный RuntimeError, а созданная непосредственно перед вызовом корутина может дополнительно вызвать предупреждение о том, что она не была обработана.

Подробное решение

Перед запуском asyncio.run Python проверяет, существует ли уже работающий event loop в текущем потоке. Если существует, вложенный запуск запрещается. При обычном внешнем вызове asyncio.run создаёт новый цикл, выполняет переданную корутину, ожидает завершения асинхронных генераторов и закрывает цикл вместе с используемыми им ресурсами.

Внутри асинхронного кода нужно не запускать новый цикл, а продолжить работу в текущем:

import asyncio async def fetch(): await asyncio.sleep(0.1) return 'готово' async def handler(): result = await fetch() task = asyncio.create_task(fetch()) other = await task return result, other asyncio.run(handler())

Здесь asyncio.run вызывается только на внешней границе программы. Внутри handler корутина вызывается через await, а независимая работа — через create_task.

Если синхронный код находится в другом потоке и ему нужно передать корутину уже работающему циклу, применяют механизм вроде asyncio.run_coroutine_threadsafe. Это отдельный сценарий межпоточного взаимодействия; использовать его для обхода ошибки в том же потоке нельзя, поскольку ожидание результата может заблокировать сам event loop.

Создание нового цикла в отдельном потоке иногда допустимо, но это уже отдельная асинхронная среда. Объекты, привязанные к конкретному циклу или потоку, например некоторые транспортные объекты и примитивы синхронизации, нельзя бездумно переносить между такими средами.

Ситуация из практики

Веб-приложение вызывает синхронный метод библиотеки, а тот внутри пытается получить результат асинхронного клиента через asyncio.run. В рабочем процессе обработчик уже выполняется внутри event loop, поэтому запросы начинают завершаться исключением.

Рассматривались три варианта. Вызов нового цикла в отдельном потоке сохраняет синхронный интерфейс, но добавляет накладные расходы и усложняет передачу контекста. Создание нового event loop в том же потоке не решает проблему: работающий цикл там уже существует.

Выбранным решением стала переделка границы библиотеки в асинхронный метод, который принимает и ожидает корутину через await. Для независимых запросов использовали create_task с последующим явным ожиданием; это сохранило один event loop, исключило вложенный запуск и упростило контроль отмены и ошибок.

Что кандидаты часто упускают

  1. Можно ли заменить asyncio.run на asyncio.create_task в любой ситуации?

Нет. create_task должен вызываться внутри работающего event loop и возвращает задачу, которую нужно сохранить или дождаться. Он не блокирует синхронный вызывающий код и не является универсальной заменой для запуска асинхронной программы из обычной функции.

Если внешняя функция синхронная и event loop ещё не запущен, корректной верхнеуровневой точкой обычно остаётся asyncio.run. Если цикл уже запущен, синхронный интерфейс нужно либо преобразовать в асинхронный, либо явно организовать взаимодействие через другой поток.

  1. Почему непосредственный вызов корутины не запускает её выполнение?

Вызов асинхронной функции создаёт объект-корутину, но её тело начинает выполняться только после передачи в await, задачу или другой механизм исполнения. Поэтому простая передача корутины в синхронную функцию не заменяет запуск event loop.

Это также объясняет предупреждение о необработанной корутине: объект был создан, но ни разу не был передан исполнителю. В частности, при выражении вроде asyncio.run(fetch()) объект корутины создаётся до входа в asyncio.run, поэтому ошибка о уже работающем цикле может сопровождаться таким предупреждением.

  1. Что произойдёт, если вызвать asyncio.run из другого потока?

Проверка выполняется относительно текущего потока. Если в другом потоке нет работающего event loop, asyncio.run может создать и запустить собственный цикл там.

Однако это не делает безопасным произвольный обмен асинхронными объектами между потоками. Циклы, транспортные объекты и некоторые примитивы могут быть привязаны к своему потоку или циклу. Для передачи корутины в уже работающий цикл из другого потока следует использовать предназначенный для этого межпоточный механизм и аккуратно обрабатывать результат, исключения и отмену.