У темі 06 та 07 ми розглянули lock, Monitor, Mutex, SemaphoreSlim — всі вони базуються на блокуванні: якщо ресурс зайнятий, потік переходить у стан очікування (wait state), віддає CPU іншим потокам і чекає поки ОС його розбудить. Це працює чудово для більшості сценаріїв, але має overhead:
Для дуже коротких критичних секцій (наприклад, інкремент лічильника, swap значення) — overhead блокування може перевищувати саму роботу!
Альтернатива: атомарні операції та lock-free структури — виконують синхронізацію без блокування потоків, використовуючи спеціальні CPU інструкції.
Атомарна операція — виконується як єдина неподільна дія з точки зору інших потоків. Ніхто не може "побачити" проміжний стан. Для CPU це означає спеціальну інструкцію з префіксом LOCK (x86/x64), яка:
System.Threading.Interlocked — клас що надає атомарні операції для примітивних типів.
using System.Threading;
// ─── Increment / Decrement ────────────────────────────────────
int counter = 0;
// Атомарно: counter = counter + 1; return новеЗначення;
int newValue = Interlocked.Increment(ref counter); // → 1
// Атомарно: counter = counter - 1; return новеЗначення;
newValue = Interlocked.Decrement(ref counter); // → 0
// ⚠️ Важливо: Increment/Decrement повертають НОВЕ значення (post-increment)
// Це НЕ те саме що counter++ (який повертає старе)
// ─── Add ───────────────────────────────────────────────────────
long total = 100;
Interlocked.Add(ref total, 50); // total = 150
// Підтримує: int, long, uint, ulong
// ─── Exchange (Atomic Swap) ────────────────────────────────────
int oldValue = 42;
int newVal = Interlocked.Exchange(ref oldValue, 100);
// oldValue тепер = 100, newVal = 42 (повертає попереднє значення)
// Працює з будь-якими reference types:
object? obj = new MyClass();
object? previous = Interlocked.Exchange(ref obj, null);
// ─── CompareExchange (CAS — Compare-And-Swap) ─────────────────
int value = 10;
int comparand = 10; // очікуване значення
int newValue = 20; // нове значення
int original = Interlocked.CompareExchange(ref value, newValue, comparand);
// Логіка: if (value == comparand) { value = newValue; } return originalValue;
// original = 10, value тепер = 20
// Якщо comparand НЕ співпадає — нічого не змінюється:
value = 10;
original = Interlocked.CompareExchange(ref value, 99, 999);
// original = 10, value залишився 10 (бо 10 != 999)
// ─── Read (Atomic Read для 64-bit на 32-bit системах) ─────────
long bigNumber = 123456789012345;
long snapshot = Interlocked.Read(ref bigNumber);
// На x64 — не потрібен (long read атомарний), але на x86-32 — критично!
i++ НЕ Атомарний// ❌ НЕБЕЗПЕЧНО у багатопотоковому коді:
int counter = 0;
Parallel.For(0, 1000, _ =>
{
counter++; // ← НЕ атомарна операція!
});
Console.WriteLine(counter); // Очікуємо 1000, отримуємо ~850-990 (race condition)
// Чому? Бо counter++ компілюється у 3 інструкції:
// 1. Read: temp = counter
// 2. Modify: temp = temp + 1
// 3. Write: counter = temp
// Між цими інструкціями інший потік може втрутитись!
// ✅ ПРАВИЛЬНО:
Parallel.For(0, 1000, _ =>
{
Interlocked.Increment(ref counter); // Атомарна операція
});
Console.WriteLine(counter); // Завжди 1000
CompareExchange — найпотужніший примітив Interlocked. Він дозволяє реалізувати будь-яку атомарну операцію через retry loop:
using System.Threading;
// Задача: атомарно виконати складну операцію (наприклад, counter = counter * 2 + 1)
// Interlocked.Add не підходить — операція нелінійна
int counter = 10;
int MultiplyAndAddOne(ref int location)
{
int current, newValue;
do
{
current = location; // 1. Читаємо поточне значення
newValue = current * 2 + 1; // 2. Обчислюємо нове (може бути дорога операція)
// 3. Спроба атомарно замінити:
// "Якщо location досі == current (ніхто не змінив) → записати newValue"
} while (Interlocked.CompareExchange(ref location, newValue, current) != current);
// ↑ Якщо повернене значення != current → хтось змінив між кроками 1-3 → retry!
return newValue;
}
// Використання:
int result = MultiplyAndAddOne(ref counter);
Console.WriteLine(result); // 21 (10 * 2 + 1)
Як це працює:
current)newValue) — може зайняти часcurrent → замінити на newValue"using System.Threading;
/// <summary>
/// Thread-safe лічильник без використання lock.
/// Всі операції — lock-free через Interlocked.
/// </summary>
public class LockFreeCounter
{
private int _value;
public int Value => Interlocked.CompareExchange(ref _value, 0, 0); // Atomic read
// Трюк: CAS з comparand=0 і newValue=0 → нічого не змінює, але повертає поточне значення
public int Increment() => Interlocked.Increment(ref _value);
public int Decrement() => Interlocked.Decrement(ref _value);
public int Add(int delta) => Interlocked.Add(ref _value, delta);
/// <summary>Атомарно встановити значення якщо воно менше за newValue.</summary>
public int SetIfGreater(int newValue)
{
int current;
do
{
current = _value;
if (current >= newValue) return current; // Вже більше → не змінюємо
} while (Interlocked.CompareExchange(ref _value, newValue, current) != current);
return newValue;
}
/// <summary>Атомарно скинути у 0 і повернути попереднє значення.</summary>
public int Reset() => Interlocked.Exchange(ref _value, 0);
}
// Benchmark: Lock-Free vs Lock-Based
var lockFree = new LockFreeCounter();
var lockBased = 0;
var lockObj = new object();
var sw = System.Diagnostics.Stopwatch.StartNew();
Parallel.For(0, 1_000_000, _ => lockFree.Increment());
sw.Stop();
Console.WriteLine($"Lock-Free: {sw.ElapsedMilliseconds}ms, Value={lockFree.Value}");
sw.Restart();
Parallel.For(0, 1_000_000, _ => { lock (lockObj) { lockBased++; } });
sw.Stop();
Console.WriteLine($"Lock-Based: {sw.ElapsedMilliseconds}ms, Value={lockBased}");
// Типовий результат (8 cores):
// Lock-Free: ~50-80ms
// Lock-Based: ~200-400ms (в 3-5 разів повільніше при високій конкуренції)
Сучасні компілятори та CPU переставляють інструкції для оптимізації. Це безпечно для однопотокового коду, але ламає багатопотоковий:
// ❌ БЕЗ volatile — може не працювати!
class Worker
{
private bool _shouldStop = false; // ← CPU може закешувати у регістрі
private int _data = 0;
public void ProducerThread()
{
_data = 42; // 1. Записуємо дані
_shouldStop = true; // 2. Сигналізуємо що готово
}
public void ConsumerThread()
{
while (!_shouldStop) // ← Може НІКОЛИ не побачити зміну!
{
Thread.SpinWait(1);
}
Console.WriteLine(_data); // Може побачити 0 замість 42 (reordering!)
}
}
// Проблема 1: Compiler може закешувати _shouldStop у регістрі → ніколи не перечитує з RAM
// Проблема 2: CPU може переставити інструкції → Consumer побачить _shouldStop=true ДО _data=42
class Worker
{
private volatile bool _shouldStop = false; // ← volatile keyword
private int _data = 0;
public void ProducerThread()
{
_data = 42;
_shouldStop = true; // volatile write: гарантує що _data записаний ДО цього
}
public void ConsumerThread()
{
while (!_shouldStop) { } // volatile read: завжди читає з пам'яті, не з кешу
Console.WriteLine(_data); // Гарантовано побачить 42
}
}
// volatile гарантує:
// ✅ Заборона compiler optimization (завжди читати/писати з/у пам'ять)
// ✅ Acquire/Release semantics:
// - volatile READ = acquire: всі операції ПІСЛЯ нього не можуть бути переставлені ПЕРЕД
// - volatile WRITE = release: всі операції ПЕРЕД ним не можуть бути переставлені ПІСЛЯ
volatile НЕ робить операції атомарними!// ❌ НЕБЕЗПЕЧНО — volatile НЕ допомагає тут!
private volatile int _counter = 0;
Parallel.For(0, 1000, _ =>
{
_counter++; // ← Досі НЕ атомарна операція! (Read-Modify-Write)
});
Console.WriteLine(_counter); // Race condition, результат < 1000
// ✅ ПРАВИЛЬНО:
private int _counter = 0; // volatile не потрібен з Interlocked
Parallel.For(0, 1000, _ =>
{
Interlocked.Increment(ref _counter); // Interlocked вже має memory barriers
});
Коли використовувати volatile:
Interlocked або lockMemory Barrier (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
}
}
// Еквівалент через volatile:
class DataExchangeVolatile
{
private int _data;
private volatile bool _ready; // volatile write/read = 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 структурах.Spinning — потік активно чекає у циклі замість переходу у 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() → інші потоки спінять вічноSpinWait — "розумний" spinning що адаптується: спочатку швидкий spin, потім Thread.Yield(), потім Thread.Sleep(0).
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();
}
// Як працює SpinOnce():
// Ітерація 1-10: Tight loop (CPU-bound spin)
// Ітерація 11-20: Thread.Yield() (дати шанс іншим потокам на цьому core)
// Ітерація 21+: Thread.Sleep(0) або Sleep(1) (kernel transition)
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}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}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}ms, counter={counter}");
// Типові результати (8 cores, дуже коротка критична секція):
// lock: ~800-1200ms
// SpinLock: ~400-600ms (в 2x швидше)
// Interlocked: ~50-100ms (в 10x швидше — найкраще для простих операцій)
Treiber Stack — класичний lock-free stack через CAS. Ідея: head вказівник оновлюється атомарно через CompareExchange.
using System.Threading;
/// <summary>
/// Lock-free stack (Treiber algorithm).
/// Push та Pop — wait-free для single producer/consumer, lock-free для multi.
/// </summary>
public class LockFreeStack<T>
{
private class Node
{
public T Value;
public Node? Next;
public Node(T value) => Value = value;
}
private Node? _head; // Вершина стеку
/// <summary>Додати елемент на вершину (lock-free).</summary>
public void Push(T item)
{
var newNode = new Node(item);
do
{
newNode.Next = _head; // 1. Новий вузол вказує на поточну вершину
// 2. CAS: якщо _head досі той самий → замінити на newNode
} while (Interlocked.CompareExchange(ref _head, newNode, newNode.Next) != newNode.Next);
// ↑ Якщо хтось інший змінив _head між кроками 1-2 → retry
}
/// <summary>Забрати елемент з вершини (lock-free). Повертає false якщо стек порожній.</summary>
public bool TryPop(out T? result)
{
Node? current;
do
{
current = _head; // 1. Читаємо поточну вершину
if (current == null) // Стек порожній
{
result = default;
return false;
}
// 2. CAS: якщо _head досі == current → замінити на current.Next
} while (Interlocked.CompareExchange(ref _head, current.Next, current) != current);
result = current.Value;
return true;
}
/// <summary>Перевірити чи стек порожній (snapshot, може змінитись одразу після виклику).</summary>
public bool IsEmpty => _head == null; // volatile read
}
// Демонстрація:
var stack = new LockFreeStack<int>();
// 10 потоків додають по 1000 елементів
Parallel.For(0, 10, i =>
{
for (int j = 0; j < 1000; j++)
stack.Push(i * 1000 + j);
});
// 10 потоків забирають елементи
int totalPopped = 0;
Parallel.For(0, 10, _ =>
{
int localCount = 0;
while (stack.TryPop(out _))
localCount++;
Interlocked.Add(ref totalPopped, localCount);
});
Console.WriteLine($"Pushed: 10000, Popped: {totalPopped}"); // Має бути 10000
ABA Problem — класична проблема CAS-based структур:
// Сценарій:
// 1. Thread A: читає head = Node(A)
// 2. Thread B: Pop(A), Pop(B), Push(A) ← head знову = Node(A) (але ІНШИЙ об'єкт!)
// 3. Thread A: CAS(head, newNode, A) → SUCCESS (бо адреса співпала)
// Але структура змінилась між кроками 1-3 → corruption!
// Рішення 1: Version Counter (Tagged Pointer)
private class VersionedNode<T>
{
public T Value;
public VersionedNode<T>? Next;
public long Version; // ← Інкрементується при кожній зміні
}
// Рішення 2: Hazard Pointers (складно, для production використовуйте бібліотеки)
// Рішення 3: Garbage Collection (C# вирішує автоматично!)
// У C# ABA problem менш критичний бо GC не дозволяє reuse адрес одразу
// Але для критичних систем — використовуйте ConcurrentStack<T> з BCL
System.Collections.Concurrent.ConcurrentStack<T> — він вже має всі оптимізації та захист від ABA. Власні lock-free структури — тільки для навчання або специфічних потреб.Interlocked
volatile
SpinLock / SpinWait
Lock-Free Structures
System.Collections.Concurrent.*Реалізуйте 4 варіанти thread-safe counter та порівняйте:
lock statementInterlocked.IncrementSpinLockvolatile int + Interlocked.CompareExchange (CAS loop)Benchmark: 10 потоків × 1_000_000 інкрементів кожен. Виміряйте час та переконайтесь що всі дають результат 10_000_000.
Stopwatch для вимірювання часу. Запускайте у Release mode (dotnet run -c Release) для реалістичних результатів. Очікуваний порядок швидкості: Interlocked > SpinLock > CAS loop > lock.Реалізуйте LockFreeSPSCQueue<T> (Single-Producer Single-Consumer):
TryEnqueue(T item) — додати якщо є місцеTryDequeue(out T item) — забрати якщо є елементиInterlocked для head/tail, volatile для синхронізаціїvolatile для індексів (не потрібен CAS). Producer пише тільки _tail, Consumer читає тільки _head. Використовуйте (index + 1) % capacity для циклічності.Створіть benchmark suite що порівнює 3 підходи для різних сценаріїв:
Thread.SpinWait)Виміряйте throughput (ops/sec) та CPU usage для кожного. Зробіть висновок: коли який підхід оптимальний залежно від довжини критичної секції та рівня конкуренції.
Process.GetCurrentProcess().TotalProcessorTime для вимірювання CPU usage.Синхронізація — Mutex, Semaphore та Event-Based Primitives
Kernel-level та advanced синхронізаційні примітиви .NET — Named Mutex для cross-process ізоляції, SemaphoreSlim для обмеження паралелізму, AutoResetEvent та ManualResetEvent для сигналізації між потоками. Теорія, аналогії та детальний розбір кожного примітива.
Interlocked, CAS та Lock-Free Структури
Атомарні операції на рівні CPU через Interlocked клас, Compare-And-Swap pattern, lock-free програмування та практичні реалізації lock-free counter, stack та queue. Детальний розбір з прикладами та benchmarks.