Багатопотоковість
Як .NET виконує код паралельно: потоки й пул потоків, захист спільних даних блокуваннями та атомарними операціями, конкурентні колекції та черги між потоками. На співбесіді тут перевіряють, чи бачить кандидат гонки й дедлоки і чи вміє знайти їх у продакшені.
Перед цією темою: Типи й пам’ять.
Чим Thread відрізняється від Task і що таке пул потоків?
Section titled “Чим 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 і як їх уникнути?
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. - Дедлок потребує чотирьох умов Коффмана:
- взаємне виключення;
- утримання ресурсу з очікуванням іншого;
- відсутність витіснення;
- циклічне очікування.
Ціна
Дедлок — це зависання без навантаження на 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)?
Section titled “Як працює 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 і чим вони відрізняються?
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 одночасних операцій, працює з 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.
Як влаштовані конкурентні колекції і коли вони потрібні?
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 за одночасних викликів фабрика могла б відпрацювати кілька разів, хоча в словнику однаково лишилося б одне значення.
- Чому
GetOrAddможе викликати фабрику двічі? — Делегат виконується поза блокуванням, щоб не тримати його на чужому коді; зберігається лише одне значення. - Чи дорого викликати
CountуConcurrentDictionary? — Так: беруться всі внутрішні блокування; на гарячому шляху краще окремий лічильник. - Коли
ConcurrentBag— поганий вибір? — Коли виробники й споживачі — різні потоки: споживачі постійно забирають із чужих списків. - Чи дає перелічування
ConcurrentDictionaryзнімок? — Ні: обхід безпечний, але бачить зміни; знімок даєToArray(). - Чи потрібні конкурентні колекції, якщо дані лише читаються? — Ні: одночасне читання звичайних колекцій без записів безпечне.
Що таке Interlocked, volatile і модель пам’яті .NET?
Section titled “Що таке 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?
Section titled “Як побудувати 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? — Обіцянку, що читач один: канал може обрати швидшу реалізацію.
Що таке голодування пулу потоків і як його діагностувати?
Section titled “Що таке голодування пулу потоків і як його діагностувати?”Що це
Усі потоки пулу зайняті очікуванням, а нові запити стоять у черзі.
Як це виглядає
Сервіс під навантаженням відповідає за секунди замість мілісекунд. Процесор при цьому завантажений слабко.
Чому виникає
- Запит виконується на потоці пулу.
- Код викликає
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? — Довга блокувальна робота отримує окремий потік і не займає пул.