Пост 3 из серии: Advanced C# for Your Next Interview
В предыдущем посте мы исправили race condition с помощью SemaphoreSlim. Знать, какой class решает проблему, полезно, но сильный senior-level ответ на интервью должен идти дальше: почему одни synchronization primitives считаются lightweight, почему другие kernel-backed и почему lock часто описывают как hybrid mechanism?
Synchronization primitives координируют concurrent execution и защищают shared resources. Их цель - не дать нескольким threads менять data так, чтобы появлялись race conditions, broken invariants или lost updates.
Они решают эту проблему не одинаково. Главное отличие - не API, а то, что происходит, пока execution ждет, когда resource станет доступен.
Одни primitives используют недорогие atomic CPU instructions или короткое active waiting. Другие полагаются на operating-system mechanisms: wait handles, system calls и thread scheduler.
Чтобы понять стоимость этих выборов, нужно начать с User Mode и Kernel Mode.
User Mode и Kernel Mode
Современные operating systems выполняют code на разных privilege levels.
User Mode - это место, где работает обычный application code: C# applications, ASP.NET Core services, background workers и console programs.
User Mode code не может напрямую обращаться к kernel memory, hardware или privileged processor instructions. Эта isolation важна. Если application падает из-за unhandled exception, process обычно завершается, не обрушивая operating system.
Kernel Mode - привилегированная среда, которую используют OS kernel, device drivers, thread scheduler и другие low-level system components.
Когда application code нужна operation, которую нельзя выполнить напрямую в User Mode, он просит operating system сделать это. Примеры: создать thread, прочитать file, ждать OS synchronization object или выполнить low-level network I/O.
Для synchronization это различие объясняет стоимость ожидания:
- Решить проблему внутри process с помощью atomic CPU instructions и runtime-managed state обычно дешевле.
- Попросить OS припарковать thread, поставить его в очередь waiters и позже разбудить через scheduler - дороже.
На этом основано различие между lightweight user-space approaches и kernel-backed primitives.
Lightweight User-Space Approaches
Распространенные примеры:
InterlockedSpinLockSpinWait
Эти tools не создают OS wait handle и не просят operating system сразу suspend текущий thread.
Например, Interlocked предоставляет atomic operations для простых изменений state: increment counter, replace value или compare-and-swap:
Interlocked.Increment(ref counter);
Operation atomic. Multiple threads могут concurrently increment counter без lost updates из-за read-modify-write race.
SpinLock и SpinWait используют другую идею. Если resource занят, thread может короткое время оставаться active и повторно проверять, стал ли resource доступен. Это называется spinning.
Thread не suspend сразу. Он продолжает выполняться на CPU core, пока ждет.
Звучит расточительно, и так действительно может быть. Но если lock удерживается очень короткое время, brief spinning может стоить меньше, чем:
- Войти в operating system.
- Припарковать thread.
- Переключить execution на другой thread.
- Разбудить original thread через scheduler.
- Восстановить его execution context.
Tradeoff меняется, когда ожидание становится длиннее. Spinning thread потребляет CPU, не выполняя useful work, и конкурирует с threads, которые могли бы продвигаться дальше.
Поэтому spin-based synchronization лучше всего подходит для extremely short waits и low-level code. Центральный tradeoff:
Избегание дорогого transition к OS-managed waiting может стоить активного consumption CPU.
Kernel-Backed Primitives и OS Handles
Другая группа synchronization primitives построена на operating-system waiting mechanisms:
MutexSemaphoreAutoResetEventManualResetEventEventWaitHandle
В .NET эти types наследуются от WaitHandle и представляют OS synchronization objects.
Если thread вызывает WaitOne() на Mutex, который уже owned, operating system может suspend waiting thread, пока mutex не станет доступен. Thread больше не spins и не потребляет CPU только для проверки resource.
Это полезно, когда ожидание может быть долгим. Suspend thread лучше, чем бесконечно тратить processor time, но transition не бесплатный. System calls, scheduler work, context switches и wake parked thread стоят дороже простой atomic user-space operation.
У kernel-backed primitives есть еще важная возможность: некоторые из них могут координировать больше одного process.
Например, named Mutex может гарантировать, что только один process на machine выполняет конкретную operation. Это полезно для single-instance applications или cross-process access к shared resource.
В обычном ASP.NET Core code такая возможность нужна редко. Для synchronization внутри одного backend process обычно лучше подходят более легкие и специализированные tools.
Почему современные primitives часто hybrid
Граница между lightweight и heavyweight synchronization не всегда строгая.
Современные .NET primitives обычно оптимизируют fast path, когда contention нет, и переходят к более дорогому waiting только при необходимости.
Monitor, который обычно используется через keyword lock, - хороший пример:
lock (_sync)
{
UpdateSharedState();
}
lock - стандартный выбор для защиты коротких synchronous critical sections внутри process, но это не просто “kernel lock”.
Когда lock свободен, runtime может захватить его через очень быстрый path. При contention runtime может briefly spin, а затем использовать более дорогие waiting mechanisms, если lock остается недоступен.
Это делает Monitor hybrid mechanism:
Он старается оставаться lightweight в uncontended case, но может escalates waiting strategy, когда threads конкурируют за lock.
SemaphoreSlim - еще один важный пример. Он ограничивает concurrency внутри process и поддерживает WaitAsync():
private readonly SemaphoreSlim _gate = new(5, 5);
await _gate.WaitAsync(cancellationToken);
try
{
await CallExternalApiAsync(cancellationToken);
}
finally
{
_gate.Release();
}
Здесь не больше пяти operations могут одновременно вызвать external API. Operation, которая не может войти сразу, может await Task, а не блокировать thread на время ожидания.
В отличие от Semaphore, SemaphoreSlim не предназначен для cross-process synchronization. Он создан для in-process scenarios: limiting concurrent operations и protection async critical sections.
Ownership и Thread Affinity
Стоимость ожидания - только один критерий выбора primitive. Другой критерий - ownership:
Кто имеет право release primitive после acquisition?
Это приводит к понятию thread affinity.
Thread-affine primitive запоминает, какой thread владеет им. Thread, который входит в critical section, должен быть тем же thread, который из нее выходит.
Thread-affine primitives:
Monitor/lockMutexReaderWriterLockSlim
Primitives без thread affinity:
SemaphoreSemaphoreSlimAutoResetEventManualResetEventEventWaitHandle
Если один thread входит в lock, другой thread не может корректно вызвать Monitor.Exit() за него. Попытка release monitor, owned другим thread, выбросит SynchronizationLockException.
Это ownership rule защищает shared state. Один thread не может случайно release lock, захваченный другим thread, и разрешить concurrent access до завершения исходной critical section.
Модель естественно подходит для synchronous critical sections:
- Thread входит.
- Обновляет protected state.
- Тот же thread выходит.
Mutex тоже tracks ownership. Поскольку это OS synchronization object, operating system может обнаружить, что owning thread или process неожиданно завершился. Waiter тогда может получить AbandonedMutexException.
Этот exception не означает, что protected data безопасна. Он означает, что previous owner исчез и мог оставить state inconsistent. Но это лучше, чем ждать owner, которого больше не существует, forever.
ReaderWriterLockSlim тоже thread-affine. Он различает read, write и upgradeable read locks. Он может быть полезен, но его не стоит выбирать автоматически только потому, что application выполняет больше reads, чем writes. Его дополнительная complexity окупается только при подходящих contention и workload patterns.
Reentrancy - отдельное свойство
Thread affinity и reentrancy связаны, но это не одно и то же.
Thread affinity отвечает: кто owns lock?
Reentrancy отвечает: может ли owner acquire тот же lock снова?
Monitor reentrant, поэтому такой code работает:
lock (_sync)
{
lock (_sync)
{
// The same thread can enter again.
}
}
Monitor tracks и owner, и acquisition count. Thread должен exit monitor столько раз, сколько вошел.
Mutex тоже reentrant. А вот ReaderWriterLockSlim по умолчанию не разрешает recursion. У него есть configurable recursion policy, но включение recursion увеличивает complexity и должно быть осознанным.
SpinLock тоже не стоит воспринимать как обычный reentrant lock. Повторный вход может привести к failures или indefinite wait, в зависимости от owner-tracking configuration.
Почему Thread Affinity конфликтует с async/await
C# compiler не разрешает await внутри lock block и сообщает error CS1996.
lock (_sync)
{
await SaveAsync(); // CS1996
}
Это не произвольное syntax restriction.
lock основан на Monitor, а у Monitor есть thread affinity. Thread, который входит, должен быть thread, который выходит.
Async method работает по другой execution model. Когда он доходит до incomplete await, method suspended, а его thread свободен делать другую работу. Когда awaited operation завершается, continuation может run на другом thread.
В ASP.NET Core нет гарантии, что execution после await продолжится на том же physical thread.
Если бы await был разрешен внутри lock, один thread мог бы acquire monitor, suspend на await, а позже попытаться release monitor с другого thread. Это нарушило бы ownership rule у monitor.
Для async critical sections обычно используют SemaphoreSlim:
await _gate.WaitAsync(cancellationToken);
try
{
await SaveAsync(cancellationToken);
}
finally
{
_gate.Release();
}
У SemaphoreSlim нет thread affinity. Continuation, который вызывает Release(), не обязан run на том же physical thread, который вызвал WaitAsync().
Это не означает, что отсутствие thread affinity всегда лучше. Это просто другая ownership model:
SemaphoreSlimподходит для async waiting и concurrency limits.lockподходит для короткой synchronous protection shared state.Mutexподходит для cross-process synchronization.Interlockedподходит для simple atomic state transitions.
У каждого primitive своя waiting cost и ownership semantics.
Практический guide по выбору
| Requirement | Typical choice |
|---|---|
| Protect a short synchronous critical section inside one process | lock |
| Increment, exchange, or compare a single value atomically | Interlocked |
| Protect an async critical section | SemaphoreSlim(1, 1) |
| Limit async concurrency to N operations | SemaphoreSlim(N, N) |
| Coordinate multiple processes | Named Mutex or another OS primitive |
| Allow concurrent readers and exclusive writers | ReaderWriterLockSlim, after measuring |
| Optimize an extremely short wait in low-level code | SpinWait or SpinLock, with care |
Важное уточнение - after measuring. Более специализированный primitive не становится автоматически быстрее. Польза зависит от contention, critical-section duration, workload shape и от того, code synchronous или asynchronous.
Главный вывод
Synchronization primitives отличаются не только names и APIs. Сравнивая их, спрашивайте:
- Как caller ждет, и сколько стоит это ожидание?
- Кто owns primitive и кто имеет право release?
- Совместима ли execution model с async code?
Эти вопросы показывают, почему lock, SemaphoreSlim, Mutex, Interlocked и SpinWait решают разные задачи.
Senior-level ответ должен объяснять не только то, что делает primitive, но и tradeoffs за его waiting strategy, ownership model и scope.