Синхронизация: lock, Monitor, SemaphoreSlim, Interlocked, ReaderWriterLockSlim

↑ 2.3 Асинхронность и многопоточность · ← Предыдущая · → Следующая

Обязательно~7 мин чтенияУровень: middle

Зачем это на собесе

Классика: «как защитить общий ресурс», «чем lock отличается от SemaphoreSlim», «почему нельзя await внутри lock». Отвечать нужно с примерами и с оговорками по производительности.

Объяснение

Синхронизация нужна, когда несколько потоков читают и меняют одно и то же состояние. Без неё возникают гонки данных (см. Race condition, deadlock, livelock, starvation).

ПримитивКогда использоватьAsync-friendlyЗаметка
lock / Monitorкороткая критическая секция в одном процессенетсамый дешёвый; lock = Monitor.Enter/Exit + try/finally
Interlockedатомарные операции над int/long/ссылкойда (не блокирует)Increment, Add, CompareExchange, Exchange
SemaphoreSlimограничить число одновременных входов; критическая секция с awaitда (WaitAsync)не reentrant
ReaderWriterLockSlimмного чтений, редкие записинетесть upgradeable-режим, легко словить deadlock
Mutexсинхронизация между процессаминеттяжёлый, ядро ОС
ManualResetEventSlim, CountdownEvent, Barrierсигналы и координациячастичнодля сценариев «дождись события»

lock

private readonly object _gate = new();
private int _balance;
 
public void Deposit(int amount)
{
    lock (_gate)
    {
        _balance += amount;
    }
}

Блокируемся на приватном readonly объекте, а не на this, typeof(T) или строке: их может захватить чужой код. В .NET 9 появился тип System.Threading.Lock — lock на нём быстрее и явнее.

Interlocked

private long _requests;
public void OnRequest() => Interlocked.Increment(ref _requests);
 
// lock-free обновление по CAS
static void AddIfLess(ref int target, int value, int max)
{
    int current, next;
    do
    {
        current = Volatile.Read(ref target);
        next = Math.Min(current + value, max);
    } while (Interlocked.CompareExchange(ref target, next, current) != current);
}

SemaphoreSlim

private readonly SemaphoreSlim _sem = new(initialCount: 1, maxCount: 1);
 
public async Task UpdateAsync()
{
    await _sem.WaitAsync();
    try
    {
        await _repo.SaveAsync(); // await внутри допустим
    }
    finally
    {
        _sem.Release();
    }
}

С initialCount: N получаем ограничитель параллелизма (например, не более 5 одновременных HTTP-запросов).

ReaderWriterLockSlim

private readonly ReaderWriterLockSlim _rw = new();
private readonly Dictionary<string, string> _cache = new();
 
public string? Get(string k)
{
    _rw.EnterReadLock();
    try { return _cache.GetValueOrDefault(k); }
    finally { _rw.ExitReadLock(); }
}
 
public void Set(string k, string v)
{
    _rw.EnterWriteLock();
    try { _cache[k] = v; }
    finally { _rw.ExitWriteLock(); }
}

Выигрыш есть только при заметном перекосе в сторону чтений и длинных критических секциях; иначе обычный lock быстрее.

Нюансы и подводные камни

  • Нельзя писать await внутри lock — код не компилируется; после await продолжение может пойти в другом потоке, а Monitor привязан к потоку. Используйте SemaphoreSlim.
  • lock реентерабелен (тот же поток может зайти повторно), SemaphoreSlim — нет: повторный WaitAsync в том же потоке зависнет.
  • Всегда Release в finally, иначе одна исключительная ситуация блокирует всех навсегда.
  • Держите критическую секцию короткой: не вызывайте внешний код, I/O и виртуальные методы под замком.
  • Единый порядок захвата нескольких замков — защита от deadlock.
  • volatile даёт видимость записи, но не атомарность составных операций (x++).

Практика

  1. Реализуйте потокобезопасный счётчик тремя способами (lock, Interlocked, SemaphoreSlim) и сравните время на 10 млн операций через BenchmarkDotNet.
  2. Сделайте ограничитель: не более 3 одновременных вызовов внешнего API через SemaphoreSlim.
  3. Найдите и исправьте deadlock из двух замков, захватываемых в разном порядке.

Вопросы с ответами

Связанные темы