Многопоточность в C#
Многопоточность в C# — это не набор классов из System.Threading, а модель того, что процессор и JIT имеют право сделать с вашим кодом. Два потока, читающие и пишущие одно поле, — это не «две последовательности инструкций по очереди»: компилятор переставляет обращения, ядра держат значения в своих кэшах и регистрах, а планировщик ОС вытесняет поток в произвольной точке, в том числе посередине count++. Всё, что делают примитивы синхронизации, — ограничивают этот произвол ровно там, где он вам мешает.
Отсюда и структура темы. Сначала — проблема: состояние гонки как результат несинхронизированного чередования. Затем — инструменты, каждый со своей ценой: lock даёт взаимное исключение и барьеры, но реентерабелен и не переживает await; SemaphoreSlim пропускает N владельцев и умеет ждать асинхронно, но не реентерабелен и способен заблокировать поток против самого себя; volatile даёт упорядочивание, но не атомарность; Interlocked даёт атомарность без блокировки, но только для одной переменной. Дальше — цена ошибок: взаимоблокировка, которую CLR не обнаружит и не разорвёт, и голодание пула потоков, в которое сваливается любой сервис, где блокируются на .Result внутри асинхронной цепочки. Разбор по слоям — ниже.
Карта темы
- Состояние гонки — почему
count++не атомарен и как несинхронизированное чередование потоков теряет обновления. - Примитивы блокировки — во что разворачивается
lock, чем от него отличаютсяMonitorиMutexи на каком объекте блокироваться. - Взаимоблокировки — четыре условия Коффмана, классический цикл из двух блокировок и зависание на
.Result. - Семафоры —
SemaphoreSlimкак ограничитель параллелизма и единственный способ «блокировки» черезawait. - Видимость памяти — переупорядочивание и кэши, семантика acquire/release у
volatileи почему он не заменяетlock. - Interlocked и атомарные операции — lock-free инкремент на CAS-инструкции процессора и граница его применимости.
- Пул потоков — переиспользуемые рабочие потоки, медленная инжекция сверх минимума и голодание пула.
- Потоко-локальное состояние —
[ThreadStatic],ThreadLocal<T>и почему на потоках пула значение утекает в чужую задачу. - Конкурентные коллекции — сегментные блокировки
ConcurrentDictionary, ловушкаGetOrAdd,Channel<T>противBlockingCollection<T>. - Параллельные циклы —
Parallel.For/ForEachдля CPU-bound работы и почему для I/O это неверный инструмент.
Частые ошибки и ловушки
| Ошибка | Последствие |
|---|---|
Считать count++ одной атомарной инструкцией | Это read-modify-write — два потока читают одно значение, оба пишут +1, один инкремент теряется |
| Считать, что гонки бывают только на многих ядрах | Вытеснение посреди read-modify-write ломает код и на одном ядре |
Блокироваться на this, на публичном поле или на строковом литерале | Монитор становится публичным или (для интернированного литерала) общим на весь процесс — чужой код блокирует вас |
Ждать, что lock защищает переданный объект | lock защищает блок кода; поле, изменённое мимо блокировки, остаётся незащищённым |
Пытаться удержать lock через await | Не компилируется (CS1996) — для асинхронной сериализации нужен SemaphoreSlim.WaitAsync |
Считать SemaphoreSlim реентерабельным | Повторный Wait из того же потока при счётчике 1 блокирует поток против самого себя навсегда |
Считать, что volatile делает x++ атомарным | volatile даёт только упорядочивание acquire/release; составное обновление остаётся гонкой |
| Разрабатывать и тестировать гонки только на x86/x64 | Сильная модель памяти Intel/AMD прячет ошибку — она всплывёт на ARM |
Применять Interlocked к двум полям по отдельности | Атомарна каждая операция, но не пара — инвариант из двух полей требует lock |
| Брать блокировки в разном порядке в разных методах | Круговое ожидание — CLR не обнаружит цикл и не бросит исключение, процесс просто зависнет |
Звать .Result / .Wait() на задаче в UI-обработчике | Захваченный SynchronizationContext не может выполнить продолжение — вечное зависание |
Блокироваться внутри async-цепочки на потоках пула | Голодание пула — сверх минимума потоки добавляются по одному в секунду, задержки взлетают |
Считать, что фабрика GetOrAdd вызывается ровно один раз на ключ | Фабрика идёт вне блокировки и под конкуренцией может отработать дважды — побочные эффекты дублируются |
| Составлять два вызова конкурентной коллекции и ждать атомарности пары | Атомарна каждая операция, но не последовательность — ContainsKey + TryAdd остаётся гонкой |
Хранить контекст запроса в [ThreadStatic] | Значение не переживает await и утекает в следующую задачу на переиспользованном потоке пула — нужен AsyncLocal<T> |
Гнать I/O через Parallel.ForEach | Каждая операция занимает блокированный поток пула — прямая дорога к голоданию; нужен await Task.WhenAll |
Значение для собеседований
Многопоточность — традиционный водораздел между middle и senior на C#-собеседовании. Спрашивают не API, а модель: что именно делает lock помимо взаимного исключения, почему volatile не спасает счётчик, откуда берётся взаимоблокировка на .Result. Кандидат, который на вопрос про count++ сразу произносит «это read-modify-write, три шага», отвечает на порядок убедительнее того, кто говорит «нужен lock, потому что так принято».
Что обычно проверяют:
- Что такое состояние гонки и почему
count++теряет инкременты. - Во что компилятор разворачивает
lock, реентерабелен ли он и на каком объекте блокироваться. - Чем
Interlockedдешевлеlockи где заканчивается его применимость. - Что
volatileгарантирует (упорядочивание) и что нет (атомарность). - Четыре условия взаимоблокировки и какое из них ломает единый порядок захвата.
- Почему
SemaphoreSlim— единственный способ «удержать блокировку» черезawait. - Что такое голодание пула потоков и как в него попадает блокирующий вызов внутри
async-метода. - Почему у
ConcurrentDictionary.GetOrAddфабрика может отработать дважды.
Типичный неверный ответ: «Пометил поле volatile — и счётчик потокобезопасен». Это открывает главный разговор темы: volatile управляет видимостью и порядком, а не атомарностью; инкремент остаётся read-modify-write, и защитить его может только Interlocked.Increment или lock.