Возможно ли состояние гонки (race condition) между корутинами в однопоточном asyncio

Ответ

Да, состояние гонки между корутинами возможно даже в одном потоке. Несмотря на то, что выполнение кооперативное (корутины не вытесняются произвольно), оно переключается в точках await, и это может привести к недетерминированному поведению.

Почему это происходит:

Проблема возникает, когда операция "чтение-модификация-запись" разделена точкой await. Одна корутина может прочитать значение, затем планировщик передает управление другой корутине, которая также читает старое значение, и в результате одно из обновлений теряется.

Классический пример с счетчиком:

import asyncio

counter = 0

async def increment():
    global counter
    # 1. Чтение
    local_counter = counter
    # 2. Точка переключения контекста. Другая корутина может выполниться здесь.
    await asyncio.sleep(0)
    # 3. Запись
    counter = local_counter + 1

async def main():
    tasks = [increment() for _ in range(1000)]
    await asyncio.gather(*tasks)
    # Ожидаемый результат: 1000
    # Реальный результат: может быть любым числом < 1000
    print(f"Итоговый счетчик: {counter}")

asyncio.run(main())

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

# ... (код выше)
lock = asyncio.Lock()

async def increment_safe():
    global counter
    async with lock:
        local_counter = counter
        await asyncio.sleep(0)
        counter = local_counter + 1
# ... (в main вызывать increment_safe)

Ответ 18+ 🔞

А, слушай, вот это классика, блядь! Состояние гонки в корутинах — это ж пиздец как просто устроено, а ловишь потом баги, как дурак. Все думают: "О, асинхронщина, один поток, вытеснения нет — красота!" А потом на тебе — хуй с горы, данные поехали.

В чём, сука, соль:

Проблема-то в том, что твоя операция "прочитал-поправил-записал" разваливается нахуй в точке await. Одна корутина считала значение, а потом — чих-пых! — планировщик её вырубил и дал поработать другой. Та вторая тоже читает то же самое, старое значение, и в итоге одно обновление накрывается медным тазом. Классический пиздец, ёпта.

Вот смотри, пример со счётчиком, чтоб совсем понятно было:

import asyncio

counter = 0

async def increment():
    global counter
    # 1. Читаем
    local_counter = counter
    # 2. А тут — бац! — контекст переключился. Другая корутина уже тут как тут.
    await asyncio.sleep(0)
    # 3. Пишем. И всё ебнулось.
    counter = local_counter + 1

async def main():
    tasks = [increment() for _ in range(1000)]
    await asyncio.gather(*tasks)
    # В теории должно быть 1000, да?
    # А на практике — любое число меньше, волнение ебать!
    print(f"Итоговый счетчик: {counter}")

asyncio.run(main())

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

Как лечить, блядь? Надо эту критическую секцию прикрыть, как срамное место. Берёшь asyncio.Lock и не парься.

# ... (код выше)
lock = asyncio.Lock()

async def increment_safe():
    global counter
    async with lock:  # Вот этот волшебный захват
        local_counter = counter
        await asyncio.sleep(0)  # Теперь спи спокойно, никто тут не нагадит
        counter = local_counter + 1
# ... (в main вызывать increment_safe)

Вот так, сука. Захватил лок, сделал свои делишки, отпустил. Все довольны, данные целы. А то без лока — чистая лотерея, в рот меня чих-пых.