Один event loop, потерянное обновление: где нужен asyncio.Lock
В асинхронном обработчике хранится счётчик свободных мест. Две задачи читают значение, ждут внешнюю проверку и записывают уменьшенный остаток. Несмотря на один поток event loop, обе могут прочитать одинаковое число. Однопоточное выполнение отдельных участков не делает всю coroutine неделимой: await даёт другой задаче возможность продолжить работу.
Для общего состояния внутри одного event loop можно защитить составную операцию asyncio.Lock. Блокировка должна охватывать чтение, решение и запись, если между ними есть точка переключения. Если взять её только перед присваиванием, обе задачи успеют рассчитать результат по устаревшему значению.
Составная операция под Lock
Чтобы увидеть дефект без случайного тайминга, используем счётчик и asyncio.sleep(0) как явную уступку управления. Нулевая задержка здесь не имитирует производительность сервиса: она создаёт пересечение, которое возможно при реальном await. Исправленная версия держит Lock на всём участке изменения.
import asyncio
async def main():
state = {"count": 0}
lock = asyncio.Lock()
async def increment():
async with lock:
previous = state["count"]
await asyncio.sleep(0)
state["count"] = previous + 1
await asyncio.gather(increment(), increment())
assert state["count"] == 2
print(state["count"])
asyncio.run(main())
Граница критической секции
Файл запускается на Python 3.12 и печатает 2. Проверьте вариант без async with: обе задачи прочитают ноль и запишут единицу. Теперь переместите блокировку только вокруг последнего присваивания — значение снова окажется потерянным. Такой негативный опыт помогает выбрать границу критической секции, а не просто добавить lock рядом с ошибкой.
В прикладной операции стоит сначала спросить, можно ли убрать await из изменения состояния. Если синхронно проверить доступность и зарезервировать место, а медленную работу выполнить после, блокировка может стать короче. Но при неудаче внешней операции потребуется явный откат или другое состояние резерва. Это уже решение предметной модели, его нельзя заменить произвольным переносом строк.
Не держите блокировку во время несвязанного сетевого ожидания. Все остальные задачи с тем же lock будут ждать, даже если работают с другим ключом. Для независимых ресурсов можно использовать разные блокировки, но тогда появляется задача хранения и удаления этого реестра. Сначала измерьте, действительно ли единая критическая секция ограничивает приложение.
asyncio.Lock координирует задачи текущего процесса и не защищает состояние в других процессах или потоках. Если счётчик хранится во внешнем хранилище, атомарность нужно обеспечить его средствами. Локальная блокировка полезна для локального кэша, буфера или состояния соединения. Тест должен воспроизводить переключение внутри составного изменения и проверять конечное значение, а не только факт вызова acquire.
Когда локальный пример работает, полезно разобрать соседние механизмы языка и их взаимодействие. В курсе «Python для профессионалов» есть практика по итераторам, генераторам, декораторам, asyncio, метапрограммированию и профилированию. Эти темы помогают понимать время жизни объектов, управление выполнением и причины поведения прикладной программы.
Посмотреть программу и практику курса.
Документация: официальный источник по теме.
Другие задачи по теме: Типы, тесты и поведение программы.
Продолжить на практике
- Python для профессионалов ↗
Python для профессионалов — курс для людей, стремящихся углубить свои знания языка. Вы изучите итераторы, генераторы, декораторы, современные возможности Python (3.8+), освоите метапрограммирование, асинхронность, внутреннее устройство интерпретатора, инструменты оптимизации и профилирования, а также получите уверенность в использовании продвинутых возможностей стандартной библиотеки.
