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

В сервисе один ресурс используют потоки и корутины: почему asyncio.Lock не может быть общей защитой доступа?

В сервисе один ресурс используют потоки и корутины: почему asyncio.Lock не может быть общей защитой доступа?

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

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

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

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

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

Потоки решают другую задачу: они могут выполняться независимо, а доступ к общей памяти синхронизируется потоковыми примитивами, например threading.Lock. У этих двух моделей разные области видимости и правила взаимодействия.

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

Если один поток и корутина обращаются к общему ресурсу, например к файлу, кешу или небезопасному клиенту библиотеки, защита только через asyncio.Lock не гарантирует взаимоисключение. Поток может изменить ресурс, не участвуя в протоколе захвата asyncio-lock, а корутина этого не обнаружит.

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

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

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

Есть три основных архитектурных решения:

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

На практике часто выбирают первый вариант: поток не трогает ресурс напрямую, а передаёт запрос event loop. Тогда только корутины владеют ресурсом и используют один asyncio.Lock. Если ресурс действительно должен обслуживаться потоками, его операции лучше целиком изолировать в потоке, а взаимодействие с asyncio выполнять через безопасный мост между моделями.

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

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

Сервис принимает запросы из HTTP-обработчиков asyncio и из фонового потока, который обрабатывает сообщения очереди. Оба пути обновляют один клиент, не поддерживающий одновременные вызовы.

Вариант с одним asyncio.Lock прост для HTTP-обработчиков, но не защищает вызовы из потока. Вариант с одним threading.Lock формально объединяет доступ, однако его ожидание внутри корутин может блокировать event loop и ухудшить задержки всего сервиса.

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

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

  1. Можно ли использовать asyncio.Lock в двух разных event loop?

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

  1. Защищает ли asyncio.Lock сам объект от изменений из обычного синхронного кода?

Нет. Lock не отслеживает обращения к объекту и не блокирует операции автоматически. Защита действует только для участков, которые все участники соглашения выполняют после захвата того же lock; код, изменяющий объект напрямую из потока, это соглашение обходит.

  1. Что выбрать, если общий участок содержит длительную блокирующую операцию?

Нельзя выполнять её под lock непосредственно в event loop: это задержит освобождение lock и одновременно заблокирует обработку других задач. Обычно блокирующую работу выносят в поток или процесс, а доступ к ресурсу последовательно организуют у одного владельца; при этом нужно учитывать, что отмена ожидающей asyncio-задачи не обязательно останавливает уже начатую синхронную работу исполнителя.