Многопоточность
Как .NET выполняет код параллельно: потоки и пул потоков, защита общих данных блокировками и атомарными операциями, конкурентные коллекции и очереди между потоками. На собеседовании здесь проверяют, видит ли кандидат гонки и дедлоки и умеет ли найти их в проде.
Перед этой темой: Типы и память.
Чем Thread отличается от Task и что такое пул потоков?
Заголовок раздела «Чем Thread отличается от Task и что такое пул потоков?»Что это
Thread— поток ОС. Его создают черезnew Thread(...), запускаютStart()и ждутJoin().- Пул потоков — набор фоновых потоков, которые CLR создаёт по мере нужды и переиспользует.
Task— единица работы с результатом.Task.Run(...)ставит делегат в очередь пула и возвращаетTaskилиTask<T>.
Зачем
Создавать поток дорого: каждый резервирует свой стек — по умолчанию 1,5 МБ на Windows и 8 МБ на Linux. Пул снимает эту цену: после задачи поток возвращается в пул.
Как пользоваться
- Запустить работу:
var task = Task.Run(() => Compute());. - Дождаться результата:
int result = await task;.
Частая ошибка
Считать, что Task — это поток. Задача может вообще не занимать поток, пока ждёт ответа от сети или диска.
Как устроено
Taskхранит состояние, результат (Task<T>) и исключение. Приawaitисключение пробрасывается как есть, при.Wait()и.Result— завёрнутым вAggregateException.- Потоки пула — фоновые: процесс не ждёт их при завершении.
new Threadпо умолчанию основной (IsBackground = false) и держит процесс живым, пока не закончит работу.TaskCreationOptions.LongRunning— подсказка планировщику выделить задаче отдельный поток вне пула.
Когда что выбирать
| Задача | Что взять |
|---|---|
| Короткая CPU-работа в фоне | Task.Run |
| Ожидание сети или диска | асинхронный API и await, без Task.Run |
| Долгая блокирующая работа | LongRunning или свой Thread |
| STA (COM, UI), особый приоритет | свой Thread |
Ловушки
Task.Run(() => File.ReadAllText(path))занимает поток пула ожиданием диска. Лучшеawait File.ReadAllTextAsync(path).Thread.Abortв .NET 5+ бросаетPlatformNotSupportedException. Поток останавливают кооперативно, черезCancellationToken.
Правило
Task.Run — для CPU-работы, await — для I/O, Thread — только когда нужен контроль над самим потоком.
Под капотом
- Каждая задача для пула — это work item, элемент его очереди.
- Work item, поставленный из кода на потоке пула, попадает в локальную очередь этого потока. Её читают в порядке LIFO — ради кеш-локальности.
- Остальная работа идёт в глобальную FIFO-очередь.
- Свободный поток ищет работу по порядку:
- в своей локальной очереди;
- в глобальной;
- с хвоста чужой локальной очереди — это и есть work stealing.
- До минимума потоков (
ThreadPool.GetMinThreads, по умолчанию — число процессоров) пул создаёт их сразу. Выше решает алгоритм, который меняет число потоков и следит за пропускной способностью.
Цена
- Каждый лишний поток — это память под стек, переключения контекста и лишняя работа GC при обходе стеков.
- Заблокированный поток пула выпадает из работы, а замена ему сверх минимума появляется не сразу.
Компромиссы
LongRunningс async-делегатом бессмыслен: выделенный поток живёт только до первогоawait, дальше продолжение уходит в пул.- С .NET 8 на Windows можно переключиться на пул Windows (
UseWindowsThreadPool); у Native AOT на Windows он включён по умолчанию. Выигрыш зависит от нагрузки и проверяется замерами.
Как проверить в проде
dotnet-counters monitor показывает число потоков пула, длину его очереди и число завершённых work items. Потоков больше примерно трёх на ядро — признак того, что код блокирует потоки пула.
Итог: пул рассчитан на короткие неблокирующие work items, и всё, что держит его потоки, бьёт по пропускной способности.
// Свой поток: создаём и ждём вручную, по умолчанию он основной (foreground)var thread = new Thread(() => Console.WriteLine("running on a dedicated thread"));thread.Start();thread.Join();
// Задача на пуле потоков: есть результатTask<int> task = Task.Run(() => 6 * 7);Console.WriteLine(await task); // 42
// Потоки пула — фоновыеawait Task.Run(() => Console.WriteLine(Thread.CurrentThread.IsBackground)); // True
// Долгая блокирующая работа — отдельный поток вне пулаawait Task.Factory.StartNew(() => Thread.Sleep(1000), TaskCreationOptions.LongRunning);- Что будет с процессом, если
Mainзавершился, аnew Threadещё работает? — Процесс дождётся потока: по умолчанию он основной (foreground). - Чем
Task.Runотличается отTask.Factory.StartNew? — УTask.Runбезопасные умолчания (DenyChildAttach, планировщик по умолчанию), и он разворачивает async-лямбды. - Занимает ли поток задача, которая ждёт ответа от БД? — Нет: асинхронный I/O не держит поток, поток нужен только продолжению.
- Сколько потоков в пуле? — Минимум по умолчанию равен числу процессоров, дальше пул подбирает число сам.
- Зачем
TaskCreationOptions.LongRunning? — Чтобы долгая блокирующая работа получила отдельный поток и не занимала пул.
Что такое race condition и deadlock и как их избежать?
Заголовок раздела «Что такое race condition и deadlock и как их избежать?»Что это
- Race condition — результат зависит от того, какой поток успел первым.
- Deadlock — поток 1 держит ресурс A и ждёт B, поток 2 держит B и ждёт A. Оба стоят вечно.
Зачем
Это две главные ошибки многопоточного кода. Обе зависят от тайминга и плохо ловятся тестами.
Как избежать
- Не делить изменяемые данные между потоками, если можно.
- Общие данные защищать
lock— он пускает в блок только один поток. - Простые счётчики менять через
Interlocked.Increment— одной неделимой операцией. - Брать блокировки всегда в одном порядке и держать их как можно меньше.
Частая ошибка
Считать count++ одной операцией. Это три шага — прочитать, прибавить, записать, — и у двух потоков одно увеличение может потеряться.
Как устроено
- Атомарная операция выполняется целиком: другой поток не увидит промежуточного состояния.
- Чтение и запись выровненного
intили ссылки атомарны. Составные операции — нет:x++, «проверить, потом добавить». - Дедлок возникает, когда каждый поток держит блокировку и ждёт ту, что держит другой.
Когда что выбирать
| Ситуация | Инструмент |
|---|---|
| Одна переменная: счётчик, флаг | Interlocked |
| Инвариант из нескольких полей | lock |
| Общий словарь или очередь | конкурентные коллекции |
| Данные можно не делить | изоляция по потокам, неизменяемые объекты |
Ловушки
if (!dict.ContainsKey(k)) dict[k] = v;— гонка даже наConcurrentDictionary: между проверкой и записью вклинится другой поток. Нужен один вызовTryAdd.- Вызов событий, колбэков или виртуальных методов внутри
lock: чужой код может взять другую блокировку и замкнуть цикл. - Синхронное ожидание асинхронного кода (
.Result) под однопоточнымSynchronizationContext— дедлок без единогоlock.
Правило
Все берут блокировки в одном порядке. Где можно отступить — Monitor.TryEnter или Lock.TryEnter с таймаутом.
Под капотом
- Data race — одновременный доступ к одной ячейке памяти без синхронизации, где хотя бы один доступ — запись.
- JIT может прочитать поле один раз и держать значение в регистре. Цикл
while (!_stop)безvolatileможет так и не увидеть_stop = true. - Дедлок требует четырёх условий Коффмана:
- взаимное исключение;
- удержание ресурса с ожиданием другого;
- отсутствие вытеснения;
- циклическое ожидание.
Цена
Дедлок — это зависание без загрузки CPU, которое часто проявляется только под нагрузкой. Процесс жив, но запросы, которым нужна блокировка, не завершаются.
Компромиссы
- Порядок блокировок (иерархия уровней) разрывает цикл, но требует дисциплины во всей кодовой базе.
TryEnterс таймаутом убирает бесконечное ожидание, но добавляет ветку отказа, которую надо продумать.- Livelock: потоки активны, но без прогресса — например, бесконечно повторяют
CompareExchange. Лечится паузами между попытками (SpinWait). - Лучшая синхронизация — её отсутствие: неизменяемые снимки, передача сообщений через очереди, разбиение данных по потокам.
Как проверить в проде
- Снять дамп:
dotnet-dump collect -p PID. - В
dotnet-dump analyzeвыполнитьclrstack -allилиparallelstacksи найти потоки, стоящие вMonitor.Enter. - Команда
syncblkпокажет, какой поток владеетMonitorи сколько потоков его ждут. - Для async-цепочек —
dumpasync.
Итог: дедлок — это цикл ожидания; его предотвращают порядком блокировок и находят по дампу.
int unsafeCount = 0;int safeCount = 0;
Parallel.For(0, 100_000, _ =>{ unsafeCount++; // гонка: чтение, сложение и запись не атомарны Interlocked.Increment(ref safeCount); // атомарно});
Console.WriteLine(unsafeCount); // обычно меньше 100000Console.WriteLine(safeCount); // 100000
// Дедлок: два потока берут блокировки a и b в разном порядкеvar a = new object();var b = new object();var t1 = Task.Run(() => { lock (a) { Thread.Sleep(50); lock (b) { } } });var t2 = Task.Run(() => { lock (b) { Thread.Sleep(50); lock (a) { } } });// await Task.WhenAll(t1, t2) не завершится никогда.// Исправление: оба потока берут сначала a, потом b.Первое число меняется от запуска к запуску — это и есть гонка.
- Атомарна ли запись
long? — На 64-битной платформе — да, если значение выровнено; на 32-битной — нет, нужныInterlocked.ReadилиVolatile. - Почему
ContainsKeyи потом запись — ошибка даже дляConcurrentDictionary? — Между двумя вызовами вклинится другой поток; нужна одна операцияTryAddилиGetOrAdd. - Как найти дедлок в проде? — Снять дамп и посмотреть
clrstack -allиsyncblkвdotnet-dump analyzeили Parallel Stacks в Visual Studio. - Что такое livelock? — Потоки не заблокированы, но из-за постоянных повторов не делают полезной работы.
- Может ли поток устроить дедлок сам с собой? — С
lockнет, он реентерабелен; сSemaphoreSlim(1, 1)да: повторныйWaitждёт сам себя.
Как работает lock и что даёт тип System.Threading.Lock (.NET 9)?
Заголовок раздела «Как работает lock и что даёт тип System.Threading.Lock (.NET 9)?»Что это
lock гарантирует, что код внутри блока в каждый момент выполняет только один поток. Остальные потоки, дошедшие до lock с тем же объектом, ждут.
Зачем
Чтобы несколько потоков не меняли общие данные одновременно. Например, чтобы два списания с одного счёта не увели баланс в минус.
Как пользоваться
- Завести приватное поле:
private readonly Lock _sync = new();(до .NET 9 —new object()). - Обернуть работу с общими данными:
lock (_sync) { _balance -= amount; }. - Держать внутри только эту работу — без I/O и долгих вычислений.
Частая ошибка
Блокироваться на this, typeof(...) или строке. Их может заблокировать и чужой код — получится лишняя конкуренция или дедлок.
Как устроено
- Для ссылочного типа
lockкомпилируется вMonitor.Enter(obj, ref lockTaken)вtryиMonitor.Exit(obj)вfinally. - Для
Lock— вusing (x.EnterScope()).Lock.Scope— этоref structс методомDispose. - Поток, уже владеющий блокировкой, может войти повторно. Выйти нужно столько же раз.
- Освободить блокировку может только поток-владелец, иначе
SynchronizationLockException.
Когда что выбирать
| Ситуация | Что взять |
|---|---|
| .NET 9+, синхронный код | поле Lock и lock |
| .NET 8 и раньше | поле object и lock |
Нужен await внутри |
семафор на один слот: SemaphoreSlim(1, 1) и WaitAsync |
| Нужен таймаут | Lock.TryEnter или Monitor.TryEnter |
Ловушки
awaitвнутриlock— ошибка CS1996: продолжение может выполниться на другом потоке, а выйти должен владелец.object o = _sync; lock (o)— это ужеMonitor, а неLock(предупреждение CS9216).- Вызов событий, колбэков и виртуальных методов внутри
lock: чужой код может взять другую блокировку и устроить дедлок. lockпо значимому типу не компилируется: каждая упаковка давала бы новый объект.
Правило
Одна блокировка — на один ресурс, внутри — только работа с его данными.
Под капотом
- Любой объект может служить монитором. Пока конкуренции нет, состояние блокировки лежит в заголовке объекта (thin lock).
- При конкуренции или ожидании рантайм «раздувает» блокировку до sync block — записи во внутренней таблице.
Lockхранит состояние в собственном поле и не трогает заголовок объекта. Быстрый путь без конкуренции — атомарная операция над этим полем.- Вход в блокировку не даёт последующим чтениям выполниться раньше него, а выход не даёт предыдущим записям задержаться после него. Поэтому полям, доступным только под
lock,volatileне нужен.
Цена
- Неконкурентный
lockдешёвый. Дорогой — конкурентный: потоки крутятся в спине, потом уходят в ожидание, и растёт задержка. - Длинная очередь на одну блокировку (lock convoy) сводит параллельный код к последовательному.
Компромиссы
Lockубирает лишнийobjectи показывает намерение типом, но у него нетWaitиPulse. Код наMonitor.Waitпри миграции придётся переписать на другой примитив.- Против конкуренции: сузить секцию, разбить данные на шарды с отдельными блокировками или перейти на структуры без блокировок.
Как проверить в проде
- В
dotnet-counters—dotnet.monitor.lock_contentions(до .NET 9 — Monitor Lock Contention Count): сколько раз поток ждал занятыйMonitor. - В дампе команда
syncblkпоказывает владельцевMonitorи число ждущих потоков. - С .NET 9
dotnet-traceпишет событие WaitHandleWait со стеком, в том числе для ожиданий вlock.
Итог: Lock — выбор по умолчанию в .NET 9+, а узкое место ищут по счётчику конкуренции и стекам ожиданий.
public sealed class Account{ private readonly Lock _sync = new(); // C# 13 / .NET 9+, раньше — new object() private decimal _balance;
public bool TryDebit(decimal amount) { lock (_sync) // компилятор генерирует using (_sync.EnterScope()) { if (_balance < amount) return false; _balance -= amount; return true; } }
public void Credit(decimal amount) { lock (_sync) { _balance += amount; } }
// object o = _sync; lock (o) { } — предупреждение CS9216: это уже Monitor // lock (_sync) { await SaveAsync(); } — ошибка CS1996: await внутри lock запрещён}- Почему нельзя
lock (this)? — Внешний код может заблокироваться на том же объекте: лишняя конкуренция или дедлок. - Что будет, если внутри
lockвылетит исключение? — Блокировка снимется вfinally, но данные могут остаться в несогласованном состоянии. - Как сделать взаимное исключение в async-коде? —
SemaphoreSlim(1, 1):await WaitAsync(), аRelease()— вfinally. - Реентерабелен ли
lock? — Да, и сMonitor, и сLock: тот же поток может войти повторно. - Что изменится, если поле с
new Lock()объявить какobject? —lockперейдёт наMonitor, а компилятор выдаст CS9216.
Какие примитивы синхронизации есть в .NET и чем они отличаются?
Заголовок раздела «Какие примитивы синхронизации есть в .NET и чем они отличаются?»Что это
Примитив синхронизации — тип, который заставляет потоки ждать друг друга по определённому правилу.
Зачем
lock решает одну задачу: один поток в секции. Для ограничения параллелизма, разделения чтения и записи и сигналов нужны другие инструменты.
Как пользоваться
| Примитив | Что делает |
|---|---|
Mutex |
как lock, но может работать между процессами |
SemaphoreSlim |
пускает одновременно не больше N потоков |
ReaderWriterLockSlim |
читать могут многие сразу, писать — один |
ManualResetEventSlim |
потоки ждут в Wait(), пока кто-то не вызовет Set() |
Barrier |
потоки ждут друг друга в конце каждой фазы |
Частая ошибка
Не освободить примитив при исключении. Release(), ExitReadLock() и ReleaseMutex() всегда вызывают в finally.
Как устроено
Mutex,Semaphore,AutoResetEventиManualResetEventнаследуютWaitHandleи оборачивают объект ядра ОС.- Именованный
Mutexвиден другим процессам на всех ОС, именованныеSemaphoreиEventWaitHandle— только на Windows. - Типы с суффиксом
Slimобходятся без объектов ядра, пока это возможно, и работают только внутри процесса. - У семафоров нет владельца:
Waitв одном потоке иReleaseв другом — нормальный сценарий.
Когда что выбирать
| Задача | Примитив |
|---|---|
| Один экземпляр приложения | именованный Mutex |
Не больше N операций, есть await |
SemaphoreSlim и WaitAsync |
| Читают часто и долго, пишут редко | ReaderWriterLockSlim |
| Один поток открывает «ворота» многим | ManualResetEventSlim |
| Дождаться N сигналов | CountdownEvent |
| Фазы вычислений в N потоках | Barrier |
Ловушки
- Поток завершился, не освободив
Mutex, — следующий владелец получитAbandonedMutexException. - Лишний
Releaseу семафора проходит молча, аSemaphoreFullExceptionполучит позже другой поток со своим законнымRelease. ReaderWriterLockSlimпо умолчанию без рекурсии. Из чтения в запись переходят только через upgradeable-режим, а в нём одновременно может быть один поток.
Правило
С await из этого списка работает только SemaphoreSlim; остальные — для синхронного кода.
Под капотом
MonitorиSlim-типы сначала крутятся в цикле (SpinWait), рассчитывая, что владелец скоро освободит ресурс. Только потом поток уходит в ожидание ядра.- Kernel-объекты делают системный вызов на каждую операцию, даже без конкуренции.
SpinLock— структура, которая только крутится. КопияSpinLock— независимая блокировка, поэтому его нельзя передавать по значению.
Цена
- Спин жжёт CPU и выгоден только для очень коротких ожиданий.
- У
ReaderWriterLockSlimвход и выход дороже, чем уlock. На коротких секциях и частых записях обычныйlockбывает быстрее.
Компромиссы
- Для данных, которые почти только читают, вместо RW-блокировки — неизменяемый снимок и атомарная замена ссылки. Читатели тогда не синхронизируются вовсе.
- Асинхронные RW-блокировку и события берут из Microsoft.VisualStudio.Threading (
AsyncReaderWriterLock,AsyncManualResetEvent) или строят наTaskCompletionSource. SemaphoreSlimбез владельца не реентерабелен: повторныйWaitAsyncв той же цепочке вызовов ждёт сам себя.- Брошенный
Mutex— сигнал, что защищаемое состояние может быть повреждено.
Как проверить в проде
- С .NET 9
dotnet-traceпишет событие WaitHandleWait, когда поток блокируется вlock,ManualResetEventSlim.WaitилиSemaphoreSlim.Wait. Стеки этих событий показывают, кто и где ждёт. - В дампе
parallelstacksгруппирует потоки, стоящие в одном и том же ожидании.
Итог: самый дешёвый примитив — тот, без которого удалось обойтись; дальше выбирают по владельцу, межпроцессности и профилю нагрузки.
// SemaphoreSlim: не больше 3 одновременных операций, работает с awaitusing var gate = new SemaphoreSlim(3);await Task.WhenAll(Enumerable.Range(1, 10).Select(async i =>{ await gate.WaitAsync(); try { await Task.Delay(100); } finally { gate.Release(); } // владельца нет: освободить может любой поток}));
// Barrier: три потока синхронно проходят фазы вычисленийusing var barrier = new Barrier(3, b => Console.WriteLine($"phase {b.CurrentPhaseNumber} done"));var threads = Enumerable.Range(0, 3).Select(_ => new Thread(() =>{ for (int phase = 0; phase < 2; phase++) { Thread.Sleep(Random.Shared.Next(50)); // считаем свою часть barrier.SignalAndWait(); // ждём остальных }})).ToList();threads.ForEach(t => t.Start());threads.ForEach(t => t.Join());// phase 0 done// phase 1 done- Чем
Mutexотличается отlock? — Это объект ядра: медленнее, зато работает между процессами по имени; брошенный даётAbandonedMutexException. - Почему
SemaphoreSlim(1, 1)— не полная заменаlock? — У него нет владельца и реентерабельности: повторный вход в той же цепочке вызовов зависнет. - Когда
ReaderWriterLockSlimхуже обычногоlock? — При коротких секциях и частых записях: вход и выход у него дороже. - Чем
CountdownEventотличается отBarrier? — ВCountdownEventждут N сигналов, вBarrierN участников ждут друг друга, фаза за фазой. - Как асинхронно дождаться сигнала? — Через
TaskCompletionSourceвместоManualResetEvent.
Как устроены конкурентные коллекции и когда они нужны?
Заголовок раздела «Как устроены конкурентные коллекции и когда они нужны?»Что это
Коллекции из System.Collections.Concurrent, которые безопасно менять из нескольких потоков одновременно.
Зачем
Обычный Dictionary<TKey,TValue> при одновременной записи может испортить свою внутреннюю структуру. Конкурентные коллекции синхронизируют себя сами.
Как пользоваться
| Коллекция | Порядок | Основные методы |
|---|---|---|
ConcurrentDictionary |
по ключу | TryAdd, GetOrAdd, AddOrUpdate, TryRemove |
ConcurrentQueue |
FIFO | Enqueue, TryDequeue |
ConcurrentStack |
LIFO | Push, TryPop |
ConcurrentBag |
без порядка | Add, TryTake |
Методы с Try возвращают false, если не получилось: другой поток мог успеть первым.
Частая ошибка
if (!dict.ContainsKey(key)) dict[key] = value; — два вызова, между которыми вклинится другой поток. Правильно — один вызов dict.TryAdd(key, value).
Как устроено
ConcurrentDictionary— таблица бакетов и массив блокировок, каждая из которых защищает часть бакетов. Запись берёт одну блокировку, чтение идёт без блокировок.- Перечисление безопасно, но это не снимок: обход может увидеть изменения, сделанные после его начала.
ConcurrentQueueиConcurrentStackпостроены наInterlocked, без блокировок.ConcurrentBagхранит элементы в списках, привязанных к потокам.TryTakeсначала смотрит свой список, потом забирает из чужих.
Когда что выбирать
| Задача | Коллекция |
|---|---|
| Общий кеш или индекс по ключу | ConcurrentDictionary |
| Работа в порядке поступления | ConcurrentQueue |
| Поток сам кладёт и сам забирает (пул объектов) | ConcurrentBag |
Ловушки
GetOrAddс фабрикой. Делегат вызывается вне блокировки. Два потока могут одновременно не найти ключ и оба вызвать фабрику, а сохранится одно значение.AddOrUpdateпри конфликте может вызвать делегат обновления повторно. Поэтому он должен быть чистой функцией.ConcurrentQueueне умеет ждать элемент. ЦиклTryDequeueбез паузы жжёт CPU.
Правило
Составную операцию делайте одним атомарным методом, а фабрику с побочными эффектами — через Lazy<T>: dict.GetOrAdd(key, k => new Lazy<T>(() => Create(k))).Value. Lazy<T> по умолчанию потокобезопасен и вызовет Create один раз.
Под капотом
Count,Keys,Values,ToArray,Clearи рост таблицы уConcurrentDictionaryзахватывают все блокировки.- Число блокировок по умолчанию зависит от числа процессоров; его задаёт параметр конструктора
concurrencyLevel. ConcurrentQueueпостроена на сегментах — кольцевых буферах — и в установившемся режиме почти не аллоцирует.ConcurrentStackсоздаёт узел на каждыйPush,ConcurrentDictionary— узел на каждую запись.
Цена
- Частый
Countна горячем пути сериализует писателей. Нужен счётчик — ведите его отдельно черезInterlocked. ConcurrentBagкак очередь между разными производителями и потребителями проигрывает: потребители всё время забирают из чужих списков.
Компромиссы
- Справочники и конфигурацию, которые почти только читают, выгоднее держать в
FrozenDictionary(.NET 8+) илиImmutableDictionaryи атомарно заменять ссылку целиком. Чтения тогда идут вообще без синхронизации. - Две связанные коллекции не станут согласованными сами: такой инвариант требует внешнего
lock. - Одновременное чтение обычного
Dictionaryбез записей безопасно — конкурентная коллекция там не нужна.
Как проверить в проде
- В профиле CPU (dotnet-trace, PerfView) видно время во внутренних блокировках
ConcurrentDictionary, например вAcquireAllLocks. - В дампе
dotnet-dump analyzeпоказывает содержимое командамиdumpconcurrentdictionaryиdumpconcurrentqueue.
Итог: конкурентные коллекции хороши для частых записей из многих потоков; для редких записей и частых чтений дешевле неизменяемые снимки.
using System.Collections.Concurrent;
var cache = new ConcurrentDictionary<string, Lazy<Client>>();int created = 0;
Client Get(string key) => cache.GetOrAdd(key, k => new Lazy<Client>(() => { Interlocked.Increment(ref created); // внутри Lazy фабрика выполнится один раз return new Client(k); })).Value;
Parallel.For(0, 1000, _ => Get("billing"));Console.WriteLine(created); // 1
var counts = new ConcurrentDictionary<string, int>();Parallel.ForEach(new[] { "a", "b", "a" }, word => counts.AddOrUpdate(word, 1, (_, old) => old + 1)); // чистая функция: может вызваться повторноConsole.WriteLine(counts["a"]); // 2
record Client(string Name);Без Lazy при одновременных вызовах фабрика могла бы отработать несколько раз, хотя в словаре всё равно осталось бы одно значение.
- Почему
GetOrAddможет вызвать фабрику дважды? — Делегат выполняется вне блокировки, чтобы не держать её на чужом коде; сохраняется только одно значение. - Дорого ли вызывать
CountуConcurrentDictionary? — Да: берутся все внутренние блокировки; на горячем пути лучше отдельный счётчик. - Когда
ConcurrentBag— плохой выбор? — Когда производители и потребители — разные потоки: потребители постоянно забирают из чужих списков. - Даёт ли перечисление
ConcurrentDictionaryснимок? — Нет: обход безопасен, но видит изменения; снимок даётToArray(). - Нужны ли конкурентные коллекции, если данные только читаются? — Нет: одновременное чтение обычных коллекций без записей безопасно.
Что такое Interlocked, volatile и модель памяти .NET?
Заголовок раздела «Что такое Interlocked, volatile и модель памяти .NET?»Что это
Interlocked— статический класс с атомарными операциями:Increment,Decrement,Add,Exchange,CompareExchange.volatile— модификатор поля, которое меняют другие потоки.
Зачем
count++— это чтение, сложение и запись. Другой поток может вклиниться между ними, и увеличение потеряется.- Без
volatileJIT может прочитать поле один раз и держать значение в регистре. Циклwhile (!_stop)тогда не увидит_stop = true.
Как пользоваться
- Счётчик:
Interlocked.Increment(ref _count);. - Флаг остановки:
private volatile bool _stop;.
Частая ошибка
Думать, что volatile делает x++ безопасным. Он не объединяет чтение и запись в одну операцию. К тому же его нельзя повесить на long и double.
Как устроено
- Все методы
Interlockedатомарны и работают как полный барьер: обращения к памяти не переносятся через них ни в одну сторону. - Volatile-чтение (acquire) не даёт последующим обращениям выполниться раньше него.
- Volatile-запись (release) не даёт предыдущим обращениям задержаться после неё.
- Ключевое слово
volatileделает такими все обращения к полю. КлассVolatile(Volatile.Read,Volatile.Write) — отдельные обращения, в том числе кlong,doubleи элементам массивов.
Когда что выбирать
| Задача | Инструмент |
|---|---|
| Счётчик, сумма | Interlocked.Increment, Interlocked.Add |
| Флаг «остановиться» | volatile или Volatile.Read и Volatile.Write |
| Своя атомарная операция (максимум) | CAS-цикл на CompareExchange |
| Инвариант из нескольких полей | lock |
Ловушки
- Volatile не даёт «свежести»: чтение не обязано вернуть последнее значение, записанное другим процессором.
- Нет единого порядка volatile-записей, одинакового для всех потоков.
longиdoubleна 32-битной платформе читаются неатомарно. ИспользуйтеInterlocked.ReadилиVolatile.Read.
Правило
CAS-цикл строится так:
- Прочитать текущее значение.
- Вычислить новое.
- Вызвать
CompareExchange; если значение успели поменять — вернуться к шагу 1.
Под капотом
Модель памяти .NET описана в спецификации Memory-model.md в репозитории dotnet/runtime и строже ECMA-335:
- обычные обращения переставляются, только если это не видно внутри потока;
- JIT не добавляет новых чтений, но может склеить соседние чтения одного поля — отсюда зависший
while (!_flag); - спекулятивные записи запрещены;
- запись ссылки на объект в общую память — release для его полей, поэтому опубликованный объект виден полностью сконструированным;
- чтения через ссылку не обгоняют чтение самой ссылки.
Цена
- На x64 volatile-чтение и запись — обычные инструкции: процессор (модель TSO) переставляет только запись с последующим чтением.
- На ARM64 модель слабее, и volatile-операциям нужны инструкции с барьерной семантикой.
Interlockedна общей кеш-линии дорог: линия переходит между ядрами, и горячий общий счётчик плохо масштабируется.
Компромиссы
- Схемы «записал свой флаг — проверил чужой» требуют
InterlockedилиInterlocked.MemoryBarrier: acquire/release такую перестановку не запрещает. - Горячий счётчик лучше шардировать: свой на поток или ядро, сумма — при чтении.
- False sharing: независимые горячие поля в одной кеш-линии мешают друг другу.
- Double-checked locking в .NET корректен, но
Lazy<T>иLazyInitializerчитаются проще.
Как проверить в проде
- Код, стабильный на x64, гоняют стресс-тестами на ARM64 (Graviton, Apple Silicon).
- Масштабируемость счётчиков меряют в BenchmarkDotNet с разным числом потоков.
Итог: корректный код опирается на гарантии модели памяти, а не на поведение x64.
public sealed class Stats{ private long _requests; // счётчик: только через Interlocked private int _maxLatencyMs; // максимум: CAS-цикл private volatile bool _stopped; // флаг: volatile достаточно
public void Record(int latencyMs) { Interlocked.Increment(ref _requests);
int current = Volatile.Read(ref _maxLatencyMs); while (latencyMs > current) { // записываем, только если с момента чтения никто не изменил значение int seen = Interlocked.CompareExchange(ref _maxLatencyMs, latencyMs, current); if (seen == current) break; current = seen; // нас опередили — пробуем снова } }
public long Requests => Interlocked.Read(ref _requests); // атомарно и на 32-битной платформе public void Stop() => _stopped = true; public bool IsStopped => _stopped;}- Почему
volatileне делаетx++безопасным? — Это три операции; volatile упорядочивает каждую, но не объединяет их в атомарную. - Нужен ли
volatileполю, к которому обращаются только подlock? — Нет: вход и выход из блокировки уже упорядочивают обращения. - Что такое ABA-проблема? — CAS видит прежнее значение, хотя между чтением и записью его меняли и вернули; для счётчиков и индексов она реальна.
- Почему код, работавший на x64, ломается на ARM64? — У ARM слабее модель памяти: видны перестановки, которых x64 не делает.
- Что такое false sharing? — Независимые горячие поля в одной кеш-линии: ядра постоянно отбирают её друг у друга.
Как построить producer-consumer: BlockingCollection или System.Threading.Channels?
Заголовок раздела «Как построить producer-consumer: BlockingCollection или System.Threading.Channels?»Что это
- Producer (производитель) создаёт работу.
- Consumer (потребитель) её обрабатывает.
- Между ними — потокобезопасная очередь.
Зачем
Чтобы разделить быстрый приём и медленную обработку. Например, принимать сообщения сразу, а записывать их в базу в своём темпе.
Как пользоваться
- Создать канал:
var channel = Channel.CreateBounded<int>(100);— не больше 100 элементов. - Производитель пишет
await channel.Writer.WriteAsync(item), а в конце вызываетchannel.Writer.Complete(). - Потребитель читает
await foreach (var item in channel.Reader.ReadAllAsync()).
Частая ошибка
Забыть Complete(). Потребитель тогда ждёт новых элементов вечно.
Как устроено
CreateUnbounded— запись всегда завершается сразу, память не ограничена.CreateBounded— при заполнении срабатывает режимFullModeизBoundedChannelOptions.- Каналы поддерживают несколько производителей и потребителей. Потребители с
ReadAllAsyncделят элементы между собой. BlockingCollection<T>— синхронная обёртка надConcurrentQueue<T>(по умолчанию):Add,Take,CompleteAdding,GetConsumingEnumerable.
Когда что выбирать
FullMode |
Что происходит при заполнении |
|---|---|
Wait (по умолчанию) |
WriteAsync ждёт места, TryWrite возвращает false |
DropOldest |
из очереди выбрасывается самый старый элемент |
DropNewest |
из очереди выбрасывается самый новый элемент |
DropWrite |
выбрасывается элемент, который пытаются записать |
Ловушки
- При нескольких производителях
Complete()вызывают, когда закончили все. ReadAsyncна завершённом канале бросаетChannelClosedException, аReadAllAsyncпросто заканчивает перечисление.- Ошибку нельзя терять:
Writer.TryComplete(ex)остановит запись и передаст исключение вReader.Completion.
Правило
Канал по умолчанию — ограниченный; неограниченный — только если источник ограничен сам.
Под капотом
- Async-методы каналов возвращают
ValueTask: если операция завершилась синхронно, аллокации нет. SingleReader = trueиSingleWriter = true— обещание каналу, которое позволяет ему выбрать более быструю реализацию.AllowSynchronousContinuations = trueпозволяет продолжению читателя выполниться прямо внутриWriteAsyncписателя.
Цена
- Ожидающий потребитель канала не занимает поток. Каждый ждущий
BlockingCollection.Takeдержит поток, и на пуле это быстро съедает свободные потоки. - Неограниченный канал при медленном потребителе превращает задержку в рост памяти, а затем в
OutOfMemoryException.
Компромиссы
Waitпробрасывает замедление назад по конвейеру — до источника, который перестаёт читать сокет или брокер.DropOldestподходит для телеметрии и «последнего значения». Для режимов со сбросом есть колбэкitemDropped.- Синхронные продолжения уменьшают переключения, но писатель неожиданно выполняет код читателя и ждёт его.
- Для графов с ветвлением и встроенным ограничением параллелизма есть TPL Dataflow; каналы — более низкоуровневый строительный блок.
Как проверить в проде
- Длина очереди — главный сигнал:
Reader.Count, еслиReader.CanCountравноtrue. - Растущее время
WriteAsyncозначает, что система упирается в потребителя. - Завершение
Reader.Completionс исключением в логах показывает, что конвейер остановился с ошибкой.
Итог: главное решение в producer-consumer — граница очереди и поведение при её достижении.
using System.Threading.Channels;
// Не больше 100 элементов; при заполнении производитель ждёт (backpressure)var channel = Channel.CreateBounded<int>( new BoundedChannelOptions(100) { FullMode = BoundedChannelFullMode.Wait });
var producer = Task.Run(async () =>{ try { for (int i = 0; i < 1_000; i++) await channel.Writer.WriteAsync(i); // ждёт, если очередь полна channel.Writer.Complete(); } catch (Exception ex) { channel.Writer.TryComplete(ex); } // ошибка дойдёт до потребителей});
// Три потребителя делят элементы между собойvar consumers = Enumerable.Range(0, 3).Select(_ => Task.Run(async () =>{ await foreach (int item in channel.Reader.ReadAllAsync()) await Task.Delay(1); // медленная обработка})).ToArray();
await Task.WhenAll(consumers.Append(producer));- Что будет с неограниченным каналом, если потребитель медленнее производителя? — Очередь растёт без предела: память, задержка и в итоге OOM.
- Чем
DropOldestотличается отDropWrite? — Первый выбрасывает самый старый элемент из очереди, второй — тот, который пытаются записать. - Как остановить конвейер при ошибке? —
Writer.TryComplete(ex): запись прекратится, аReader.Completionзавершится с этим исключением. - Когда
BlockingCollectionещё уместна? — В синхронном коде с выделенными потоками-потребителями, где блокировка потока не мешает. - Что даёт
SingleReader = true? — Обещание, что читатель один: канал может выбрать более быструю реализацию.
Что такое голодание пула потоков и как его диагностировать?
Заголовок раздела «Что такое голодание пула потоков и как его диагностировать?»Что это
Все потоки пула заняты ожиданием, а новые запросы стоят в очереди.
Как это выглядит
Сервис под нагрузкой отвечает за секунды вместо миллисекунд. Процессор при этом загружен слабо.
Почему возникает
- Запрос выполняется на потоке пула.
- Код вызывает
httpClient.GetStringAsync(url).Result— поток стоит и ждёт ответа. - Под нагрузкой так встают все потоки пула.
- Новые запросы ждут в очереди, пока пул медленно добавляет потоки.
Частая ошибка
Лечить симптом: поднять минимум потоков вместо того, чтобы убрать блокировки.
Как устроено
- До минимума (
ThreadPool.GetMinThreads, по умолчанию — число процессоров) пул создаёт потоки сразу. - Выше — с задержкой. В примере Microsoft число потоков быстро доходит до 2–3 на ядро, а дальше прибавляется по 1–2 в секунду.
- Пока потоков не хватает, растёт очередь и задержка каждого запроса.
Когда что выбирать
| Причина | Решение |
|---|---|
.Result, .Wait() на async-коде |
await по всей цепочке |
| Синхронный I/O | асинхронные API (ReadAsync, SaveChangesAsync) |
| Сторонний блокирующий API | отдельный поток или LongRunning с ограничением параллелизма |
| Пережить всплеск до исправления | временно SetMinThreads |
Ловушки
- Число потоков стабилизировалось высоко — больше примерно трёх на ядро. Значит, код блокирует потоки, а пул компенсирует; при всплеске или рестарте голодание вернётся.
Thread.Sleepи долгийlockв коде на пуле тоже съедают потоки.
Правило
Признаки голодания в dotnet-counters: число потоков пула медленно растёт, очередь длинная, завершённой работы мало, CPU заметно ниже 100%.
Под капотом
- Hill climbing периодически меняет число активных потоков и смотрит, выросла ли пропускная способность — число завершённых work items в секунду.
- Отдельный механизм добавляет поток, когда очередь есть, а прогресса нет. Оба видны в событиях
ThreadPoolWorkerThreadAdjustmentс причинами вроде Climbing move и Starvation. - С .NET 6 пул сразу добавляет потоки, когда поток пула блокируется в ожидании задачи. Без задержки — до
ThreadsToAddWithoutDelay_ProcCountFactor, умноженного на число процессоров, дальше с растущей задержкой. - Этот механизм работает только для ожидания задач (
Task.Wait,.Result), а не дляlock, синхронного I/O илиThread.Sleep.
Цена
- Компенсация — это сотни потоков: память под стеки, переключения контекста, дольше обход стеков при сборке мусора.
- Большой
SetMinThreadsплатит те же издержки постоянно, а не только во время всплеска.
Компромиссы
Настройки System.Threading.ThreadPool.Blocking.* и MinThreads покупают время до исправления кода. Настоящее решение — асинхронность по всему стеку и выделенные потоки для неизбежной блокировки.
Как проверить в проде
dotnet-counters monitor -n MyApp. В .NET 9+ это метрикиdotnet.thread_pool.thread.count,dotnet.thread_pool.queue.lengthиdotnet.thread_pool.work_item.count; раньше — ThreadPool Thread Count, ThreadPool Queue Length и ThreadPool Completed Work Item Count.- Постоянная проблема:
dotnet-stack report -n MyAppили дамп сparallelstacks. Ищите потоки сWorkerThreadвнизу стека иTask.WaitилиGetResultнаверху. - Плавающая проблема:
dotnet-trace collect -n MyApp --clrevents waithandle --clreventlevel verbose(.NET 9+), затем стеки WaitHandleWait в PerfView.
Итог: голодание видно по счётчикам, а причину показывают стеки заблокированных потоков пула.
var builder = WebApplication.CreateBuilder(args);builder.Services.AddHttpClient();var app = builder.Build();
// Плохо: sync-over-async — поток пула стоит всё время запросаapp.MapGet("/bad", (IHttpClientFactory f) => f.CreateClient().GetStringAsync("https://example.com").Result.Length);
// Хорошо: на время ожидания поток возвращается в пулapp.MapGet("/good", async (IHttpClientFactory f) => (await f.CreateClient().GetStringAsync("https://example.com")).Length);
app.Run();Под нагрузкой /bad держит по потоку на каждый запрос, и число потоков пула растёт; /good обходится несколькими потоками.
- Почему CPU загружен слабо, а запросы тормозят? — Потоки не считают, а ждут; работа стоит в очереди пула.
- Поможет ли
ThreadPool.SetMinThreads(500, 500)? — Снимет симптом при всплесках, но добавит памяти и переключений; блокирующий код останется. - Что изменилось в .NET 6? — Пул быстрее добавляет потоки, когда поток блокируется на
Task.Waitили.Result; голодание короче, но не исчезает. - Как отличить голодание от дедлока? — При голодании число потоков растёт и работа идёт медленно; при дедлоке потоки стоят на одних блокировках, и прогресса нет вовсе.
- Чем помогает
TaskCreationOptions.LongRunning? — Долгая блокирующая работа получает отдельный поток и не занимает пул.