Практическая ситуация: в одном event loop первая корутина удерживает threading.Lock во время await, а втора...

Практическая ситуация: в одном event loop первая корутина удерживает threading.Lock во время await, а вторая пытается захватить этот lock. Может ли event loop зависнуть?

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

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

Да, event loop может зависнуть. Вызов threading.Lock.acquire() блокирует поток синхронно; если этот поток является потоком event loop, он перестаёт обрабатывать другие корутины, включая первую, которая должна была продолжить работу и освободить lock.

Для взаимного исключения между корутинами следует использовать asyncio.Lock и не выполнять длительные операции внутри критической секции без необходимости.

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

threading.Lock создан для синхронизации потоков: если lock занят, поток обычно ждёт его освобождения блокирующим системным вызовом. Такой подход допустим для обычного многопоточного кода, где блокировка одного потока не останавливает остальные.

asyncio использует кооперативную модель: один event loop последовательно исполняет корутины в своём потоке. Поэтому синхронная блокировка этого потока нарушает основную модель asyncio — управление должно возвращаться event loop на точках await, а не блокироваться внутри обычного вызова.

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

Рассмотрим последовательность: первая корутина захватывает threading.Lock и до освобождения выполняет await. Event loop переключается на вторую корутину, а та вызывает acquire() для уже занятого lock.

Вторая корутина не приостанавливается кооперативно — она блокирует весь поток event loop. Первая корутина не может возобновиться после await, поэтому не может освободить lock. Возникает взаимная блокировка, хотя корутины работают в одном потоке и отдельных потоков в приложении может не быть.

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

Минимальная демонстрация проблемы:

import asyncio import threading lock = threading.Lock() async def first(): lock.acquire() await asyncio.sleep(0.1) lock.release() async def second(): await asyncio.sleep(0) lock.acquire() lock.release() async def main(): await asyncio.gather(first(), second()) asyncio.run(main())

После захвата lock функция first уступает управление на await. Затем second вызывает блокирующий acquire(). Event loop останавливается внутри этого вызова и больше не может возобновить first.

Для корутин нужен asyncio.Lock. Его ожидание является асинхронным: занятая корутина передаёт управление event loop, а ожидающие задачи не блокируют поток.

import asyncio lock = asyncio.Lock() async def worker(): async with lock: await asyncio.sleep(0.1) async def main(): await asyncio.gather(worker(), worker()) asyncio.run(main())

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

Даже с asyncio.Lock длительная работа внутри критической секции снижает параллелизм: остальные корутины будут ждать освобождения lock. Поэтому следует защищать только минимальный участок, а независимый ввод-вывод или вычисления выносить за пределы критической секции, если это сохраняет корректность.

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

В асинхронном сервисе несколько обработчиков обновляют локальный кэш. Один разработчик использовал threading.Lock, потому что тот уже применялся в синхронной версии компонента. Под нагрузкой запросы иногда переставали завершаться: один обработчик ждал ввод-вывод с захваченным lock, а другой блокировал event loop на попытке захвата того же lock.

Рассматривались варианты:

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

Выбрали asyncio.Lock, сократили критическую секцию до проверки и изменения записи, а сетевой запрос вынесли за пределы lock. В результате event loop перестал блокироваться, а сериализация конкурентных изменений сохранилась.

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

  1. Разве GIL не предотвращает такую блокировку?

Нет. GIL регулирует выполнение Python-кода в потоках CPython, но не превращает блокирующий threading.Lock.acquire() в асинхронное ожидание. Event loop всё равно может остановиться внутри вызова, ожидающего освобождения пользовательского lock.

Кроме того, GIL и взаимное исключение решают разные задачи: GIL связан с исполнением Python-байткода, а lock защищает конкретный общий ресурс. Наличие GIL не делает произвольную последовательность операций безопасной и не защищает от зависания event loop.

  1. Можно ли безопасно использовать threading.Lock, если корутина почти сразу освобождает его?

Гарантии безопасности нет. Если lock свободен, вызов действительно быстро завершится, но при занятости он может заблокировать поток event loop на неопределённое время. Редкая вероятность блокировки не устраняет последствий: достаточно одного такого случая, чтобы остановить обработку всех задач в этом loop.

Использование допустимо только при строгой гарантии, что вызов никогда не будет ждать, либо если он выполняется вне event loop, например в отдельном потоке с корректной архитектурой обмена данными. Для обычной синхронизации корутин предпочтительнее asyncio.Lock.

  1. Почему нельзя просто заменить lock на asyncio.Lock при доступе из нескольких потоков?

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

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