Start Debugging

lock vs Monitor vs SemaphoreSlim vs System.Threading.Lock в C#

Четыре способа защитить критическую секцию в C# и матрица решений для выбора одного из них. Используйте System.Threading.Lock для синхронной взаимной блокировки на .NET 9+, SemaphoreSlim когда секция охватывает await, и Monitor только когда нужны Wait/Pulse.

Для синхронной взаимной блокировки в новом коде на .NET 9 или новее используйте System.Threading.Lock и пишите его с ключевым словом lock. Если критическая секция должна ожидать (await) что-либо, ни один из синхронных примитивов не допустим, поэтому обращайтесь к SemaphoreSlim(1, 1) и await WaitAsync(). Оставьте чистый Monitor для единственного случая, который остальные не могут выполнить вообще: переменные условия (Monitor.Wait / Pulse / PulseAll). Классическая идиома lock (object) не является ошибкой, она просто компилируется в немного более тяжёлый путь Monitor, чем Lock, поэтому на .NET 9+ нет причин начинать новую блокировку с простого object.

Эта статья ориентирована на .NET 11 (preview 4), C# 14 и BCL в состоянии System.Threading в net11.0. System.Threading.Lock — это тип .NET 9, поэтому рекомендация одинаково применима к .NET 9, .NET 10 и .NET 11. Monitor и ключевое слово lock восходят к .NET 1.1 и C# 1.0; SemaphoreSlim появился в .NET Framework 4.0.

Четыре претендента на самом деле не равнозначны

Причина, по которой это сравнение сбивает людей с толку, в том, что четыре названия находятся на разных уровнях.

lock — это инструкция языка C#. Сама по себе она ничего не реализует. Компилятор сводит lock (x) { body } к одной из двух форм в зависимости от статического типа x. Если x — это System.Threading.Lock, она становится using (x.EnterScope()) { body }. Для любого другого ссылочного типа она становится парой Monitor.Enter / Monitor.Exit, обёрнутой в try / finally. Так что вопрос «использовать lock или Monitor» в основном является ложным выбором: lock (someObject) и есть Monitor, написанный более безопасно.

Monitor — это статический API за классической идиомой. Он выполняет взаимную блокировку, но также несёт две возможности, которых нет у остальных: рекурсию (один и тот же поток может войти дважды) и переменные условия через Wait, Pulse и PulseAll. Эти методы переменных условия — единственная возможность во всём этом сравнении, у которой нет замены среди остальных трёх.

System.Threading.Lock — это выделенный тип для взаимной блокировки, представленный в .NET 9. Это то, чем был бы Monitor, если бы он не выполнял также роль базовой реализации для lock (object). Он предоставляет ровно то, что нужно мьютексу, и ничего больше. Подробный разбор того, как работает System.Threading.Lock и как на него мигрировать, охватывает его механику в деталях.

SemaphoreSlim — это считающий семафор, а не мьютекс, но он становится мьютексом, когда вы конструируете его со счётчиком, равным единице. Что отличает его от остальных трёх — это WaitAsync: это единственный примитив здесь, который вы можете законно удерживать через await.

Матрица решений

Каждая строка этой таблицы соответствует поведению .NET 9+ / C# 13+, если не указано иное.

Возможностьlock (object)MonitorSemaphoreSlimSystem.Threading.Lock
Взаимная блокировка (один держатель)дадада, при new(1, 1)да
Ограничение до N > 1 держателейнетнетда, new(N, N)нет
await допустим внутри удерж. областинет (CS1996)нет (CS1996)да, через WaitAsyncнет (CS1996)
Переменные условия (Wait/Pulse)нетданетнет
Реентерабельность в том же потокедаданет (дедлок)да
Проверка идентичности потока/держателядаданетда
Сводится кMonitor.Enter/Exit(самому себе)Wait/ReleaseLock.EnterScope()
Инфляция sync block при конкуренциидаданетнет
Попытка захвата с тайм-аутомMonitor.TryEnterTryEnter(TimeSpan)Wait(TimeSpan)TryEnter(TimeSpan)
Отменяемый захватнетнетда (CancellationToken)нет
Между процессаминетнетнет (используйте Semaphore)нет
IDisposableнетнетданет
Впервые появилсяC# 1.0.NET 1.1.NET Framework 4.0.NET 9

Две строки решают почти все реальные случаи: «await допустим внутри» и «переменные условия». Если вам нужно первое, вы на SemaphoreSlim. Если вам нужно второе, вы на Monitor. Всё остальное указывает на System.Threading.Lock.

Когда выбирать System.Threading.Lock

Это вариант по умолчанию для нового синхронного кода на .NET 9+.

// .NET 11, C# 14 -- the default gate for synchronous critical sections
public sealed class Counter
{
    private readonly Lock _gate = new();
    private long _value;

    public void Increment()
    {
        lock (_gate) // lowers to using (_gate.EnterScope())
        {
            _value++;
        }
    }

    public long Read()
    {
        lock (_gate)
        {
            return _value;
        }
    }
}

Если вы пока не можете перейти на .NET 9, запасной вариант — классический lock (object). У него та же семантика, немного тяжелее. Не обращайтесь к Monitor явно только ради блокировки; ключевое слово lock уже оборачивает Monitor.Enter / Exit в правильный try / finally, так что блокировка освобождается, даже если тело выбрасывает исключение. Написанный вручную Monitor.Enter без finally — это классический источник осиротевших блокировок.

Когда выбирать SemaphoreSlim

SemaphoreSlim — это ответ ровно на один вопрос, на который синхронные примитивы ответить не могут: как сериализовать секцию, содержащую await?

// .NET 11, C# 14 -- async-safe mutual exclusion across an await
public sealed class AsyncCache : IDisposable
{
    private readonly SemaphoreSlim _gate = new(1, 1); // count 1 == mutex
    private readonly Dictionary<string, byte[]> _store = new();

    public async Task<byte[]> GetOrAddAsync(string key, Func<string, Task<byte[]>> factory)
    {
        await _gate.WaitAsync();
        try
        {
            if (_store.TryGetValue(key, out var existing))
                return existing;

            var fresh = await factory(key); // legal: we are holding a semaphore, not a lock
            _store[key] = fresh;
            return fresh;
        }
        finally
        {
            _gate.Release(); // ALWAYS in finally
        }
    }

    public void Dispose() => _gate.Dispose();
}

С SemaphoreSlim приходят три ловушки, и все три восходят к одному корню: он не отслеживает, кто его удерживает. Согласно документации SemaphoreSlim, класс «не проверяет идентичность потока или задачи при вызовах методов Wait, WaitAsync и Release».

  1. Нет реентерабельности. Если метод, удерживающий семафор, вызывает другой метод, который тоже ожидает на том же семафоре, вы получаете дедлок. Monitor и Lock позволили бы тому же потоку войти повторно; SemaphoreSlim не может, потому что у него нет понятия владеющего потока для сравнения.
  2. Release не защищён. Ничто не мешает вам вызвать Release больше раз, чем вы вызвали Wait, что молча поднимает CurrentCount выше начального счётчика и нарушает инвариант. Всегда сопоставляйте Wait / WaitAsync с Release в finally.
  3. Это IDisposable. В отличие от остальных трёх, SemaphoreSlim владеет лениво выделяемым WaitHandle и должен быть освобождён. Семафор на уровне поля означает, что ваш класс теперь тоже IDisposable.

Накладные расходы на захват выше, чем у Lock. Это цена асинхронной поддержки. Не используйте SemaphoreSlim для чисто синхронного быстрого пути только потому, что он уже есть в области видимости.

Когда выбирать Monitor явно

Почти никогда, с одним реальным исключением: вам нужна переменная условия.

Monitor.Wait, Monitor.Pulse и Monitor.PulseAll позволяют потоку освободить блокировку, уснуть, пока другой поток не просигнализирует об изменении состояния, и захватить её снова при пробуждении. Это классический примитив координации ограниченного буфера / производитель-потребитель. Ни один другой тип в этом сравнении его не предоставляет. System.Threading.Lock намеренно от него отказался; у SemaphoreSlim его никогда не было.

// .NET 11, C# 14 -- the one job only Monitor can do: condition variables
public sealed class BoundedBuffer<T>
{
    private readonly object _gate = new();
    private readonly Queue<T> _items = new();
    private readonly int _capacity;

    public BoundedBuffer(int capacity) => _capacity = capacity;

    public void Add(T item)
    {
        lock (_gate)
        {
            while (_items.Count == _capacity)
                Monitor.Wait(_gate);     // release + sleep until pulsed

            _items.Enqueue(item);
            Monitor.PulseAll(_gate);     // wake any waiting consumers
        }
    }

    public T Take()
    {
        lock (_gate)
        {
            while (_items.Count == 0)
                Monitor.Wait(_gate);

            var item = _items.Dequeue();
            Monitor.PulseAll(_gate);
            return item;
        }
    }
}

Обратите внимание, что блокировка здесь — это простой object, а не Lock: Monitor.Wait/Pulse работают на sync block объекта и недоступны на System.Threading.Lock. Это и есть компромисс. Если вы ловите себя на написании этого шаблона с нуля в 2026 году, остановитесь и проверьте, не заменит ли Channel<T> всю эту конструкцию. System.Threading.Channels даёт вам ограниченную, дружественную к асинхронности очередь производитель/потребитель со встроенным backpressure, и вы больше никогда не трогаете Monitor.Wait. Самодельный ограниченный буфер сегодня представляет в основном исторический и образовательный интерес.

Другое место, где вы могли бы вызвать Monitor напрямую, — это Monitor.TryEnter для неблокирующей попытки, но у System.Threading.Lock тоже есть TryEnter, так что на .NET 9+ эта причина испаряется.

Бенчмарк: что Lock на самом деле экономит по сравнению с Monitor

Утверждение о производительности конкретно состоит в том, что System.Threading.Lock быстрее, чем поддерживаемый Monitor вариант lock (object), как для неконкурентного быстрого пути, так и для конкурентного пути. Статья Стивена Тоуба Performance Improvements in .NET 9 измеряет это с помощью BenchmarkDotNet. Неконкурентный захват сводится к единственному взаимоблокированному compare-exchange плюс барьер; конкурентный захват примерно в 2-3 раза быстрее пути Monitor.Enter, потому что Monitor проходит несколько условных ветвлений перед своим барьером.

Чего синтетические числа вам не говорят, так это насколько мало это значит в реальном сервисе, потому что реальные сервисы почти не проводят своего настенного времени внутри lock. Измеримые выигрыши в продакшене структурные, а не по пропускной способности:

Что не меняется между Monitor и Lock: пропускная способность самой защищённой секции, справедливость (оба несправедливы с лёгким противодействием голоданию) и поведение рекурсии (оба реентерабельны в том же потоке).

SemaphoreSlim находится в совершенно другом классе, и сравнение не является равнозначным: WaitAsync, завершающийся синхронно, всё равно заметно дороже, чем Lock.EnterScope, а тот, что завершается асинхронно, выделяет память и делает круг через пул потоков. Вы выбираете SemaphoreSlim не ради скорости. Вы выбираете его, потому что это единственный корректный вариант через await, а корректность каждый раз побеждает подсчёт циклов.

Ловушка, которая решает за вас

Три ограничения полностью перекрывают предпочтения:

await в критической секции вынуждает использовать SemaphoreSlim. Это не выбор стиля. lock, Monitor и Lock отслеживают владение по управляемому потоку, а await может возобновиться на другом потоке, что освободило бы блокировку от неправильного владельца. Компилятор C# отказывает в await внутри lock с CS1996. Коварный вариант — это using (_gate.EnterScope()) вокруг await: это может скомпилироваться, но выбрасывает SynchronizationLockException во время выполнения, когда продолжение пытается освободить scope на потоке, который никогда не входил. Если тело ожидает, вы на SemaphoreSlim. Точка. Это то же рассуждение стоит за тем, почему async void и async Task ведут себя так по-разному под капотом.

Переменные условия вынуждают использовать Monitor. Если вашей координации действительно нужна семантика «спать, пока не просигнализируют», и Channel<T> не подходит, только Monitor.Wait / Pulse это сделают.

Целевая платформа до .NET 9 исключает Lock. Если ваша библиотека нацелена на несколько платформ, включая netstandard2.0, System.Threading.Lock на той стороне не существует. Защитите его с помощью #if NET9_0_OR_GREATER и сохраните блокировку object на пути для более старой версии. Не делайте forward типа Lock из полифилла; семантика будет расходиться с настоящим типом.

Рекомендация, повторно сформулированная

По умолчанию используйте System.Threading.Lock для синхронной взаимной блокировки на .NET 9+ и пишите его через ключевое слово lock, чтобы компилятор управлял try / finally за вас. Опускайтесь до простой блокировки object только когда вам нужно нацелиться на среду выполнения старше .NET 9, где lock (object) даёт вам идентичную семантику при немного более высокой стоимости. Переключайтесь на SemaphoreSlim(1, 1) в тот момент, когда защищённая область содержит await, и используйте SemaphoreSlim(N, N), когда хотите ограничить параллелизм выше единицы. Трогайте Monitor напрямую только ради переменных условия Wait / Pulse, и сначала спросите, не заставит ли Channel<T> всю самодельную координацию исчезнуть. Самое короткое корректное решение: синхронно и коротко означает Lock; асинхронно означает SemaphoreSlim; сигнализация означает Monitor.

Связанное

Ссылки на источники

Comments

Sign in with GitHub to comment. Reactions and replies thread back to the comments repo.

< Назад