.NET Tutorial
Middle6 мин чтенияВопрос 21 из 25

Примитивы синхронизации: lock, Monitor, Mutex, SemaphoreSlim, ReaderWriterLockSlim, Interlocked?

Зачем синхронизация

C#
private int _counter;
public void Increment() => _counter++;   // НЕ атомарно!

_counter++ — это три операции: прочитать, прибавить, записать. Два потока могут прочитать одно и то же значение:

Поток B_counter (память)Поток Aожидали 7,получили 6 —потерянноеобновлениечитает 5читает 55 + 1 = 65 + 1 = 6пишет 6пишет 6
Поток B_counter (память)Поток Aожидали 7,получили 6 —потерянноеобновлениечитает 5читает 55 + 1 = 65 + 1 = 6пишет 6пишет 6
Гонка данных при инкременте

Обзор примитивов

Примитив Область Участников async Реентерабельный Стоимость
Interlocked Одна переменная — — — Минимальная (1 инструкция CPU)
lock / Monitor / Lock Процесс 1 Нет Да Низкая
SpinLock Процесс 1 Нет Нет Очень низкая для микросекундных участков
SemaphoreSlim Процесс N Да Нет Низкая
ReaderWriterLockSlim Процесс N читателей / 1 писатель Нет Опционально Средняя
Mutex Межпроцессный 1 Нет Да Высокая (ядро ОС)
Semaphore Межпроцессный N Нет Нет Высокая

lock и Monitor

C#
private readonly object _sync = new();           // приватный объект для блокировки
private readonly Dictionary<string, int> _stock = new();

public bool TryReserve(string sku, int qty)
{
    lock (_sync)                                  // только один поток внутри
    {
        if (_stock.GetValueOrDefault(sku) < qty) return false;
        _stock[sku] -= qty;
        return true;
    }
}

// Компилятор разворачивает lock в:
bool taken = false;
try { Monitor.Enter(_sync, ref taken); /* тело */ }
finally { if (taken) Monitor.Exit(_sync); }
.NET 9+: выделенный тип Lock — быстрее и нагляднее
private readonly Lock _lock = new();
lock (_lock) { /* ... */ }                        // компилятор использует Lock.EnterScope()

Monitor также умеет Wait/Pulse (условные переменные) и TryEnter с таймаутом — но в новом коде для этих сценариев обычно удобнее Channel<T> или SemaphoreSlim.

SemaphoreSlim — для async-кода

C#
// 1. Асинхронный «lock» (N = 1)
private readonly SemaphoreSlim _mutex = new(1, 1);

public async Task<Token> GetTokenAsync(CancellationToken ct)
{
    await _mutex.WaitAsync(ct);
    try
    {
        if (_cached is { IsExpired: false }) return _cached;
        _cached = await _auth.RequestTokenAsync(ct);   // await внутри — допустимо
        return _cached;
    }
    finally
    {
        _mutex.Release();
    }
}

// 2. Ограничение параллелизма (N = 5)
private readonly SemaphoreSlim _throttle = new(5);

public async Task DownloadAllAsync(IEnumerable<Uri> urls, CancellationToken ct)
{
    var tasks = urls.Select(async url =>
    {
        await _throttle.WaitAsync(ct);
        try { await DownloadAsync(url, ct); }
        finally { _throttle.Release(); }
    });
    await Task.WhenAll(tasks);
}
// Альтернатива в .NET 6+: Parallel.ForEachAsync(urls, new ParallelOptions { MaxDegreeOfParallelism = 5 }, ...)

Release()

Очередь ожидающих
задачи 4, 5, 6

SemaphoreSlim
свободно: 0 из 3

Задача 1

Задача 2

Задача 3

Release()

Очередь ожидающих
задачи 4, 5, 6

SemaphoreSlim
свободно: 0 из 3

Задача 1

Задача 2

Задача 3

Семафор с N = 3

ReaderWriterLockSlim

C#
private readonly ReaderWriterLockSlim _rw = new();
private Dictionary<string, Rate> _rates = new();

public Rate? Get(string currency)
{
    _rw.EnterReadLock();                 // читатели не блокируют друг друга
    try { return _rates.GetValueOrDefault(currency); }
    finally { _rw.ExitReadLock(); }
}

public void Update(Dictionary<string, Rate> newRates)
{
    _rw.EnterWriteLock();                // ждёт выхода всех читателей, блокирует новых
    try { _rates = newRates; }
    finally { _rw.ExitWriteLock(); }
}

Mutex — между процессами

C#
// Запретить запуск второго экземпляра приложения
using var mutex = new Mutex(initiallyOwned: true, name: @"Global\MyApp.SingleInstance", out bool createdNew);
if (!createdNew)
{
    Console.WriteLine("Приложение уже запущено");
    return;
}

Для синхронизации между серверами Mutex не поможет — нужны распределённые блокировки (Redis SET NX PX, advisory locks PostgreSQL pg_advisory_lock, библиотека DistributedLock).

Interlocked — атомарные операции

C#
private long _requests;
public void OnRequest() => Interlocked.Increment(ref _requests);   // атомарно, без блокировки

// CompareExchange: «записать новое значение, только если текущее равно ожидаемому»
private int _state; // 0 = idle, 1 = running
public bool TryStart() => Interlocked.CompareExchange(ref _state, 1, 0) == 0;

// Lock-free обновление по шаблону CAS-цикла
public void AddToTotal(decimal amount)
{
    TotalSnapshot current, updated;
    do
    {
        current = _total;
        updated = current with { Sum = current.Sum + amount, Count = current.Count + 1 };
    }
    while (Interlocked.CompareExchange(ref _total, updated, current) != current);
}

Как выбрать

да

нет

да

нет

да

нет

да

нет

Синхронизация между
процессами?

Mutex / Semaphore
(именованные)

Одна числовая переменная
или замена ссылки?

Interlocked

Есть await
внутри критической секции?

SemaphoreSlim.WaitAsync

Много чтений,
редкие записи?

ReaderWriterLockSlim
или неизменяемый снапшот

lock

да

нет

да

нет

да

нет

да

нет

Синхронизация между
процессами?

Mutex / Semaphore
(именованные)

Одна числовая переменная
или замена ссылки?

Interlocked

Есть await
внутри критической секции?

SemaphoreSlim.WaitAsync

Много чтений,
редкие записи?

ReaderWriterLockSlim
или неизменяемый снапшот

lock

Выбор примитива синхронизации

Вопросы на засыпку

Что такое реентерабельность и почему SemaphoreSlim её не поддерживает?

Реентерабельность — поток, уже владеющий блокировкой, может захватить её повторно (рекурсия). Monitor запоминает поток-владельца. SemaphoreSlim — просто счётчик и не знает, кто его занял; повторный WaitAsync из того же потока при N=1 приведёт к deadlock.

Как происходит deadlock с двумя lock?

Поток A захватил lock1 и ждёт lock2, поток B захватил lock2 и ждёт lock1. Решение — единый порядок захвата, Monitor.TryEnter с таймаутом, минимизация вложенных блокировок.

Почему lock «быстрый»?

Monitor сначала пытается захватить блокировку атомарной операцией (thin lock в заголовке объекта), затем немного крутится (spin-wait) и только потом переводит поток в ожидание через событие ядра. При низкой конкуренции это десятки наносекунд.

Что такое SpinLock и когда он полезен?

Блокировка, которая активно крутится в цикле вместо засыпания. Полезна для очень коротких секций при низкой конкуренции (переключение контекста дороже ожидания). Структура — нельзя копировать; нереентерабельна. В прикладном коде почти не нужна.

Итог

Interlocked — для одной переменной, lock — для короткой синхронной критической секции, SemaphoreSlim — для async-кода и ограничения параллелизма, ReaderWriterLockSlim — для частого чтения, Mutex — между процессами. А лучшая синхронизация — отсутствие общего изменяемого состояния: неизменяемые данные и конкурентные коллекции.