Перейти до вмісту

Багатопотоковість

Як .NET виконує код паралельно: потоки й пул потоків, захист спільних даних блокуваннями та атомарними операціями, конкурентні колекції та черги між потоками. На співбесіді тут перевіряють, чи бачить кандидат гонки й дедлоки і чи вміє знайти їх у продакшені.

Перед цією темою: Типи й пам’ять.

Junior+

Чим Thread відрізняється від Task і що таке пул потоків?

Section titled “Чим 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 і як їх уникнути?

Section titled “Що таке 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)?

Section titled “Як працює 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 і чим вони відрізняються?

Section titled “Які примітиви синхронізації є в .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+

Як влаштовані конкурентні колекції і коли вони потрібні?

Section titled “Як влаштовані конкурентні колекції і коли вони потрібні?”
Відповідь

Що це

Колекції з 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, volatile і модель пам’яті .NET?

Section titled “Що таке Interlocked, volatile і модель пам’яті .NET?”
Відповідь

Що це

  • 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?

Section titled “Як побудувати 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+

Що таке голодування пулу потоків і як його діагностувати?

Section titled “Що таке голодування пулу потоків і як його діагностувати?”
Відповідь

Що це

Усі потоки пулу зайняті очікуванням, а нові запити стоять у черзі.

Як це виглядає

Сервіс під навантаженням відповідає за секунди замість мілісекунд. Процесор при цьому завантажений слабко.

Чому виникає

  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 обходиться кількома потоками.