Синхронизация: lock, Monitor, SemaphoreSlim, Interlocked, ReaderWriterLockSlim
↑ 2.3 Асинхронность и многопоточность · ← Предыдущая · → Следующая
Зачем это на собесе
Классика: «как защитить общий ресурс», «чем 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++).
Практика
- Реализуйте потокобезопасный счётчик тремя способами (lock, Interlocked, SemaphoreSlim) и сравните время на 10 млн операций через BenchmarkDotNet.
- Сделайте ограничитель: не более 3 одновременных вызовов внешнего API через
SemaphoreSlim. - Найдите и исправьте deadlock из двух замков, захватываемых в разном порядке.
Вопросы с ответами
Чем lock отличается от SemaphoreSlim?
lock— эксклюзивный, реентерабельный, привязан к потоку, не поддерживаетawait.SemaphoreSlim— счётчик разрешений, не реентерабельный, не привязан к потоку и имеетWaitAsync, поэтому подходит для async-кода и для ограничения параллелизма.
Почему нельзя lock(this) или lock(typeof(T))?
Объект публично доступен: внешний код может взять тот же замок и вызвать deadlock либо ненужную конкуренцию. Нужен приватный выделенный объект.
Что делает Interlocked.CompareExchange?
Атомарно сравнивает значение с ожидаемым и при совпадении заменяет на новое, возвращая прежнее. На нём строятся lock-free структуры: цикл «прочитал — посчитал — попробовал записать».
Когда ReaderWriterLockSlim лучше lock?
Когда читателей намного больше, чем писателей, и чтение достаточно долгое. При коротких секциях накладные расходы RWLS выше выгоды.
Что такое Monitor.Wait/Pulse?
Условная синхронизация: поток отпускает замок и ждёт сигнала
Pulse/PulseAllот другого. Сегодня чаще используютChannel,BlockingCollectionилиSemaphoreSlim.