В сервисе один ресурс используют потоки и корутины: почему asyncio.Lock не может быть общей защитой доступа?
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. Если ресурс действительно должен обслуживаться потоками, его операции лучше целиком изолировать в потоке, а взаимодействие с asyncio выполнять через безопасный мост между моделями.
Нельзя удерживать threading.Lock через await: за время ожидания корутина может заблокировать lock, а другая корутина или поток — оказаться в ситуации взаимной блокировки. Также нельзя считать, что отсутствие явного await автоматически делает сложную операцию атомарной: вызванная синхронная функция может сама блокироваться или передавать управление другому исполнителю.
Сервис принимает запросы из HTTP-обработчиков asyncio и из фонового потока, который обрабатывает сообщения очереди. Оба пути обновляют один клиент, не поддерживающий одновременные вызовы.
Вариант с одним asyncio.Lock прост для HTTP-обработчиков, но не защищает вызовы из потока. Вариант с одним threading.Lock формально объединяет доступ, однако его ожидание внутри корутин может блокировать event loop и ухудшить задержки всего сервиса.
Выбранное решение — выделить владельца ресурса в один event loop и передавать ему все операции. Входящие из потока запросы ставятся в очередь или преобразуются в запланированную работу event loop, а сам ресурс защищается одним asyncio.Lock. Это устраняет конкурирующий прямой доступ и сохраняет неблокирующее выполнение остальных корутин; цена решения — дополнительная сериализация и задержка на передачу запроса владельцу.
Нет, рассчитывать на это нельзя. Примитивы asyncio предназначены для координации задач в соответствующем цикле событий; перенос lock между потоками или циклами нарушает модель его использования. Для взаимодействия разных loop нужен внешний потокобезопасный протокол, а не общий asyncio-lock.
Нет. Lock не отслеживает обращения к объекту и не блокирует операции автоматически. Защита действует только для участков, которые все участники соглашения выполняют после захвата того же lock; код, изменяющий объект напрямую из потока, это соглашение обходит.
Нельзя выполнять её под lock непосредственно в event loop: это задержит освобождение lock и одновременно заблокирует обработку других задач. Обычно блокирующую работу выносят в поток или процесс, а доступ к ресурсу последовательно организуют у одного владельца; при этом нужно учитывать, что отмена ожидающей asyncio-задачи не обязательно останавливает уже начатую синхронную работу исполнителя.