Перейти к содержимому

Многопоточность

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

Перед этой темой: Типы и память.

Junior+

Чем Thread отличается от Task и что такое пул потоков?

Заголовок раздела «Чем Thread отличается от Task и что такое пул потоков?»
Ответ

Что это

  • Thread — поток ОС. Его создают через new Thread(...), запускают Start() и ждут Join().
  • Пул потоков — набор фоновых потоков, которые CLR создаёт по мере нужды и переиспользует.
  • Task — единица работы с результатом. Task.Run(...) ставит делегат в очередь пула и возвращает Task или Task<T>.

Зачем

Создавать поток дорого: каждый резервирует свой стек — по умолчанию 1,5 МБ на Windows и 8 МБ на Linux. Пул снимает эту цену: после задачи поток возвращается в пул.

Как пользоваться

  1. Запустить работу: var task = Task.Run(() => Compute());.
  2. Дождаться результата: 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-очередь.
  • Свободный поток ищет работу по порядку:
    1. в своей локальной очереди;
    2. в глобальной;
    3. с хвоста чужой локальной очереди — это и есть 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);
Junior+

Что такое 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.
  • Дедлок требует четырёх условий Коффмана:
    1. взаимное исключение;
    2. удержание ресурса с ожиданием другого;
    3. отсутствие вытеснения;
    4. циклическое ожидание.

Цена

Дедлок — это зависание без загрузки CPU, которое часто проявляется только под нагрузкой. Процесс жив, но запросы, которым нужна блокировка, не завершаются.

Компромиссы

  • Порядок блокировок (иерархия уровней) разрывает цикл, но требует дисциплины во всей кодовой базе.
  • TryEnter с таймаутом убирает бесконечное ожидание, но добавляет ветку отказа, которую надо продумать.
  • Livelock: потоки активны, но без прогресса — например, бесконечно повторяют CompareExchange. Лечится паузами между попытками (SpinWait).
  • Лучшая синхронизация — её отсутствие: неизменяемые снимки, передача сообщений через очереди, разбиение данных по потокам.

Как проверить в проде

  1. Снять дамп: dotnet-dump collect -p PID.
  2. В dotnet-dump analyze выполнить clrstack -all или parallelstacks и найти потоки, стоящие в Monitor.Enter.
  3. Команда syncblk покажет, какой поток владеет Monitor и сколько потоков его ждут.
  4. Для async-цепочек — dumpasync.

Итог: дедлок — это цикл ожидания; его предотвращают порядком блокировок и находят по дампу.

Пример
int unsafeCount = 0;
int safeCount = 0;
Parallel.For(0, 100_000, _ =>
{
unsafeCount++; // гонка: чтение, сложение и запись не атомарны
Interlocked.Increment(ref safeCount); // атомарно
});
Console.WriteLine(unsafeCount); // обычно меньше 100000
Console.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.

Первое число меняется от запуска к запуску — это и есть гонка.

Middle+

Как работает lock и что даёт тип System.Threading.Lock (.NET 9)?

Заголовок раздела «Как работает lock и что даёт тип System.Threading.Lock (.NET 9)?»
Ответ

Что это

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

Зачем

Чтобы несколько потоков не меняли общие данные одновременно. Например, чтобы два списания с одного счёта не увели баланс в минус.

Как пользоваться

  1. Завести приватное поле: private readonly Lock _sync = new(); (до .NET 9 — new object()).
  2. Обернуть работу с общими данными: lock (_sync) { _balance -= amount; }.
  3. Держать внутри только эту работу — без 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 запрещён
}
Middle+

Какие примитивы синхронизации есть в .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 одновременных операций, работает с await
using 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
Middle+

Как устроены конкурентные коллекции и когда они нужны?

Заголовок раздела «Как устроены конкурентные коллекции и когда они нужны?»
Ответ

Что это

Коллекции из 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 при одновременных вызовах фабрика могла бы отработать несколько раз, хотя в словаре всё равно осталось бы одно значение.

Senior+
Ответ

Что это

  • Interlocked — статический класс с атомарными операциями: Increment, Decrement, Add, Exchange, CompareExchange.
  • volatile — модификатор поля, которое меняют другие потоки.

Зачем

  • count++ — это чтение, сложение и запись. Другой поток может вклиниться между ними, и увеличение потеряется.
  • Без volatile JIT может прочитать поле один раз и держать значение в регистре. Цикл 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-цикл строится так:

  1. Прочитать текущее значение.
  2. Вычислить новое.
  3. Вызвать 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;
}
Senior+

Как построить producer-consumer: BlockingCollection или System.Threading.Channels?

Заголовок раздела «Как построить producer-consumer: BlockingCollection или System.Threading.Channels?»
Ответ

Что это

  • Producer (производитель) создаёт работу.
  • Consumer (потребитель) её обрабатывает.
  • Между ними — потокобезопасная очередь.

Зачем

Чтобы разделить быстрый приём и медленную обработку. Например, принимать сообщения сразу, а записывать их в базу в своём темпе.

Как пользоваться

  1. Создать канал: var channel = Channel.CreateBounded<int>(100); — не больше 100 элементов.
  2. Производитель пишет await channel.Writer.WriteAsync(item), а в конце вызывает channel.Writer.Complete().
  3. Потребитель читает 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));
Senior+

Что такое голодание пула потоков и как его диагностировать?

Заголовок раздела «Что такое голодание пула потоков и как его диагностировать?»
Ответ

Что это

Все потоки пула заняты ожиданием, а новые запросы стоят в очереди.

Как это выглядит

Сервис под нагрузкой отвечает за секунды вместо миллисекунд. Процессор при этом загружен слабо.

Почему возникает

  1. Запрос выполняется на потоке пула.
  2. Код вызывает httpClient.GetStringAsync(url).Result — поток стоит и ждёт ответа.
  3. Под нагрузкой так встают все потоки пула.
  4. Новые запросы ждут в очереди, пока пул медленно добавляет потоки.

Частая ошибка

Лечить симптом: поднять минимум потоков вместо того, чтобы убрать блокировки.

Как устроено

  1. До минимума (ThreadPool.GetMinThreads, по умолчанию — число процессоров) пул создаёт потоки сразу.
  2. Выше — с задержкой. В примере Microsoft число потоков быстро доходит до 2–3 на ядро, а дальше прибавляется по 1–2 в секунду.
  3. Пока потоков не хватает, растёт очередь и задержка каждого запроса.

Когда что выбирать

Причина Решение
.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 покупают время до исправления кода. Настоящее решение — асинхронность по всему стеку и выделенные потоки для неизбежной блокировки.

Как проверить в проде

  1. 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.
  2. Постоянная проблема: dotnet-stack report -n MyApp или дамп с parallelstacks. Ищите потоки с WorkerThread внизу стека и Task.Wait или GetResult наверху.
  3. Плавающая проблема: 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 обходится несколькими потоками.