Привет, Хабр!

Два потока одновременно оказались в критической секции, и оба зашли туда через lock по одному и тому же объекту. За один прогон таких встреч набралось 106 тысяч.

Начиналось всё с правки на одно слово. В.NET 9 появился System.Threading.Lock, и с тех пор в статьях и на ревью советуют заменить private static readonly object gate = new() на private static readonly Lock gate = new() — быстрее и понятнее.

Старый и новый замок живут на одном объекте и ничего друг о друге не знают. Пока весь код ходит к полю через тип Lock, всё в порядке. Достаточно одному месту получить тот же экземпляр через переменную типа object или через универсальный метод — и взаимное исключение между этими двумя местами кончилось.

Во что превращается lock для каждого из двух типов

Оператор lock с C# 13 разворачивается двумя разными способами, и выбирает компилятор по статическому типу выражения в скобках. Для object это по‑прежнему Monitor:

lock (gate) { /* тело */ }

// примерно эквивалентно
bool taken = false;
try { Monitor.Enter(gate, ref taken); /* тело */ }
finally { if (taken) Monitor.Exit(gate); }

Для System.Threading.Lock компилятор выдаёт совсем другое:

lock (gate) { /* тело */ }

// точно эквивалентно
using (gate.EnterScope()) { /* тело */ }

Запирать в CLR можно любой объект, поэтому место под состояние замка приходится искать в самом объекте. Документация рантайма описывает это так: пока в заголовке объекта есть место, Monitor держит там идентификатор потока‑владельца, и такой замок называют тонким. Когда места не хватает или начинается конкуренция, объект раздувается (рантайм создаёт для него отдельный блок синхронизации, а в заголовке остаётся индекс этого блока).

Lock устроен иначе именно в этом месте. Состояние захвата лежит в его собственных полях. Заголовок объекта в этом не участвует вообще. Разница в устройстве здесь важнее разницы в скорости, к которой я ещё вернусь: два состояния приходится держать рядом на одном объекте, где они ничего друг о друге не знают и второго никто не проверяет.

Отсюда и вывод, который документация формулирует так:

When using the C# lock keyword or similar to enter and exit a lock, the type of the expression must be precisely System.Threading.Lock. If the type of the expression is anything else, such as Object or a generic type like T, a different implementation that is not interchangeable can be used instead (such as Monitor).

Слово interchangeable тут стоит прочитать буквально. Два замка просто не заменяют друг друга, и скорость тут вторична.

Два потока в одной критической секции

Соберём это в проверяемую конструкцию. Один экземпляр Lock, второе поле — тот же экземпляр, объявленный как object:

public static readonly Lock TypedGate = new();
public static readonly object LeakedGate = TypedGate;   // предупреждение CS9216

Внутри критической секции поставим счётчик посетителей и наивный инкремент без Interlocked, чтобы потеря была видна:

static void Critical()
{
    if (Interlocked.Increment(ref Shared.Inside) != 1)
        Interlocked.Increment(ref Shared.Overlaps);
    Shared.Counter++;
    Interlocked.Decrement(ref Shared.Inside);
}

Дальше два потока по 150 000 проходов каждый. Первый заходит через поле типа Lock, второй — через поле типа object:

-- один экземпляр: поток A через Lock, поток B через object
   ReferenceEquals: True
   счётчик: ожидали 300000, получили 295513
   одновременных входов в критическую секцию: 106309

Ссылки совпадают, объект физически один. А в критической секции потоки встретились 106 309 раз. Счётчик потерял 4487 инкрементов потому, что Counter++ не атомарен и выполнялся параллельно. От запуска к запуску число встреч гуляет, у меня выходило от 28 до 168 тысяч, но нулём оно не становится ни разу.

Заметить это на обзоре кода почти невозможно: оба места выглядят как обычный lock по общему полю. Защита пропала не в них, а в строке, где экземпляр положили в переменную другого типа, и эта строка может лежать в другом файле.

Где компилятор предупреждает

Про это знали при проектировании. Предупреждение есть, звучит так:

A value of type System.Threading.Lock converted to a different type will use likely unintended monitor‑based locking in lock statement.

Сработало оно у меня в пяти местах, и три из них стоит перечислить, потому что они разной формы. Первое — присваивание в поле типа object. Второе — Monitor.Enter(gate) прямо на экземпляре Lock. Параметр у него типа object. Третье — инициализатор словаря:

static readonly Dictionary<string, object> Gates = new() { ["a"] = new Lock() };

Сюда же попадает явное приведение (object)gate в сравнении ссылок, хотя там никакого lock рядом нет. То есть предупреждение ловит сам факт преобразования, а не подозрительный lock, и в этом его сила: оно срабатывает в точке, где защита ломается, а не там, где это потом проявится.

Где он молчит

Молчит он в двух случаях. Оба встречаются чаще, чем присваивание в object.

Первый — универсальный метод. Вспомогательные обёртки вида «залочь и сделай» пишут все, и выглядят они обычно так:

static void LockAny<T>(T gate) where T : class
{
    lock (gate) { Critical(); }
}

Никакого преобразования к object при вызове LockAny(Shared.TypedGate) не происходит: T выводится как Lock. Предупреждать компилятору вроде бы не о чем, и он не предупреждает.

Но тело то универсального метода компилируется один раз, T внутри остаётся параметром типа, и lock (gate) превращается в Monitor. Замер даёт то же самое, что и раньше, только теперь совсем молча:

-- один экземпляр: поток A через Lock, поток B через LockAny<T>
   счётчик: ожидали 300000, получили 295458
   одновременных входов: 137046

Первое, что приходит в голову, — поставить ограничение и заставить компилятор увидеть тип. Не выйдет:

error CS0701: 'Lock' is not a valid constraint. A type used as a constraint
must be an interface, a non-sealed class or a type parameter.

Lock объявлен sealed, а на sealed‑класс ограничение поставить нельзя. Значит универсальную обёртку не вылечить ни where T : Lock, ни чем‑то похожим, и единственный выход — принимать Lock обычным параметром конкретного типа, отдельной перегрузкой рядом с той, что берёт object.

Второй случай — чтение обратно из коллекции. Предупреждение стоит в той строке, где Lock положили в Dictionary<string, object>, а там, где его потом читают, всё чисто:

lock (Gates["a"]) { }   // ни ошибки, ни предупреждения

Индексатор возвращает object, преобразования тут нет, и компилятор видит совершенно законный lock по ссылочному типу. Если словарь заполняется в одном сборочном проекте, а читается в другом, единственное предупреждение остаётся в первом, и второй проект о нём не узнает никогда.

Привычная диагностика начинает врать

Рядом с самой блокировкой обычно живут проверки, что она взята. Пишут их через Monitor.IsEntered. В утверждениях вида Debug.Assert(Monitor.IsEntered(_gate)) они годами работают. После замены типа поля эти проверки продолжают компилироваться и продолжают выполняться. Отвечать они начинают неправду:

-- Monitor.IsEntered при захвате через Lock
   Gate.IsHeldByCurrentThread = True
   Monitor.IsEntered(Gate)    = False

Блокировка взята, поток внутри критической секции, а Monitor.IsEntered честно отвечает, что монитор этого объекта свободен. Так и есть. Монитор никто не брал. А утверждение молча ломается: в отладочной сборке Debug.Assert начнёт срабатывать там, где всё правильно, и разбираться с ним будут долго.

Правильный вопрос теперь задаётся через свойство самого замка, IsHeldByCurrentThread. Его и надо подставлять вместо Monitor.IsEntered. Причём во всех вспомогательных проверках сразу.

Ещё два свойства нового типа стоит знать заранее. Повторный вход тем же потоком работает так же, как у Monitor: вложенный lock по тому же Lock проходит и не блокирует сам себя. А EnterScope возвращает ref struct, поэтому положить результат в поле или протащить его через await не получится, и форма с using в одном методе остаётся единственной.

Monitor.Wait перестаёт работать совсем

Есть и та половина кода, которая после замены типа сразу падает. Условные переменные в C# традиционно делают через Monitor.Wait и Monitor.Pulse по тому же объекту, который используется для lock:

var gate = new Lock();
lock (gate) { Monitor.Wait(gate, 10); }

Выхлоп:

SynchronizationLockException: Object synchronization method was called from an unsynchronized block of code.

Причина уже разобрана: lock зашёл через EnterScope, а Monitor.Wait требует, чтобы вызывающий держал монитор этого объекта, которого никто не брал. Падает это сразу, и тут повезло.

Хуже выглядит состав типа. Вот все открытые члены Lock, снятые рефлексией:

Void .ctor()
Void Enter()
Scope EnterScope()
Void Exit()
Boolean IsHeldByCurrentThread
Boolean TryEnter()
Boolean TryEnter(Int32)
Boolean TryEnter(System.TimeSpan)

Ручные пары Monitor.Enter и Monitor.Exit переносятся один в один: у Lock есть свои Enter и Exit с той же семантикой. Monitor.TryEnter с таймаутом закрывается тремя перегрузками TryEnter. А вот Monitor.Enter(obj, ref taken), который пишут ради корректного finally, аналога не имеет, и он больше не нужен: EnterScope вместе с using делает то же самое надёжнее.

Ни Wait, ни Pulse, ни PulseAll в списке нет. Своего механизма ожидания у Lock нет. А монитором того же объекта пользоваться нельзя. Значит производитель‑потребитель, собранный на Monitor.Wait и Monitor.Pulse, на Lock не переносится в принципе, и переписывать его придётся на SemaphoreSlim, Channel<T> или ManualResetEventSlim. Это уже не правка на одно слово.

Заодно проверил, что ограничение на await внутри lock никак не поменялось: и с Lock, и с object получается та же ошибка CS1996. А lock по Lock в async‑методе, где await стоит за пределами блокировки, собирается без замечаний.

Выигрыш, которого я не нашёл

Остался вопрос, ради чего замену и делают. Документация обещает осторожно, и стоит привести формулировку целиком: использование EnterScope с using или оператора lock “might also have performance benefits over using Enter/TryEnter and Exit”.

Я взял 10 000 000 захватов без конкуренции, 7 раундов, варианты чередуются внутри одного процесса, сравнение по минимумам:

lock (object)          21.0 21.2 19.2 20.4 15.9 17.3 20.4   минимум 15.9
lock (Lock)            20.7 19.6 18.7 17.9 15.7 21.3 19.8   минимум 15.7
lock (Lock как object) 19.9 19.8 17.4 16.3 16.0 20.9 16.8   минимум 16.0

Разница между типами — 0.2 наносекунды при разбросе между раундами от 15.7 до 21.3. На таком разбросе это шум, а не результат. Под конкуренцией, окном по 400 мс, картина такая же смазанная: на двух потоках Monitor дал 12.42 миллиона захватов в секунду против 10.71 у Lock, на четырёх — 19.70 против 20.09.

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

Раз по времени разницы нет, я полез искать её в память, и там она нашлась сразу. Миллион экземпляров каждого типа:

object    30 МБ всего, 24 байт на экземпляр
Lock      45 МБ всего, 40 байт на экземпляр

Lock дороже на 16 байт. Платите вы их всегда, с момента создания. Monitor же не берёт ничего, пока замок не понадобился по‑настоящему: состояние живёт в заголовке, который у объекта и так есть. Зато при конфликте объект раздувается. И вот эта плата уже не возвращается.

Замер: 200 000 отдельных замков, по одному конфликту на каждый, и смотрим на прирост памяти процесса.

вариант     время   память процесса
object      252 мс      4948 КБ
Lock        243 мс       632 КБ

Время одинаковое, а памяти object съел в 8 раз большеб примерно 25 байт на каждый раздутый объект, и это блоки синхронизации, которые рантайм создал и держит. У Lock прирост маленький, потому что своё состояние он уже оплатил при создании.

Так и складывается ответ на вопрос «стоит ли менять ради скорости». Если замков десяток, не меняется ничего, ни в наносекундах, ни в байтах. Если их десятки тысяч и они конкурентные, Lock держит память ровной и предсказуемой, а Monitor растёт по мере того, как объекты раздуваются.

Как менять, если всё‑таки менять

Работает Lock хорошо. Тип замка в C# теперь влияет на семантику, а не только на производительность, и любая точка, где этот тип теряется, тихо возвращает вас к Monitor.

Начать поэтому стоит с самого дешёвого: включить CS9216 как ошибку, а не предупреждение. Одна строчка в .csproj превращает целый класс проблем в непроходимую сборку, а другого места, где компилятор даёт гарантию, у вас и нет:

<WarningsAsErrors>CS9216</WarningsAsErrors>

Дальше идут места, которые компилятор не видит. Универсальные обёртки с lock по параметру типа надо найти поиском по проекту и либо принимать в них Lock явным типом, либо оставлять object и не пускать туда новый тип. Коллекции замков, где значение объявлено как object, стоит перевести на Dictionary<string, Lock>. Публичные методы и свойства, возвращающие объект блокировки наружу как object, лучше не оставлять вовсе, потому что предупреждение в чужой сборке не покажется.

И про Monitor.Wait: если он в коде есть, замену типа проще не начинать. Переезд на другой примитив ожидания — это отдельная задача с собственным тестированием, и делать её заодно с однострочной правкой производительности не стоит.

Итак, мы разобрали, что происходит с кодом.NET на этапе выполнения: почему замена одного типа блокировки может повлиять на поведение программы и где скрываются неожиданные проблемы. Продолжить разбираться в устройстве платформы и инструментах, которые помогают находить такие нюансы в production‑коде, можно на бесплатных вебинарах:

  • 1 октября в 20:00. «JIT и AOT в.NET: ReadyToRun и NativeAOT на практике». Записаться

  • 20 октября в 20:00. «OpenTelemetry в.NET: от чёрного ящика к наблюдаемой системе». Записаться

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


  1. levlimin
    01.10.2026 09:49

    У меня даже в голову не пришло бы писать так
    public static readonly Lock TypedGate = new();public static readonly object LeakedGate = TypedGate; // предупреждение CS9216

    Или уж везде Lock lock = new()
    Или везде Object lock = new...
    Сижу до сих пор на старом варианте


  1. Ptalomej
    01.10.2026 09:49

    Если почитать документацию, то там акцентируется за счёт чего лучше новый обьект, он лучше только при конкуренции на коде который лочится ненадолго, за счёт того что ему не всегда нужно усыплять поток