У попередній темі ми розглянули Interlocked — атомарні операції що гарантують що одна операція виконається неподільно. Але що якщо у нас кілька операцій і важливий їхній порядок?
Сучасні компілятори та CPU переставляють інструкції для оптимізації. Це абсолютно безпечно для однопотокового коду, але може зламати багатопотоковий:
// ❌ БЕЗ синхронізації — може не працювати!
class DataExchange
{
private int _data = 0;
private bool _ready = false;
// Thread 1 (Producer):
public void Produce()
{
_data = 42; // 1. Записуємо дані
_ready = true; // 2. Сигналізуємо що готово
}
// Thread 2 (Consumer):
public void Consume()
{
while (!_ready) { } // 3. Чекаємо сигнал
Console.WriteLine(_data); // 4. Читаємо дані
}
}
Що може піти не так?
Проблема 1: Compiler Caching — компілятор може закешувати _ready у регістрі CPU:
; Consumer thread:
MOV EAX, [_ready] ; Завантажити _ready у регістр EAX
loop:
TEST EAX, EAX ; Перевірити EAX (не перечитує з пам'яті!)
JZ loop ; Якщо 0 → loop
; ← Нескінченний цикл! Ніколи не побачить зміну _ready
Проблема 2: CPU Reordering — CPU може переставити інструкції:
Producer: Consumer:
─────────────────────────────────────────
_ready = true; (2) while (!_ready) {} (3)
_data = 42; (1) Console.WriteLine(_data); (4)
↑ Може побачити _ready=true
але _data=0 (старе значення!)
Проблема 3: Cache Coherency Delay — зміни у L1 cache одного core не миттєво видимі іншим cores:
CPU Core 1 (Producer) CPU Core 2 (Consumer)
─────────────────────────────────────────────────────
_data = 42 (у L1 cache)
_ready = true (у L1 cache)
while (!_ready) {}
← Читає _ready з свого L1 cache
(досі false, invalidation ще не прийшла!)
volatile keyword у C# надає дві ключові гарантії:
1. Заборона Compiler Caching — компілятор завжди читає/пише з/у пам'ять, не кешує у регістрах:
private volatile bool _ready;
// Компілятор згенерує:
while (!_ready) { }
// ↓
loop:
MOV EAX, [_ready] ; Перечитувати з пам'яті КОЖНОЇ ітерації
TEST EAX, EAX
JZ loop
2. Memory Ordering (Acquire/Release Semantics):
class DataExchange
{
private int _data = 0;
private volatile bool _ready = false; // ← volatile keyword
// Thread 1 (Producer):
public void Produce()
{
_data = 42; // 1. Записуємо дані
_ready = true; // 2. volatile WRITE (release barrier)
// ↑ Гарантує що _data записаний ДО _ready
}
// Thread 2 (Consumer):
public void Consume()
{
while (!_ready) { } // 3. volatile READ (acquire barrier)
// ↑ Гарантує що _data прочитаний ПІСЛЯ _ready
Console.WriteLine(_data); // 4. Завжди побачить 42 ✓
}
}
Чому це працює?
_data = 42 не може бути переставлений після _ready = true (release barrier)Console.WriteLine(_data) не може бути переставлений перед while (!_ready) (acquire barrier)_data = 42 після того як побачить _ready = truevolatile НЕ робить операції атомарними!// ❌ НЕБЕЗПЕЧНО — volatile НЕ допомагає тут!
private volatile int _counter = 0;
Parallel.For(0, 10_000, _ =>
{
_counter++; // ← Досі НЕ атомарна операція!
// Компілюється у: temp = _counter; temp++; _counter = temp;
// volatile гарантує що читання/запис не кешуються,
// але НЕ гарантує що вся операція виконається атомарно!
});
Console.WriteLine(_counter); // Race condition, результат < 10000
// ✅ ПРАВИЛЬНО:
private int _counter = 0; // volatile не потрібен з Interlocked
Parallel.For(0, 10_000, _ =>
{
Interlocked.Increment(ref _counter); // Атомарна операція
});
| Аспект | volatile | Interlocked | lock |
|---|---|---|---|
| Atomicity | ❌ Ні | ✅ Так | ✅ Так |
| Memory Ordering | ✅ Acquire/Release | ✅ Full barrier | ✅ Full barrier |
| Compiler Caching | ✅ Заборонено | ✅ Заборонено | ✅ Заборонено |
| CPU Overhead | ~1-2ns | ~5-20ns | ~50-200ns |
| Use Case | Boolean flags, simple state | Simple atomic ops | Complex critical sections |
| Підходить для RMW | ❌ Ні | ✅ Так | ✅ Так |
RMW = Read-Modify-Write (наприклад, i++, i += 5)
✅ Правильні use cases:
// 1. Boolean flags для сигналізації
private volatile bool _shouldStop = false;
// 2. Simple state variables (enum)
private volatile ConnectionState _state = ConnectionState.Disconnected;
// 3. Reference swap (але Interlocked.Exchange краще)
private volatile Config? _currentConfig;
❌ Неправильні use cases:
// 1. Лічильники (потрібен Interlocked)
private volatile int _counter = 0;
_counter++; // ❌ Race condition
// 2. Складні операції (потрібен lock)
private volatile decimal _balance = 0;
_balance += amount; // ❌ Race condition
// 3. Кілька пов'язаних змінних (потрібен lock)
private volatile int _x = 0;
private volatile int _y = 0;
// Інваріант: _x == _y
_x = 10; // ← Між цими двома операціями
_y = 10; // ← інший потік може побачити _x != _y
Memory Model — набір правил що визначають коли зміни зроблені одним потоком стають видимими іншим потокам. Це контракт між:
Happens-Before — відношення порядку між операціями:
Якщо операція A happens-before операція B, то результат A гарантовано видимий для B.
Приклад:
int x = 0;
volatile bool ready = false;
// Thread 1:
x = 42; // A
ready = true; // B (volatile write)
// Thread 2:
if (ready) // C (volatile read)
{
print(x); // D
}
Happens-Before ланцюжок:
A happens-before B (release barrier: все перед volatile write)B happens-before C (volatile write → volatile read)C happens-before D (acquire barrier: все після volatile read)A happens-before D → Thread 2 побачить x = 42 ✓Release Semantics (volatile write, Interlocked.*, lock exit):
Acquire Semantics (volatile read, Interlocked.*, lock enter):
Візуалізація:
Thread 1 (Producer): Thread 2 (Consumer):
────────────────────────────────────────────────────
x = 1;
y = 2;
z = 3;
─── Release Barrier ───
ready = true; (volatile) ──→ while (!ready) {} (volatile)
─── Acquire Barrier ───
print(x, y, z);
↑ Гарантовано побачить 1, 2, 3
Memory Barrier (або Memory Fence) — інструкція CPU що забороняє reordering навколо себе. Thread.MemoryBarrier() = full fence:
using System.Threading;
class DataExchange
{
private int _data;
private bool _ready;
public void Writer()
{
_data = 42;
Thread.MemoryBarrier(); // ← Full fence: _data записаний ДО _ready
_ready = true;
}
public int Reader()
{
while (!_ready) { }
Thread.MemoryBarrier(); // ← Full fence: _ready прочитаний ДО _data
return _data; // Гарантовано 42
}
}
// Попередній приклад еквівалентний:
class DataExchange
{
private int _data;
private volatile bool _ready; // volatile = implicit barriers
public void Writer()
{
_data = 42;
_ready = true; // volatile write = release barrier
}
public int Reader()
{
while (!_ready) { } // volatile read = acquire barrier
return _data;
}
}
Thread.MemoryBarrier() не потрібен — volatile, Interlocked, lock вже мають вбудовані barriers. Використовується у низькорівневих бібліотеках та lock-free структурах.У деяких платформах (не .NET) є різні типи barriers:
| Тип | Що забороняє | Use Case |
|---|---|---|
| LoadLoad | Load перед barrier не може бути після Load після barrier | Читання залежних даних |
| StoreStore | Store перед barrier не може бути після Store після barrier | Публікація даних |
| LoadStore | Load перед не може бути після Store після | Acquire semantics |
| StoreLoad | Store перед не може бути після Load після | Release semantics |
| Full Fence | Забороняє всі reorderings | Thread.MemoryBarrier() |
.NET спрощує: Thread.MemoryBarrier() = full fence, volatile = acquire/release.
Spinning — потік активно чекає у циклі (busy-wait) замість переходу у wait state. Це має сенс коли:
✅ Коли використовувати:
❌ Коли НЕ використовувати:
Trade-off: Spinning витрачає CPU але уникає context switch (~1-10μs overhead).
using System.Threading;
// ⚠️ SpinLock — struct (не class!), передавати через ref
private SpinLock _spinLock = new SpinLock(enableThreadOwnerTracking: false);
// enableThreadOwnerTracking=true: додає перевірки (повільніше), для debugging
public void CriticalSection()
{
bool lockTaken = false;
try
{
_spinLock.Enter(ref lockTaken); // ← Spinning поки не захопить
// Критична секція — має бути ДУЖЕ короткою!
_sharedCounter++;
}
finally
{
if (lockTaken)
_spinLock.Exit(); // ← ОБОВ'ЯЗКОВО у finally
}
}
// TryEnter з timeout:
public bool TryCriticalSection(int timeoutMs)
{
bool lockTaken = false;
try
{
_spinLock.TryEnter(timeoutMs, ref lockTaken);
if (!lockTaken) return false;
_sharedCounter++;
return true;
}
finally
{
if (lockTaken) _spinLock.Exit();
}
}
Exit() → інші потоки спінять вічноref) → копія, не працює!// Спрощена реалізація SpinLock:
public struct SpinLock
{
private int _owner; // 0 = вільний, ThreadId = зайнятий
public void Enter(ref bool lockTaken)
{
int threadId = Environment.CurrentManagedThreadId;
// Спроба швидкого захоплення (без spinning)
if (Interlocked.CompareExchange(ref _owner, threadId, 0) == 0)
{
lockTaken = true;
return;
}
// Повільний шлях: spinning
SpinWait spinner = new SpinWait();
while (true)
{
// Adaptive spinning (детально нижче)
spinner.SpinOnce();
// Спроба захопити знову
if (Interlocked.CompareExchange(ref _owner, threadId, 0) == 0)
{
lockTaken = true;
return;
}
}
}
public void Exit()
{
Interlocked.Exchange(ref _owner, 0); // Звільнити
}
}
SpinWait — "розумний" spinning що адаптується до ситуації:
Thread.Yield() (дати шанс іншим потокам на цьому core)Thread.Sleep(0) або Sleep(1) (kernel transition, дати шанс іншим cores)Це дозволяє балансувати між низькою латентністю (tight loop) та CPU efficiency (yield/sleep).
using System.Threading;
// Патерн: чекати поки умова стане true
private volatile bool _condition = false;
public void WaitForCondition()
{
var spinner = new SpinWait();
while (!_condition)
{
spinner.SpinOnce(); // ← Adaptive: spin → yield → sleep
}
}
// SpinUntil: helper для простих умов
public void WaitForConditionSimple()
{
SpinWait.SpinUntil(() => _condition); // Еквівалент циклу вище
// З timeout:
bool success = SpinWait.SpinUntil(() => _condition, TimeSpan.FromSeconds(5));
if (!success)
throw new TimeoutException("Condition not met within 5 seconds");
}
// Спрощена логіка SpinWait.SpinOnce():
public struct SpinWait
{
private int _count;
public void SpinOnce()
{
if (_count < 10)
{
// Ітерації 0-9: Tight loop (CPU-bound spin)
Thread.SpinWait(4 << _count); // Exponential backoff
// SpinWait(N) = N iterations of NOP instruction
}
else if (_count < 20)
{
// Ітерації 10-19: Thread.Yield()
Thread.Yield(); // Дати шанс іншим потокам на цьому core
}
else
{
// Ітерації 20+: Thread.Sleep()
Thread.Sleep(_count >= 40 ? 1 : 0);
// Sleep(0) = yield to any thread (same or higher priority)
// Sleep(1) = yield to any thread + minimum 1ms delay
}
_count++;
}
}
Exponential Backoff: кожна ітерація чекає довше → зменшує contention.
using System;
using System.Diagnostics;
using System.Threading;
using System.Threading.Tasks;
const int Iterations = 10_000_000;
int counter = 0;
// ─── Test 1: lock (Monitor) ───────────────────────────────────
var lockObj = new object();
var sw = Stopwatch.StartNew();
Parallel.For(0, Iterations, _ =>
{
lock (lockObj) { counter++; }
});
sw.Stop();
Console.WriteLine($"lock: {sw.ElapsedMilliseconds,5}ms, counter={counter}");
// ─── Test 2: SpinLock ─────────────────────────────────────────
counter = 0;
var spinLock = new SpinLock(false);
sw.Restart();
Parallel.For(0, Iterations, _ =>
{
bool taken = false;
try
{
spinLock.Enter(ref taken);
counter++;
}
finally { if (taken) spinLock.Exit(); }
});
sw.Stop();
Console.WriteLine($"SpinLock: {sw.ElapsedMilliseconds,5}ms, counter={counter}");
// ─── Test 3: Interlocked ──────────────────────────────────────
counter = 0;
sw.Restart();
Parallel.For(0, Iterations, _ =>
{
Interlocked.Increment(ref counter);
});
sw.Stop();
Console.WriteLine($"Interlocked: {sw.ElapsedMilliseconds,5}ms, counter={counter}");
// Типові результати (8 cores, дуже коротка критична секція):
// lock: 1200ms
// SpinLock: 600ms (в 2x швидше)
// Interlocked: 80ms (в 15x швидше — найкраще для простих операцій)
ABA Problem — класична проблема CAS-based структур. Виникає коли:
AA → B → A (повертає назад!)A → замінити" → SUCCESS (бо значення співпало)// Сценарій з lock-free stack:
// Initial: head → [A] → [B] → [C] → null
// Thread 1: Pop()
Node? current = _head; // current = [A]
// ← PREEMPTED (зупинився тут)
// Thread 2: Pop(), Pop(), Push(A)
TryPop(out _); // Pop [A], head → [B]
TryPop(out _); // Pop [B], head → [C]
Push(A); // Push [A], head → [A] → [C]
// Тепер: head → [A] → [C] → null (але це ІНШИЙ [A]!)
// Thread 1: продовжує
// CAS(head, current.Next, current)
// CAS(head, [B], [A]) → SUCCESS! (бо head == [A])
// head = [B]
// Результат: head → [B] → ??? (CORRUPTION! [B] вже не в стеку)
Проблема: CAS перевіряє тільки значення, не ідентичність. Якщо значення повернулось до A — CAS не помітить що це інший A.
Додати лічильник версій що інкрементується при кожній зміні:
private class VersionedNode<T>
{
public T Value;
public VersionedNode<T>? Next;
public long Version; // ← Інкрементується при кожній зміні
}
private VersionedNode<T>? _head;
private long _version = 0;
public void Push(T item)
{
var newNode = new VersionedNode<T>
{
Value = item,
Version = Interlocked.Increment(ref _version) // Нова версія
};
do
{
newNode.Next = _head;
}
while (Interlocked.CompareExchange(ref _head, newNode, newNode.Next) != newNode.Next);
}
// Тепер навіть якщо адреса співпала — версія буде інша → CAS fail ✓
Складний механізм що "захищає" вказівники від reuse поки хтось їх використовує. Використовується у production lock-free бібліотеках.
У C# ABA problem менш критичний бо:
Але для критичних систем — використовуйте готові бібліотеки:
System.Collections.Concurrent.ConcurrentStack<T> — він вже має всі оптимізації та захист від ABA. Власні lock-free структури — тільки для навчання або специфічних потреб.volatile
Memory Model
Thread.MemoryBarrier() — заборона всіх reorderingsSpinLock / SpinWait
ABA Problem
System.Collections.Concurrent.*Створіть демонстрацію проблеми compiler caching:
volatile keywordСтворіть benchmark для різних довжин критичних секцій:
Структура:
public class CriticalSectionBenchmark
{
private int _counter = 0;
private object _lockObj = new();
private SpinLock _spinLock = new(false);
public void TestLock(int operationsCount)
{
// Різні довжини критичних секцій:
// 1. Дуже коротка: 1 інкремент
// 2. Коротка: 10 операцій
// 3. Середня: 100 операцій + array access
// 4. Довга: 1000 операцій + Thread.SpinWait
}
}
Вимоги:
Очікуваний результат:
Висновок: Визначте "точку перелому" — при якій довжині критичної секції lock стає кращим за SpinLock.
Створіть демонстрацію ABA problem:
Частина 1: Vulnerable Stack
public class VulnerableStack<T>
{
private Node? _head;
// Реалізуйте Push/Pop через CAS
// Додайте штучну затримку у Pop для провокації ABA
}
Частина 2: ABA Scenario
// Thread 1: Pop з затримкою
var thread1 = new Thread(() =>
{
Node? current = _head;
Thread.Sleep(100); // ← Затримка для провокації
// Спроба CAS...
});
// Thread 2: Pop, Pop, Push (створює ABA)
var thread2 = new Thread(() =>
{
stack.TryPop(out _);
stack.TryPop(out _);
stack.Push(originalValue); // Повертає той самий value
});
Частина 3: Fixed Stack з Version Counter
public class VersionedStack<T>
{
private class Node
{
public T Value;
public Node? Next;
public long Version;
}
// Реалізуйте з version counter
}
Вимоги:
Класичний патерн для lazy initialization з мінімальним overhead:
public class Singleton
{
private static volatile Singleton? _instance;
private static readonly object _lock = new();
public static Singleton Instance
{
get
{
// Перша перевірка (без lock) — швидкий шлях
if (_instance == null)
{
lock (_lock)
{
// Друга перевірка (під lock) — безпечний шлях
if (_instance == null)
{
_instance = new Singleton();
}
}
}
return _instance;
}
}
}
Чому потрібен volatile?
volatile: Thread 2 може побачити частково ініціалізований об'єктvolatile: Release barrier гарантує що конструктор завершивсяСучасна альтернатива: Lazy<T> (рекомендовано)
private static readonly Lazy<Singleton> _instance =
new Lazy<Singleton>(() => new Singleton());
public static Singleton Instance => _instance.Value;
Lock-Free — гарантує що хоча б один потік прогресує (може бути starvation):
// CAS loop — lock-free
do
{
current = _value;
newValue = current + 1;
}
while (Interlocked.CompareExchange(ref _value, newValue, current) != current);
// ↑ Якщо багато потоків — один може retry нескінченно (starvation)
Wait-Free — гарантує що кожен потік прогресує за обмежену кількість кроків:
// Interlocked.Increment — wait-free
Interlocked.Increment(ref _value);
// ↑ Завжди завершується за O(1) кроків, незалежно від contention
Порівняння:
| Аспект | Lock-Free | Wait-Free |
|---|---|---|
| Прогрес | Хоча б один потік | Кожен потік |
| Starvation | Можлива | Неможлива |
| Складність | Середня | Висока |
| Performance | Добра | Відмінна |
| Приклади | CAS loop, Treiber stack | Interlocked ops, FAA |
FAA = Fetch-And-Add (атомарне додавання з поверненням старого значення)
| Платформа | Model | Особливості |
|---|---|---|
| x86/x64 | TSO (Total Store Order) | Сильна модель, мало reordering |
| ARM | Weak | Багато reordering, потрібні explicit barriers |
| ARM64 | Weak | Acquire/Release instructions |
| RISC-V | RVWMO | Слабка модель |
.NET абстрагує: volatile, Interlocked, lock працюють однаково на всіх платформах.
Інструменти:
Best Practices:
// 1. Додайте assertions
Debug.Assert(Interlocked.CompareExchange(ref _value, 0, 0) >= 0);
// 2. Логуйте retries
int retries = 0;
do
{
if (++retries > 1000)
Console.WriteLine($"High contention: {retries} retries");
}
while (/* CAS */);
// 3. Stress testing
Parallel.For(0, 1_000_000, _ => /* операція */);
// 4. Використовуйте Thread.Yield() для провокації race conditions
Thread.Yield(); // Дати шанс іншим потокам "зламати" код
Блок-схема вибору примітива:
Рекомендації:
У цій темі ми розглянули низькорівневі механізми синхронізації:
volatile — забороняє compiler caching та надає acquire/release semantics, але НЕ дає atomicity. Використовуйте для boolean flags.
Memory Model — правила видимості змін між потоками. Happens-before relationship гарантує порядок операцій через acquire/release barriers.
Thread.MemoryBarrier — full fence для повного контролю над reordering. Рідко потрібен у прикладному коді.
SpinLock/SpinWait — user-mode spinning для дуже коротких критичних секцій. Швидше за lock але марнує CPU.
ABA Problem — підводний камінь lock-free структур. Рішення: version counter, hazard pointers, або використання готових бібліотек.
Золоте правило: Почніть з lock, оптимізуйте тільки якщо profiling показав bottleneck. Передчасна оптимізація — корінь усього зла.
System.Collections.Concurrent.* замість власних lock-free структур. Вони вже мають всі оптимізації, захист від ABA та протестовані на мільйонах систем.Документація:
Книги:
Статті:
Interlocked, CAS та Lock-Free Структури
Атомарні операції на рівні CPU через Interlocked клас, Compare-And-Swap pattern, lock-free програмування та практичні реалізації lock-free counter, stack та queue. Детальний розбір з прикладами та benchmarks.
ThreadPool — Пул Потоків для Ефективного Виконання
Глибокий академічний розбір ThreadPool у .NET — архітектура, Hill Climbing Algorithm, worker threads vs IOCP threads, ExecutionContext, проблеми thread starvation та best practices використання пулу потоків.