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

Исправление Race Condition в многопоточном коде

2.0 Middle🔥 151 комментариев
#Асинхронность и многопоточность

Условие

В следующем коде есть race condition. Найдите и исправьте проблему.

public class BankAccount { private decimal _balance;

public BankAccount(decimal initialBalance)
{
    _balance = initialBalance;
}

public decimal Balance => _balance;

public bool Withdraw(decimal amount)
{
    if (_balance >= amount)
    {
        Thread.Sleep(1); // Simulates processing time
        _balance -= amount;
        return true;
    }
    return false;
}

public void Deposit(decimal amount)
{
    Thread.Sleep(1); // Simulates processing time
    _balance += amount;
}

}

// Usage causing race condition: var account = new BankAccount(100); var tasks = new List<Task>(); for (int i = 0; i < 10; i++) { tasks.Add(Task.Run(() => account.Withdraw(20))); } Task.WaitAll(tasks.ToArray()); // Expected: 0, Actual: may be negative!

Задание:

  1. Объясните, почему возникает race condition
  2. Исправьте код, используя подходящий механизм синхронизации
  3. Предложите альтернативные решения (lock, Interlocked, SemaphoreSlim)

Критерии оценки:

  • Понимание проблемы
  • Выбор правильного механизма синхронизации
  • Избежание deadlock

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

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

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

Race Condition в многопоточном коде

Почему возникает race condition?

Проблема состояния в следующем:

  1. Проверка не атомарна — Код проверяет if (_balance >= amount), но в момент после проверки другой поток может снять деньги
  2. Несинхронизированный доступ — Два потока одновременно могут читать одно значение _balance
  3. Временное окноThread.Sleep(1) создаёт большое окно для race condition

Пример сценария:

Поток 1: if (100 >= 20)  ✓ проверка пройдена
Поток 2: if (100 >= 20)  ✓ проверка пройдена
Поток 1: _balance -= 20  → _balance = 80
Поток 2: _balance -= 20  → _balance = 60 (должно быть 80!)

Результат: Снято 40, хотя проверка прошла для каждого отдельно. После 6 операций вывода 120 баланс может быть отрицательным.

Решение 1: lock (Monitor) — самое простое

public class BankAccount
{
    private decimal _balance;
    private readonly object _lockObject = new object();
    
    public BankAccount(decimal initialBalance)
    {
        _balance = initialBalance;
    }
    
    public decimal Balance
    {
        get
        {
            lock (_lockObject)
            {
                return _balance;
            }
        }
    }
    
    public bool Withdraw(decimal amount)
    {
        lock (_lockObject)  // Блокируем весь метод
        {
            if (_balance >= amount)
            {
                Thread.Sleep(1); // Теперь это безопасно
                _balance -= amount;
                return true;
            }
            return false;
        }
    }
    
    public void Deposit(decimal amount)
    {
        lock (_lockObject)
        {
            Thread.Sleep(1);
            _balance += amount;
        }
    }
}

Преимущества:

  • Простая реализация
  • Надёжно защищает критическую секцию
  • Разработана за 30+ лет, работает везде

Недостатки:

  • Может быть медленно при высокой конкуренции
  • Нет timeout
  • Сложнее отладить deadlock
  • Всем потокам нужно ждать одного lock

Решение 2: ReaderWriterLockSlim — оптимизация для чтения

public class BankAccount
{
    private decimal _balance;
    private readonly ReaderWriterLockSlim _rwLock = new ReaderWriterLockSlim();
    
    public BankAccount(decimal initialBalance)
    {
        _balance = initialBalance;
    }
    
    public decimal Balance
    {
        get
        {
            _rwLock.EnterReadLock();
            try
            {
                return _balance;
            }
            finally
            {
                _rwLock.ExitReadLock();
            }
        }
    }
    
    public bool Withdraw(decimal amount)
    {
        _rwLock.EnterWriteLock();
        try
        {
            if (_balance >= amount)
            {
                Thread.Sleep(1);
                _balance -= amount;
                return true;
            }
            return false;
        }
        finally
        {
            _rwLock.ExitWriteLock();
        }
    }
    
    public void Deposit(decimal amount)
    {
        _rwLock.EnterWriteLock();
        try
        {
            Thread.Sleep(1);
            _balance += amount;
        }
        finally
        {
            _rwLock.ExitWriteLock();
        }
    }
}

Преимущества:

  • Множество читателей могут работать одновременно
  • Только писатели блокируют друг друга

Недостатки:

  • Сложнее в реализации
  • Медленнее lock при низкой конкуренции
  • Нужны try/finally блоки

Решение 3: SemaphoreSlim — с timeout

public class BankAccount
{
    private decimal _balance;
    private readonly SemaphoreSlim _semaphore = new SemaphoreSlim(1, 1);
    
    public BankAccount(decimal initialBalance)
    {
        _balance = initialBalance;
    }
    
    public decimal Balance
    {
        get
        {
            _semaphore.Wait();
            try
            {
                return _balance;
            }
            finally
            {
                _semaphore.Release();
            }
        }
    }
    
    public bool Withdraw(decimal amount)
    {
        // Ждём с timeout 5 секунд
        if (!_semaphore.Wait(TimeSpan.FromSeconds(5)))
            throw new OperationCanceledException("Не удалось получить доступ");
        
        try
        {
            if (_balance >= amount)
            {
                Thread.Sleep(1);
                _balance -= amount;
                return true;
            }
            return false;
        }
        finally
        {
            _semaphore.Release();
        }
    }
    
    public async Task<bool> WithdrawAsync(decimal amount)
    {
        await _semaphore.WaitAsync(TimeSpan.FromSeconds(5));
        try
        {
            if (_balance >= amount)
            {
                _balance -= amount;
                return true;
            }
            return false;
        }
        finally
        {
            _semaphore.Release();
        }
    }
}

Преимущества:

  • Поддержка timeout и CancellationToken
  • Async/await поддержка
  • Избегает deadlock благодаря timeout

Недостатки:

  • Нужно обрабатывать OperationCanceledException
  • Чуть медленнее lock

Решение 4: Interlocked операции — для простых типов

public class BankAccount
{
    private long _balanceCents;  // Хранить в центах (long)
    
    public BankAccount(decimal initialBalance)
    {
        _balanceCents = (long)(initialBalance * 100);
    }
    
    public decimal Balance => _balanceCents / 100m;
    
    public bool Withdraw(decimal amount)
    {
        long amountCents = (long)(amount * 100);
        long originalBalance, newBalance;
        
        do
        {
            originalBalance = Interlocked.Read(ref _balanceCents);
            
            if (originalBalance < amountCents)
                return false;  // Недостаточно средств
            
            newBalance = originalBalance - amountCents;
        }
        while (Interlocked.CompareExchange(ref _balanceCents, newBalance, originalBalance) != originalBalance);
        
        return true;
    }
    
    public void Deposit(decimal amount)
    {
        long amountCents = (long)(amount * 100);
        long originalBalance, newBalance;
        
        do
        {
            originalBalance = Interlocked.Read(ref _balanceCents);
            newBalance = originalBalance + amountCents;
        }
        while (Interlocked.CompareExchange(ref _balanceCents, newBalance, originalBalance) != originalBalance);
    }
}

Преимущества:

  • Самый быстрый вариант (lock-free)
  • Не требует блокировок
  • Хорошо для высоконагруженных систем

Недостатки:

  • Сложная реализация
  • Работает только с простыми типами (long, int)
  • Нужно понимать CAS (Compare-And-Swap)

Решение 5: Immutable Design Pattern

public record BankAccountState(decimal Balance);

public class BankAccount
{
    private BankAccountState _state;
    private readonly object _lockObject = new object();
    
    public BankAccount(decimal initialBalance)
    {
        _state = new BankAccountState(initialBalance);
    }
    
    public decimal Balance
    {
        get
        {
            lock (_lockObject)
            {
                return _state.Balance;
            }
        }
    }
    
    public bool Withdraw(decimal amount)
    {
        lock (_lockObject)
        {
            if (_state.Balance >= amount)
            {
                Thread.Sleep(1);
                _state = _state with { Balance = _state.Balance - amount };
                return true;
            }
            return false;
        }
    }
    
    public void Deposit(decimal amount)
    {
        lock (_lockObject)
        {
            Thread.Sleep(1);
            _state = _state with { Balance = _state.Balance + amount };
        }
    }
}

Преимущества:

  • Все изменения очевидны
  • Сложнее сделать ошибку
  • Функциональный подход

Недостатки:

  • Чуть медленнее из-за создания новых объектов

Тестирование на race condition

[Test]
public void Withdraw_ConcurrentAccess_BalanceNeverNegative()
{
    var account = new BankAccount(100);
    var tasks = new List<Task>();
    
    // 10 потоков снимают по 20
    for (int i = 0; i < 10; i++)
    {
        tasks.Add(Task.Run(() =>
        {
            bool result = account.Withdraw(20);
            Assert.That(result, Is.True);
        }));
    }
    
    Task.WaitAll(tasks.ToArray());
    
    // Баланс не может быть отрицательным
    Assert.That(account.Balance, Is.GreaterThanOrEqualTo(0));
    // И не может быть выше 100
    Assert.That(account.Balance, Is.LessThanOrEqualTo(100));
}

[Test]
public void Withdraw_MixedOperations_ConsistentBalance()
{
    var account = new BankAccount(1000);
    var random = new Random(42);
    var tasks = new List<Task>();
    
    for (int i = 0; i < 100; i++)
    {
        tasks.Add(Task.Run(() =>
        {
            if (random.Next(2) == 0)
                account.Deposit(10);
            else
                account.Withdraw(5);
        }));
    }
    
    Task.WaitAll(tasks.ToArray());
    
    // Баланс должен быть позитивный
    Assert.That(account.Balance, Is.GreaterThan(0));
}

Рекомендации по выбору

lock — Используй в 95% случаев:

  • Простая реализация
  • Хорошая производительность
  • Легко отладить

SemaphoreSlim — Когда нужен timeout или async:

  • API требует async
  • Нужна защита от deadlock
  • Timeout важен

ReaderWriterLockSlim — Много читателей, мало писателей:

  • Профилирование показало узкое место на lock
  • Операции чтения намного чаще писания

Interlocked — Высоконагруженные системы:

  • Критическая производительность
  • Опытные разработчики
  • Простой тип данных