← Назад ко всем статьям

Примитивы синхронизации в .NET: режим пользователя, режим ядра и привязка к потоку

Опубликовано

Третья статья серии: «Продвинутый C# для следующего собеседования»

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

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

Однако работают они по-разному. Важнее не сам интерфейс программирования, а то, что происходит, пока код ждёт освобождения ресурса.

Одни примитивы используют недорогие атомарные инструкции процессора или короткое активное ожидание. Другие задействуют механизмы операционной системы: объекты ожидания, системные вызовы и планировщик потоков.

Чтобы понять цену этих решений, начнём с режимов пользователя и ядра.

Режим пользователя и режим ядра

Современные операционные системы выполняют код на разных уровнях привилегий.

Режим пользователя — среда, в которой работает обычный код приложений: программы на C#, службы ASP.NET Core, фоновые обработчики и консольные утилиты.

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

Режим ядра — привилегированная среда, в которой работают ядро ОС, драйверы устройств, планировщик потоков и другие низкоуровневые системные компоненты.

Когда приложению нужна операция, которую нельзя выполнить напрямую в режиме пользователя, оно обращается к операционной системе. Например, чтобы создать поток, прочитать файл, ждать объект синхронизации ОС или выполнить низкоуровневую сетевую операцию.

Для синхронизации это различие определяет стоимость ожидания:

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

На этом основано различие между лёгкими внутрипроцессными подходами и примитивами, работающими через ядро ОС.

Лёгкие внутрипроцессные подходы

Распространённые примеры:

  • Interlocked
  • SpinLock
  • SpinWait

Эти средства не создают объект ожидания ОС и не требуют немедленно приостановить текущий поток.

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

Interlocked.Increment(ref counter);

Операция атомарна. Несколько потоков могут одновременно увеличивать счётчик, не теряя обновления из-за гонки типа «чтение — изменение — запись».

SpinLock и SpinWait основаны на другой идее. Если ресурс занят, поток может недолгое время оставаться активным и повторно проверять, не освободился ли ресурс. Это называют активным ожиданием.

Поток не приостанавливается сразу: во время ожидания он продолжает выполняться на ядре процессора.

Это действительно может быть расточительно. Но если блокировка удерживается совсем недолго, краткое активное ожидание может обойтись дешевле, чем:

  1. Перейти в операционную систему.
  2. Приостановить поток.
  3. Передать выполнение другому потоку.
  4. Разбудить исходный поток через планировщик.
  5. Восстановить контекст его выполнения.

Когда ожидание затягивается, соотношение меняется. Поток в активном ожидании расходует процессорное время впустую и конкурирует с потоками, которые могли бы продвинуть работу вперёд.

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

Отказ от дорогого перехода к ожиданию под управлением ОС может стоить активного расходования процессорного времени.

Примитивы на основе ядра и объекты ОС

Другая группа примитивов синхронизации построена на механизмах ожидания операционной системы:

  • Mutex
  • Semaphore
  • AutoResetEvent
  • ManualResetEvent
  • EventWaitHandle

В .NET эти типы наследуются от WaitHandle и представляют объекты синхронизации ОС.

Если поток вызывает WaitOne() у уже занятого Mutex, операционная система может приостановить ожидающий поток до освобождения мьютекса. Поток перестаёт активно ждать и не тратит процессорное время только на проверку доступности ресурса.

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

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

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

В обычном коде ASP.NET Core эта возможность нужна редко. Для синхронизации внутри одного серверного процесса обычно лучше подходят более лёгкие и специализированные средства.

Почему современные примитивы часто гибридные

Граница между лёгкой и тяжёлой синхронизацией не всегда чёткая.

Современные примитивы .NET обычно оптимизированы для быстрого пути, когда конкуренции нет, и переходят к более дорогому ожиданию только при необходимости.

Хороший пример — Monitor, который обычно используют через ключевое слово lock:

lock (_sync)
{
    UpdateSharedState();
}

lock — стандартный выбор для защиты коротких синхронных критических участков внутри процесса, но это не просто «блокировка ядра».

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

Именно поэтому Monitor является гибридным механизмом:

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

Другой важный пример — SemaphoreSlim. Он ограничивает параллелизм внутри процесса и поддерживает WaitAsync():

private readonly SemaphoreSlim _gate = new(5, 5);

await _gate.WaitAsync(cancellationToken);
try
{
    await CallExternalApiAsync(cancellationToken);
}
finally
{
    _gate.Release();
}

Здесь не более пяти операций могут одновременно обращаться к внешнему API. Операция, которая не может войти сразу, ожидает Task, не блокируя поток на всё время ожидания.

В отличие от Semaphore, SemaphoreSlim не предназначен для межпроцессной синхронизации. Он рассчитан на работу внутри процесса: ограничение числа одновременных операций и защиту асинхронных критических участков.

Владение и привязка к потоку

Стоимость ожидания — лишь один из критериев выбора примитива. Другой — владение:

Кто имеет право освободить примитив после его захвата?

Этот вопрос приводит к понятию привязки к потоку.

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

К таким примитивам относятся:

  • Monitor / lock
  • Mutex
  • ReaderWriterLockSlim

Примитивы без привязки к потоку:

  • Semaphore
  • SemaphoreSlim
  • AutoResetEvent
  • ManualResetEvent
  • EventWaitHandle

Если один поток вошёл в lock, другой не может корректно вызвать за него Monitor.Exit(). Попытка освободить монитор, которым владеет другой поток, вызовет SynchronizationLockException.

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

Такая модель естественно подходит для синхронных критических участков:

  1. Поток входит в участок.
  2. Изменяет защищаемое состояние.
  3. Тот же поток выходит из участка.

Mutex также отслеживает владельца. Поскольку это объект синхронизации ОС, система может обнаружить неожиданное завершение владеющего потока или процесса. Тогда ожидающий поток может получить AbandonedMutexException.

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

ReaderWriterLockSlim тоже привязан к потоку. Он различает блокировки на чтение, запись и обновляемое чтение. Примитив может быть полезен, но не стоит выбирать его автоматически только потому, что приложение чаще читает, чем пишет. Дополнительная сложность оправдана лишь при подходящем характере нагрузки и конкуренции.

Повторный вход — отдельное свойство

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

Привязка к потоку отвечает на вопрос: кто владеет блокировкой?

Повторный вход отвечает на вопрос: может ли владелец захватить ту же блокировку ещё раз?

Monitor допускает повторный вход, поэтому такой код работает:

lock (_sync)
{
    lock (_sync)
    {
        // Тот же поток может войти снова.
    }
}

Monitor отслеживает и владельца, и число захватов. Поток должен выйти из монитора столько же раз, сколько в него вошёл.

Mutex тоже допускает повторный вход. А вот ReaderWriterLockSlim по умолчанию рекурсию не разрешает. Его политику рекурсии можно настроить, но это усложняет код и должно быть осознанным решением.

К SpinLock также не следует относиться как к обычной блокировке с повторным входом. Повторный захват может привести к ошибке или бессрочному ожиданию — в зависимости от настроек отслеживания владельца.

Почему привязка к потоку несовместима с async/await

Компилятор C# не разрешает использовать await внутри блока lock и выдаёт ошибку CS1996.

lock (_sync)
{
    await SaveAsync(); // CS1996
}

Это не произвольное синтаксическое ограничение.

lock основан на Monitor, а Monitor привязан к потоку: войти и выйти должен один и тот же поток.

Асинхронный метод выполняется по другой модели. Достигнув незавершённого await, метод приостанавливается, а его поток освобождается для другой работы. После завершения ожидаемой операции продолжение может выполниться в другом потоке.

В ASP.NET Core нет гарантии, что выполнение после await продолжится на том же физическом потоке.

Если бы await разрешался внутри lock, один поток мог бы захватить монитор и приостановиться на await, а позднее другой поток попытался бы освободить монитор. Это нарушило бы правило владения монитором.

Для асинхронных критических участков обычно используют SemaphoreSlim:

await _gate.WaitAsync(cancellationToken);
try
{
    await SaveAsync(cancellationToken);
}
finally
{
    _gate.Release();
}

У SemaphoreSlim нет привязки к потоку. Продолжение, вызывающее Release(), не обязано выполняться на том же физическом потоке, который вызвал WaitAsync().

Отсутствие привязки к потоку не делает примитив универсально лучшим. Это просто другая модель владения:

  • SemaphoreSlim подходит для асинхронного ожидания и ограничения параллелизма.
  • lock подходит для короткой синхронной защиты общего состояния.
  • Mutex подходит для межпроцессной синхронизации.
  • Interlocked подходит для простых атомарных изменений состояния.

У каждого примитива своя стоимость ожидания и свои правила владения.

Практическое руководство по выбору

ПотребностьОбычный выбор
Защитить короткий синхронный критический участок внутри одного процессаlock
Атомарно увеличить, заменить или сравнить одно значениеInterlocked
Защитить асинхронный критический участокSemaphoreSlim(1, 1)
Ограничить асинхронный параллелизм до N операцийSemaphoreSlim(N, N)
Координировать несколько процессовИменованный Mutex или другой примитив ОС
Разрешить одновременное чтение и исключительную записьReaderWriterLockSlim — после измерений
Оптимизировать исключительно короткое ожидание в низкоуровневом кодеSpinWait или SpinLock — с осторожностью

Ключевое уточнение — после измерений. Более специализированный примитив не становится автоматически быстрее. Его польза зависит от уровня конкуренции, длительности критического участка, характера нагрузки и того, синхронный это код или асинхронный.

Главное

Примитивы синхронизации отличаются не только названиями и интерфейсами. Сравнивая их, задайте три вопроса:

  1. Как вызывающий код ждёт и сколько это ожидание стоит?
  2. Кто владеет примитивом и кому разрешено его освободить?
  3. Совместима ли его модель выполнения с асинхронным кодом?

Эти вопросы объясняют, почему lock, SemaphoreSlim, Mutex, Interlocked и SpinWait решают разные задачи.

На уровне senior важно объяснить не только назначение примитива, но и компромиссы его стратегии ожидания, модели владения и области применения.

Предыдущие статьи серии

  1. Строим базу данных в текстовом файле: почему async недостаточно
  2. Устраняем состояние гонки: SemaphoreSlim в действии