Программирование PythonКонкурентность и asyncioBackend-разработчик Python, работающий с asyncio

В двух корутинах asyncio обновляется общее состояние с фактической приостановкой между чтением и записью. М...

В двух корутинах asyncio обновляется общее состояние с фактической приостановкой между чтением и записью. Может ли возникнуть гонка данных в одном event loop?

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

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

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

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

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

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

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

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

В результате часть обновлений теряется. Ошибка особенно неприятна тем, что она зависит от порядка планирования задач и может не воспроизводиться на каждом запуске.

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

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

Если критическая секция содержит несколько шагов, её нужно защищать асинхронной блокировкой:

import asyncio value = 0 lock = asyncio.Lock() async def increment(): global value async with lock: current = value await asyncio.sleep(0) value = current + 1

async with lock гарантирует, что только одна корутина одновременно выполняет защищённый участок. Ожидающие корутины не блокируют поток: они приостанавливаются и позволяют event loop обслуживать другие задачи.

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

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

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

В асинхронном сервисе несколько запросов одновременно запрашивают отсутствующий объект из кэша. Каждая корутина проверяет кэш, видит промах, приостанавливается на сетевом запросе и затем сохраняет результат. Без координации все запросы могут одновременно обратиться к базе данных — это вариант эффекта «стадного запроса».

Первый вариант — не использовать блокировку. Он проще и иногда приемлем, если повторные запросы безопасны и их стоимость мала, но при высокой нагрузке создаёт лишнюю нагрузку на базу.

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

Практичный вариант — защищать проверку и заполнение кэша, а после получения блокировки повторно проверять кэш. Такой повторный тест необходим: пока корутина ждала блокировку, другая уже могла загрузить и сохранить объект. Внешний вызов всё ещё выполняется последовательно для одного ключа, но лишние запросы исключаются; для разных ключей можно использовать отдельные блокировки.

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

  1. Всегда ли наличие await означает переключение на другую задачу?

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

Поэтому корректнее говорить не о любом синтаксическом await, а о точке фактической приостановки. Тем не менее нельзя строить надёжность программы на предположении, что конкретный await всегда завершится немедленно: это может измениться из-за реализации или состояния системы.

  1. Защищает ли asyncio.Lock данные от доступа из другого потока?

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

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

  1. Можно ли заменить блокировку копированием состояния или очередью сообщений?

Иногда да. Если состояние принадлежит одному владельцу, а остальные корутины отправляют ему команды через очередь, конкурентная запись исчезает: только владелец изменяет данные. Это уменьшает число блокировок и делает порядок операций явнее.

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