Menu

Оператор lock в C#: состояние гонки, Interlocked и взаимоблокировки

Оператор lock позволяет выполнять блок кода только одному потоку одновременно. Посмотрите, как состояние гонки портит счётчик, исправьте это через lock, узнайте, на каком объекте блокироваться, используйте Interlocked для простых счётчиков, избегайте взаимоблокировок из-за порядка блокировок и берите SemaphoreSlim, когда код внутри использует await.

На этой странице есть исполняемые редакторы: меняйте, запускайте и сразу видите результат.

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

Проблема: состояние гонки

Четыре задачи прибавляют к общему счётчику по 100 000. Ответ должен быть 400 000:

Пример вывода:

Expected 400000, got 245609

Итог обычно получается меньше, и каждый раз на разную величину (на одноядерной машине он иногда может оказаться правильным, и именно поэтому такие ошибки трудно поймать). count++ выглядит как одна операция, а на деле их три: прочитать count, прибавить единицу, записать обратно. Два потока могут оба прочитать 500, оба вычислить 501 и оба записать 501, и одно увеличение пропадёт.

Исправление: lock

Оберните чтение, изменение и запись в lock на общем объекте:

Вывод:

Expected 400000, got 400000

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

Защита работает, только если каждое обращение к count проходит через одну и ту же блокировку. Один незаблокированный count++ где-нибудь в программе возвращает гонку.

Во что компилируется lock

lock это сокращение для класса Monitor с try/finally, чтобы блокировка освобождалась, даже когда блок выбрасывает исключение:

lock (sync)
{
    count++;
}

// is compiled roughly as:
bool taken = false;
try
{
    Monitor.Enter(sync, ref taken);
    count++;
}
finally
{
    if (taken) Monitor.Exit(sync);
}

Monitor также предлагает TryEnter(obj, timeout), который сдаётся по истечении тайм-аута, а не ждёт вечно, и Wait/Pulse для сигналов между потоками. Поток, который уже держит блокировку, может войти в неё снова (блокировки реентерабельны), поэтому заблокированный метод может вызвать другой заблокированный метод на том же объекте.

Выбор объекта блокировки

Объект в lock (...) это лишь метка, о которой договариваются потоки. Правила:

  • Используйте закрытое поле только для чтения типа object. private readonly object sync = new object();. Закрытое значит, что внешний код не может взять ту же блокировку; readonly значит, что метку нельзя подменить, пока её держит поток.
  • Никогда не пишите lock (this). Любой, у кого есть ссылка на ваш объект, тоже может на нём заблокироваться, и его код заблокирует ваш или устроит с ним взаимоблокировку.
  • Никогда не блокируйтесь на строке или typeof(...). Строковые литералы интернируются (каждый "orders" в процессе это один и тот же объект), а объекты Type общие для всего приложения, поэтому несвязанный код может оказаться в борьбе за ту же блокировку.
  • Никогда не блокируйтесь на типе значения. lock (count) для int не компилируется (ошибка CS0185), а ручная упаковка каждый раз создаёт новый объект, поэтому ничего бы не блокировала.
  • Используйте одну блокировку на каждый набор данных, который должен оставаться согласованным: статическое поле блокировки для статических данных, поле экземпляра для данных экземпляра.

Защита многошаговых операций

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

Вывод:

10 left after 33 withdrawals

Без блокировки два потока могли бы оба увидеть баланс 40, оба пройти проверку и оба списать 30, оставив на счёте минус 20. Метод Summary тоже блокируется, поэтому никогда не сообщает balance после списания вместе со счётчиком withdrawals до него.

Dictionary<TKey, TValue> и List<T> тоже не потокобезопасны: одновременная запись может испортить их внутренние массивы, а не просто потерять обновления. Защищайте их блокировкой или используйте ConcurrentDictionary<TKey, TValue> и другие типы из System.Collections.Concurrent.

Interlocked для одиночных значений

Когда общее состояние это одно целое число или одна ссылка, а обновление выполняется за один шаг, класс Interlocked делает его атомарно без блокировки:

Вывод:

Visits: 10000
Bytes: 5120000
Peak: 10000

Increment, Decrement, Add и Exchange соответствуют атомарным операциям процессора (одна инструкция на x64). CompareExchange(ref location, newValue, expected) записывает, только если в ячейке всё ещё expected, и возвращает найденное значение, что позволяет построить любое обновление в виде цикла повторных попыток. Всё, что затрагивает две переменные сразу, по-прежнему требует lock.

Взаимоблокировки

Взаимоблокировка возникает, когда каждый из двух потоков держит блокировку, нужную другому:

// Thread 1                          // Thread 2
lock (accountA)                      lock (accountB)
{                                    {
    lock (accountB) { /* ... */ }        lock (accountA) { /* ... */ }
}                                    }

Если поток 1 взял accountA, а поток 2 взял accountB, каждый затем вечно ждёт другого. Ни исключения, ни тайм-аута; программа зависает. Перевод между двумя счетами, который блокирует сначала «откуда», а затем «куда», приводит ровно к этому, когда одновременно выполняются два встречных перевода.

Стандартные способы защиты:

  • Блокируйтесь в фиксированном глобальном порядке. Для перевода сначала блокируйте счёт с меньшим идентификатором, в какую бы сторону ни шли деньги.
  • Держите блокировки недолго и выполняйте медленную работу (ввод-вывод, логирование, сетевые вызовы) вне их.
  • Не вызывайте неизвестный код, держа блокировку: события, обратные вызовы и виртуальные методы могут брать собственные блокировки.
  • Используйте Monitor.TryEnter с тайм-аутом там, где зависание хуже отказа.

Никакого await внутри lock: используйте SemaphoreSlim

await внутри блока lock не разрешён (ошибка компилятора CS1996). После await метод может продолжиться в другом потоке, а блокировку Monitor должен освободить тот же поток, который её взял. Для асинхронного кода SemaphoreSlim со счётчиком 1 работает как блокировка, совместимая с async:

Вывод:

5 saved, one at a time

Всегда сочетайте WaitAsync с Release в finally, иначе исключение оставит ворота закрытыми навсегда. SemaphoreSlim(3, 3) пропускает трёх вызывающих одновременно, и так ограничивают число одновременных запросов к API с ограничением частоты.

System.Threading.Lock (.NET 9)

.NET 9 вместе с C# 13 добавляет отдельный тип System.Threading.Lock. Когда объект в операторе lock имеет тип Lock, компилятор использует его более быстрый API EnterScope вместо Monitor:

private readonly Lock sync = new Lock();

public void Add(decimal amount)
{
    lock (sync)          // uses Lock.EnterScope(), not Monitor
    {
        balance += amount;
    }
}

Правила выбора объекта остаются теми же. В более ранних версиях правильный выбор это private readonly object.

Частые ошибки

  • Блокировка части обращений, но не всех. Каждое чтение и каждая запись общих данных должны брать одну и ту же блокировку.
  • lock (this), lock (typeof(X)), lock ("name"). Внешний код может взять ту же блокировку.
  • Медленный ввод-вывод внутри блокировки. Все остальные потоки ждут; держите блок коротким.
  • Две блокировки в разном порядке в разных местах. Классическая взаимоблокировка.
  • lock вокруг await. Не компилируется; используйте SemaphoreSlim.
  • Блокировка на каждый вызов. lock (new object()) ничего не защищает; объект должен быть общим.

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

Что делает lock в C#?

lock (obj) { ... } позволяет выполнять блок для данного объекта блокировки только одному потоку одновременно. Второй поток, дошедший до lock на том же объекте, ждёт, пока первый не выйдет из блока. Блокировка освобождается, даже если блок выбрасывает исключение, потому что lock компилируется в Monitor.Enter и Monitor.Exit внутри try/finally.

На каком объекте блокироваться в C#?

На отдельном закрытом поле: private readonly object _sync = new object();. Это должен быть ссылочный тип, общий для всех потоков, работающих с защищаемыми данными, и недоступный снаружи класса. Никогда не блокируйтесь на this, на Type (typeof(MyClass)) или на строке, потому что другой код может заблокироваться на том же объекте и заблокировать вас или вызвать взаимоблокировку.

Когда использовать Interlocked вместо lock?

Когда общее состояние это одно число или одна ссылка, а операция выполняется за один шаг: Interlocked.Increment(ref count), Interlocked.Add(ref total, x), Interlocked.Exchange или CompareExchange. Это атомарные аппаратные операции, и они быстрее блокировки. Для всего, что затрагивает несколько полей или коллекцию, используйте lock.

Можно ли использовать await внутри lock в C#?

Нет, компилятор отвергает await внутри блока lock (ошибка CS1996), потому что код после await может продолжиться в другом потоке, а не в том, который держит блокировку. Вместо этого используйте SemaphoreSlim со счётчиком 1: await sem.WaitAsync(); try { ... } finally { sem.Release(); }.

Как возникают взаимоблокировки с lock?

Поток 1 держит блокировку A и ждёт блокировку B, а поток 2 держит блокировку B и ждёт блокировку A. Ни один не может продолжить, и программа зависает без исключения. Предотвращайте это, всегда беря несколько блокировок в одном и том же глобальном порядке, удерживая блокировки как можно меньше времени и никогда не вызывая неизвестный код, держа блокировку.

Coddy programming languages illustration

Учитесь программировать с Coddy

НАЧАТЬ