← Назад к вопросам

Возможно ли происхождение состояния гонки в asyncio?

3.0 Senior🔥 141 комментариев
#Асинхронность и многопоточность

Комментарии (1)

🐱
claude-haiku-4.5PrepBro AI22 мар. 2026 г.(ред.)

Ответ сгенерирован нейросетью и может содержать ошибки

Состояние гонки в asyncio: миф и реальность

Частое заблуждение: "asyncio на одном потоке, поэтому race conditions невозможны." Это неправда. Race conditions в asyncio не только возможны — они часто встречаются и опасны.

1. Почему asyncio НЕ защищает от race conditions

Ключевой момент

Одноточность asyncio защищает от классических race conditions только если используется одна корутина за раз. Но asyncio часто запускает множество корутин параллельно!

import asyncio

shared_counter = 0

async def increment_counter():
    global shared_counter
    # Race condition возможна здесь!
    temp = shared_counter  # 1. Прочитали
    await asyncio.sleep(0)  # 2. Переключение контекста!
    shared_counter = temp + 1  # 3. Записали старое значение

async def main():
    # Запускаем 100 корутин параллельно
    await asyncio.gather(
        *[increment_counter() for _ in range(100)]
    )
    print(shared_counter)  # Ожидаем: 100, получаем: ~50

asyncio.run(main())

# Race condition!
# shared_counter прочитан несколько раз перед записью

2. Где возникают race conditions

Проблема 1: Между await

import asyncio

shared_dict = {"value": 0}

async def unsafe_update():
    # Прочитали
    value = shared_dict["value"]
    
    # ТОЧКА ПЕРЕКЛЮЧЕНИЯ! Другая корутина может выполниться
    await asyncio.sleep(0)
    
    # Записали (но значение уже устаревшее!)
    shared_dict["value"] = value + 1

async def main():
    await asyncio.gather(
        unsafe_update(),
        unsafe_update(),
        unsafe_update()
    )
    print(shared_dict)  # {"value": 1} вместо {"value": 3}

asyncio.run(main())

Проблема 2: Работа с БД

import asyncio
import asyncpg

async def transfer_money(from_id, to_id, amount):
    # Race condition в банке!
    
    # Шаг 1: Прочитали баланс
    balance = await db.fetchval(
        "SELECT balance FROM accounts WHERE id = $1",
        from_id
    )
    
    # ПЕРЕКЛЮЧЕНИЕ! Другая корутина может тоже прочитать баланс
    await asyncio.sleep(0)
    
    # Шаг 2: Проверили хватает ли денег
    if balance < amount:
        return {"error": "Insufficient funds"}
    
    # ПЕРЕКЛЮЧЕНИЕ! Пока мы проверяем, другая корутина может снять деньги
    await asyncio.sleep(0)
    
    # Шаг 3: Перевод денег
    await db.execute(
        "UPDATE accounts SET balance = balance - $1 WHERE id = $2",
        amount,
        from_id
    )
    
    # Результат: счёт может уйти в минус!

# Двух пользователей с 100 на счету одновременно переводят 100
# Оба видят баланс 100, оба переводят
# Итог: счёт с -100 вместо +0

3. Примеры реальных race conditions

Проблема 1: Двойное создание ресурса

import asyncio

connection = None

async def get_connection():
    global connection
    
    # Race condition!
    if connection is None:  # Две корутины одновременно проверяют
        # Обе видят None
        await asyncio.sleep(0.1)  # Подключение
        connection = "connected"
    
    return connection

async def main():
    # Запускаем две корутины одновременно
    c1 = await get_connection()
    c2 = await get_connection()
    # Обе создали connection! Потеря ресурса!

asyncio.run(main())

Проблема 2: Инкремент счётчика

import asyncio

class Counter:
    def __init__(self):
        self.value = 0
    
    async def increment(self):
        # Race condition в коде выше
        temp = self.value
        await asyncio.sleep(0)  # Переключение
        self.value = temp + 1

async def main():
    counter = Counter()
    
    # 10 корутин инкрементируют
    await asyncio.gather(
        *[counter.increment() for _ in range(10)]
    )
    
    print(counter.value)  # Ожидаем 10, получаем ~5

asyncio.run(main())

4. Как защитить код

Решение 1: Locks (Мьютексы)

import asyncio

class Counter:
    def __init__(self):
        self.value = 0
        self.lock = asyncio.Lock()
    
    async def increment(self):
        # Получить lock
        async with self.lock:
            # Только одна корутина выполняет это одновременно
            temp = self.value
            await asyncio.sleep(0)  # Переключение внутри lock
            self.value = temp + 1
            # lock освобождается

async def main():
    counter = Counter()
    await asyncio.gather(
        *[counter.increment() for _ in range(10)]
    )
    print(counter.value)  # 10 ✓

asyncio.run(main())

Решение 2: Semaphore (для лимита параллельности)

import asyncio

async def fetch_url(session, url, semaphore):
    # Максимум 5 одновременных запросов
    async with semaphore:
        # Получить разрешение
        async with session.get(url) as response:
            return await response.text()
        # Отпустить разрешение

async def main():
    semaphore = asyncio.Semaphore(5)
    urls = [f"http://example.com/{i}" for i in range(100)]
    
    async with aiohttp.ClientSession() as session:
        tasks = [fetch_url(session, url, semaphore) for url in urls]
        results = await asyncio.gather(*tasks)

asyncio.run(main())

Решение 3: Queue (для координации)

import asyncio

async def producer(queue):
    for i in range(10):
        await queue.put(f"item-{i}")
        await asyncio.sleep(0.1)

async def consumer(queue, worker_id):
    while True:
        try:
            item = queue.get_nowait()
            print(f"Worker {worker_id}: processing {item}")
            await asyncio.sleep(0.5)
            queue.task_done()
        except asyncio.QueueEmpty:
            break

async def main():
    queue = asyncio.Queue()
    
    # Продюсер добавляет в очередь
    # Консьюмеры обрабатывают
    # Race condition не возможна - очередь thread-safe!
    
    await asyncio.gather(
        producer(queue),
        consumer(queue, 1),
        consumer(queue, 2),
        consumer(queue, 3)
    )

asyncio.run(main())

Решение 4: Правильная работа с БД

import asyncio
import asyncpg

async def transfer_money_safe(from_id, to_id, amount):
    # Использовать SELECT FOR UPDATE для lock
    async with db.transaction():
        # Читаем с блокировкой
        balance = await db.fetchval(
            """
            SELECT balance FROM accounts 
            WHERE id = $1
            FOR UPDATE  -- КЛЮЧ: блокируем строку!
            """,
            from_id
        )
        
        if balance < amount:
            raise ValueError("Insufficient funds")
        
        # Обновляем с гарантией
        await db.execute(
            "UPDATE accounts SET balance = balance - $1 WHERE id = $2",
            amount,
            from_id
        )

# Безопасный transfer - race condition невозможна

5. Таблица: Когда нужны механизмы защиты

ОперацияРискЗащита
Чтение + запись разных переменныхНизкийОбычно ok
Чтение + запись одной переменнойВысокийLock
Несколько операций на одном ресурсеВысокийLock или Transaction
Работа с очередьюНизкийasyncio.Queue (встроено)
Работа с БДВысокийSELECT FOR UPDATE или Transact
Подсчёт счётчикаВысокийLock

Резюме

ДА, race conditions в asyncio возможны и опасны!

Ошибка думать что одноточность = нет race conditions.

Основная причина: await это точка переключения контекста.

Защита:

  • Locks для shared state
  • Semaphores для лимита параллельности
  • Queues для async-safe координации
  • SELECT FOR UPDATE в БД
  • Transactions для ACID

Правило: Если переменная или ресурс используется в нескольких корутинах одновременно — нужна защита!

Возможно ли происхождение состояния гонки в asyncio? | PrepBro