Уявіть бібліотеку, де кілька людей одночасно працюють з картотекою. Кожен читає картку, робить нотатки, повертає на місце. Якщо двоє візьмуть одну картку одночасно, зроблять різні зміни і повернуть — одна зміна "загубиться". Це фундаментальна проблема спільного стану (shared state) у багатопотокових системах.
У світі комп'ютерів "картка" — це змінна в пам'яті, "люди" — потоки (threads), "нотатки" — операції читання/запису. Коли кілька потоків одночасно працюють з однією змінною без координації, виникає race condition — результат залежить від непередбачуваного порядку виконання операцій.
i++ НебезпечнийРозглянемо найпростішу операцію — інкремент змінної:
int counter = 0;
// Два потоки одночасно виконують:
counter++; // Здається атомарним, але це ілюзія
Компілятор перетворює counter++ у три окремі інструкції:
counter з пам'яті у регістр CPUЯкщо два потоки виконують це одночасно, можливий такий сценарій:
Час | Потік 1 | Потік 2 | counter (пам'ять)
----|----------------------|----------------------|------------------
t0 | READ counter → 0 | | 0
t1 | | READ counter → 0 | 0
t2 | MODIFY: 0 + 1 = 1 | | 0
t3 | | MODIFY: 0 + 1 = 1 | 0
t4 | WRITE 1 | | 1
t5 | | WRITE 1 | 1
Результат: counter = 1, хоча мало бути 2. Один інкремент "загубився" — це lost update, найпоширеніший тип race condition.
1. Рівень CPU: Non-Atomic Operations
Сучасні CPU виконують інструкції паралельно (out-of-order execution, pipelining). Навіть проста операція може бути розбита на мікрооперації, що виконуються у різних блоках CPU. Між цими мікроопераціями інший потік може "втрутитись".
2. Рівень Кешу: Cache Coherency
Кожне ядро CPU має власний L1/L2 кеш. Коли Потік 1 (на ядрі 0) читає counter, значення потрапляє у кеш ядра 0. Коли Потік 2 (на ядрі 1) читає counter, значення потрапляє у кеш ядра 1. Обидва кеші містять копії — зміни одного не одразу видимі іншому.
3. Рівень Компілятора: Reordering
Компілятор (JIT у .NET) може змінювати порядок інструкцій для оптимізації. Операції, що здаються послідовними у коді, можуть виконуватись у іншому порядку на CPU. Це називається memory reordering.
Щоб вирішити проблему race condition, потрібні три гарантії:
Саме це і забезпечує Monitor — фундаментальний примітив синхронізації у .NET.
Monitor (монітор) — це концепція з теорії паралельного програмування, запропонована Тоні Хоаром (Tony Hoare) у 1974 році та розвинута Пером Брінчем Хансеном (Per Brinch Hansen). Це механізм синхронізації, що забезпечує взаємне виключення (mutual exclusion) доступу до спільного ресурсу.
Ключові властивості монітора:
Аналогія: уявіть кімнату з одним входом і замком. Коли хтось заходить, двері замикаються — ніхто інший не може увійти. Коли людина виходить, двері відмикаються — наступний може зайти. Монітор — це "розумний замок", що автоматично керує доступом.
У .NET кожен об'єкт має прихований заголовок (object header), що містить sync block index — вказівник на структуру синхронізації. Коли потік намагається захопити монітор на об'єкті, CLR:
Структура sync block (спрощено):
┌─────────────────────────────────────┐
│ Object Header (8-16 bytes) │
├─────────────────────────────────────┤
│ Sync Block Index (4 bytes) │ ──→ ┌──────────────────────┐
└─────────────────────────────────────┘ │ Sync Block Table │
├──────────────────────┤
│ Owner Thread ID │
│ Recursion Count │
│ Wait Queue │
│ Condition Variables │
└──────────────────────┘
Важливо: sync block створюється лінивою (lazy) — тільки коли перший потік намагається захопити монітор. Це економить пам'ять для об'єктів, що ніколи не використовуються для синхронізації.
Коли потік захоплює монітор, CLR виконує acquire fence (memory barrier) — це гарантує, що всі зміни, зроблені іншими потоками до цього моменту, стають видимими поточному потоку.
Візуалізація memory barrier:
Потік 1 Потік 2
────────────────────────────────────────────────────────
counter = 0
Monitor.Enter(lockObj)
counter = 1
[RELEASE FENCE] ───────────────→ [ACQUIRE FENCE]
Monitor.Exit(lockObj) Monitor.Enter(lockObj)
// Бачить counter = 1
Monitor.Exit(lockObj)
Без memory barrier CPU або компілятор могли б переупорядкувати інструкції так, що Потік 2 побачив би старе значення counter = 0, навіть якщо Потік 1 вже записав 1.
Monitor у .NET є reentrant (рекурсивним) — потік, що вже тримає монітор, може захопити його знову без блокування себе. CLR веде лічильник входів (recursion count):
object lockObj = new();
void MethodA()
{
Monitor.Enter(lockObj); // Recursion count = 1
MethodB();
Monitor.Exit(lockObj); // Recursion count = 0
}
void MethodB()
{
Monitor.Enter(lockObj); // Recursion count = 2 (той самий потік!)
// Робота...
Monitor.Exit(lockObj); // Recursion count = 1
}
Чому це важливо: без reentrancy потік заблокував би сам себе (self-deadlock). Але є підводний камінь — кожен Enter має мати відповідний Exit, інакше монітор залишиться захопленим назавжди.
class BankAccount
{
private decimal _balance;
private readonly object _lock = new();
public event Action<decimal>? BalanceChanged;
public void Withdraw(decimal amount)
{
lock (_lock)
{
_balance -= amount;
BalanceChanged?.Invoke(_balance); // ⚠️ Callback під lock!
}
}
}
// Підписник може знову викликати Withdraw:
account.BalanceChanged += balance => account.Withdraw(10); // Deadlock або порушення інваріантів
Тепер, коли ми розуміємо концепцію, розглянемо клас System.Threading.Monitor детально. Це статичний клас з методами для роботи з монітором будь-якого об'єкта.
Сигнатури:
public static void Enter(object obj);
public static void Enter(object obj, ref bool lockTaken);
public static void Exit(object obj);
Базове використання:
object lockObj = new();
int counter = 0;
void Increment()
{
Monitor.Enter(lockObj);
try
{
counter++; // Критична секція
}
finally
{
Monitor.Exit(lockObj);
}
}
Чому try/finally критичний: якщо всередині критичної секції виникне виняток, Exit має бути викликаний обов'язково. Інакше монітор залишиться захопленим, і всі інші потоки заблокуються назавжди (це називається abandoned lock).
Перевантаження з lockTaken:
bool lockTaken = false;
try
{
Monitor.Enter(lockObj, ref lockTaken);
counter++;
}
finally
{
if (lockTaken)
Monitor.Exit(lockObj);
}
Навіщо lockTaken: якщо виняток виникне до захоплення монітора (наприклад, ThreadAbortException), lockTaken залишиться false, і Exit не буде викликаний. Це запобігає помилці "object synchronization method was called from an unsynchronized block of code".
lockTaken для максимальної надійності, особливо у критичному коді (фінансові системи, медичне ПЗ).Іноді потрібно спробувати захопити монітор, але не чекати нескінченно. Для цього є TryEnter:
public static bool TryEnter(object obj);
public static bool TryEnter(object obj, int millisecondsTimeout);
public static bool TryEnter(object obj, TimeSpan timeout);
public static bool TryEnter(object obj, int millisecondsTimeout, ref bool lockTaken);
Приклад з timeout:
object lockObj = new();
void TryProcessData()
{
if (Monitor.TryEnter(lockObj, millisecondsTimeout: 1000))
{
try
{
// Захопили монітор, обробляємо дані
ProcessData();
}
finally
{
Monitor.Exit(lockObj);
}
}
else
{
// Не вдалося захопити за 1 секунду
Console.WriteLine("Resource is busy, skipping...");
}
}
Коли використовувати TryEnter:
Реальний приклад — Thread-Safe Cache з Timeout:
class ThreadSafeCache<TKey, TValue> where TKey : notnull
{
private readonly Dictionary<TKey, TValue> _cache = new();
private readonly object _lock = new();
private readonly TimeSpan _lockTimeout = TimeSpan.FromMilliseconds(500);
public bool TryGet(TKey key, out TValue? value)
{
bool lockTaken = false;
try
{
Monitor.TryEnter(_lock, _lockTimeout, ref lockTaken);
if (!lockTaken)
{
value = default;
return false; // Cache недоступний, повертаємо промах
}
return _cache.TryGetValue(key, out value);
}
finally
{
if (lockTaken)
Monitor.Exit(_lock);
}
}
public void Set(TKey key, TValue value)
{
bool lockTaken = false;
try
{
Monitor.TryEnter(_lock, _lockTimeout, ref lockTaken);
if (!lockTaken)
throw new TimeoutException("Failed to acquire cache lock");
_cache[key] = value;
}
finally
{
if (lockTaken)
Monitor.Exit(_lock);
}
}
}
Демонстрація роботи:
Метод IsEntered дозволяє перевірити, чи поточний потік тримає монітор:
public static bool IsEntered(object obj);
Приклад використання:
object lockObj = new();
void AssertLockHeld()
{
if (!Monitor.IsEntered(lockObj))
throw new InvalidOperationException("Lock must be held!");
}
void ProcessData()
{
lock (lockObj)
{
AssertLockHeld(); // ✅ Пройде
// Робота...
}
}
Коли використовувати:
IsEntered працює тільки для поточного потоку. Неможливо перевірити, чи інший потік тримає монітор — це порушило б інкапсуляцію синхронізації.До цього моменту ми розглядали тільки взаємне виключення (mutual exclusion). Але часто потрібна координація — один потік чекає, поки інший виконає певну умову. Для цього Monitor надає механізм condition variables через методи Wait і Pulse.
Сигнатури:
public static bool Wait(object obj);
public static bool Wait(object obj, int millisecondsTimeout);
public static bool Wait(object obj, TimeSpan timeout);
public static void Pulse(object obj);
public static void PulseAll(object obj);
Як це працює:
Wait(obj): звільняє монітор на obj, блокує поточний потік і додає його у wait queue. Коли інший потік викличе Pulse(obj), потік прокинеться і знову захопить монітор.Pulse(obj): будить один потік з wait queue (якщо є).PulseAll(obj): будить всі потоки з wait queue.Важливо: Wait, Pulse і PulseAll можна викликати тільки всередині критичної секції (коли поточний потік тримає монітор). Інакше буде виняток SynchronizationLockException.
Класичний приклад — Producer-Consumer (Виробник-Споживач):
class BoundedQueue<T>
{
private readonly Queue<T> _queue = new();
private readonly int _maxSize;
private readonly object _lock = new();
public BoundedQueue(int maxSize) => _maxSize = maxSize;
public void Enqueue(T item)
{
lock (_lock)
{
// Чекаємо, поки черга не звільниться
while (_queue.Count >= _maxSize)
{
Monitor.Wait(_lock); // Звільняємо lock і чекаємо
}
_queue.Enqueue(item);
Monitor.Pulse(_lock); // Будимо один потік-споживач
}
}
public T Dequeue()
{
lock (_lock)
{
// Чекаємо, поки з'явиться елемент
while (_queue.Count == 0)
{
Monitor.Wait(_lock); // Звільняємо lock і чекаємо
}
T item = _queue.Dequeue();
Monitor.Pulse(_lock); // Будимо один потік-виробник
return item;
}
}
}
Чому while, а не if? Це критично важливий паттерн, що називається wait loop або guarded wait. Причини:
Pulse (рідко, але можливо на деяких платформах)Pulse і пробудженнямВізуалізація роботи Producer-Consumer:
Демонстрація виконання:
Pulse vs PulseAll — Коли Що Використовувати:
| Метод | Коли використовувати | Приклад |
|---|---|---|
Pulse | Одна умова, один потік може продовжити | Producer-Consumer (один елемент → один споживач) |
PulseAll | Кілька умов або всі потоки мають перевірити умову | Broadcast-сигнал (наприклад, "дані оновлено") |
Pulse ефективніший, бо будить тільки один потік. PulseAll будить всі потоки, але тільки один захопить монітор — решта знову заблокуються (це називається thundering herd).Приклад з PulseAll — Broadcast Signal:
class ConfigurationManager
{
private readonly object _lock = new();
private Dictionary<string, string> _config = new();
private int _version = 0;
public void UpdateConfig(Dictionary<string, string> newConfig)
{
lock (_lock)
{
_config = newConfig;
_version++;
Monitor.PulseAll(_lock); // Будимо ВСІ потоки, що чекають
}
}
public Dictionary<string, string> WaitForVersion(int minVersion)
{
lock (_lock)
{
while (_version < minVersion)
{
Monitor.Wait(_lock); // Чекаємо оновлення
}
return new Dictionary<string, string>(_config);
}
}
}
У цьому прикладі кілька потоків можуть чекати оновлення конфігурації. Коли конфігурація оновлюється, PulseAll будить всі потоки, щоб кожен міг перевірити, чи досягнута потрібна версія.
Тепер, коли ми глибоко розуміємо Monitor, розглянемо lock — ключове слово C#, що робить синхронізацію зручнішою та безпечнішою.
lock — це синтаксичний цукор (syntactic sugar), що компілятор перетворює у виклики Monitor.Enter і Monitor.Exit з правильною обробкою винятків. Це найпоширеніший спосіб синхронізації у .NET коді.
Базовий синтаксис:
object lockObj = new();
lock (lockObj)
{
// Критична секція
}
Що генерує компілятор (спрощено):
object lockObj = new();
bool lockTaken = false;
try
{
Monitor.Enter(lockObj, ref lockTaken);
// Критична секція
}
finally
{
if (lockTaken)
Monitor.Exit(lockObj);
}
Переваги lock над ручним Monitor.Enter/Exit:
Exit — компілятор гарантує finally блокlockTaken паттернclass BankAccount
{
private decimal _balance;
private readonly object _lock = new();
public BankAccount(decimal initialBalance)
{
_balance = initialBalance;
}
public void Deposit(decimal amount)
{
if (amount <= 0)
throw new ArgumentException("Amount must be positive", nameof(amount));
lock (_lock)
{
_balance += amount;
Console.WriteLine($"Deposited {amount:C}, new balance: {_balance:C}");
}
}
public bool Withdraw(decimal amount)
{
if (amount <= 0)
throw new ArgumentException("Amount must be positive", nameof(amount));
lock (_lock)
{
if (_balance >= amount)
{
_balance -= amount;
Console.WriteLine($"Withdrew {amount:C}, new balance: {_balance:C}");
return true;
}
else
{
Console.WriteLine($"Insufficient funds for {amount:C}");
return false;
}
}
}
public decimal GetBalance()
{
lock (_lock)
{
return _balance;
}
}
}
Демонстрація багатопотокового доступу:
var account = new BankAccount(1000m);
// 10 потоків одночасно роблять операції
var tasks = new List<Task>();
for (int i = 0; i < 5; i++)
{
tasks.Add(Task.Run(() => account.Deposit(100m)));
tasks.Add(Task.Run(() => account.Withdraw(50m)));
}
await Task.WhenAll(tasks);
Console.WriteLine($"Final balance: {account.GetBalance():C}");
// Завжди коректний результат: 1000 + (5 × 100) - (5 × 50) = 1250
Не всі об'єкти підходять для використання у lock. Ось правила:
✅ Правильно
private readonly object _lock = new(); — найкращий варіантprivate static readonly object _staticLock = new(); — для статичних членівthis — тільки якщо клас sealed і ви контролюєте весь код
::❌ Неправильно
lock(this) — зовнішній код може захопити монітор на вашому об'єктіlock(typeof(MyClass)) — глобальний lock, блокує всі екземпляриlock("string") — рядки інтерновані, різні частини коду можуть заблокувати одне одногоlock(value type) — компілятор заборонить (boxing створює новий об'єкт)
::Чому lock(this) небезпечний:
class Counter
{
private int _value;
public void Increment()
{
lock (this) // ⚠️ Небезпечно!
{
_value++;
}
}
}
// Зовнішній код може заблокувати ваш об'єкт:
var counter = new Counter();
lock (counter) // Захопили монітор ззовні
{
Thread.Sleep(10000); // Тримаємо 10 секунд
// counter.Increment() всередині заблокується!
}
Правильний підхід:
class Counter
{
private int _value;
private readonly object _lock = new(); // ✅ Приватний lock object
public void Increment()
{
lock (_lock)
{
_value++;
}
}
}
// Зовнішній код не має доступу до _lock — безпечно!
Granularity (гранулярність) — це розмір критичної секції. Вибір правильної гранулярності критичний для продуктивності.
Грубий lock (Coarse-grained):
class ShoppingCart
{
private readonly List<Item> _items = new();
private readonly object _lock = new();
public void AddItem(Item item)
{
lock (_lock) // Один lock для всього
{
_items.Add(item);
}
}
public void RemoveItem(Item item)
{
lock (_lock) // Той самий lock
{
_items.Remove(item);
}
}
public decimal GetTotal()
{
lock (_lock) // Той самий lock
{
return _items.Sum(i => i.Price);
}
}
}
Переваги: простий, важко помилитись
Недоліки: низька паралельність — тільки одна операція одночасно
Тонкий lock (Fine-grained):
class ShoppingCart
{
private readonly Dictionary<int, Item> _items = new();
private readonly ConcurrentDictionary<int, object> _itemLocks = new();
public void UpdateItem(int id, Item newItem)
{
var itemLock = _itemLocks.GetOrAdd(id, _ => new object());
lock (itemLock) // Lock тільки для конкретного item
{
_items[id] = newItem;
}
}
}
Переваги: висока паралельність — різні items можна змінювати одночасно
Недоліки: складніший код, ризик deadlock
lock можна комбінувати з Monitor.Wait і Monitor.Pulse для складніших сценаріїв синхронізації:
class TaskQueue
{
private readonly Queue<Action> _tasks = new();
private readonly object _lock = new();
private bool _shutdown = false;
public void Enqueue(Action task)
{
lock (_lock)
{
if (_shutdown)
throw new InvalidOperationException("Queue is shut down");
_tasks.Enqueue(task);
Monitor.Pulse(_lock); // Будимо worker thread
}
}
public Action? Dequeue(int timeoutMs)
{
lock (_lock)
{
var deadline = DateTime.UtcNow.AddMilliseconds(timeoutMs);
while (_tasks.Count == 0 && !_shutdown)
{
var remaining = deadline - DateTime.UtcNow;
if (remaining <= TimeSpan.Zero)
return null; // Timeout
Monitor.Wait(_lock, remaining);
}
return _shutdown ? null : _tasks.Dequeue();
}
}
public void Shutdown()
{
lock (_lock)
{
_shutdown = true;
Monitor.PulseAll(_lock); // Будимо всі worker threads
}
}
}
Візуалізація роботи TaskQueue:
| Name | Type | Value |
|---|---|---|
| ◢_tasks.Count | int | 3 |
| ◢_shutdown | bool | false |
| ◢Waiting Threads | int | 2 |
У .NET 9 (C# 13) з'явився новий тип System.Threading.Lock — сучасна альтернатива традиційному lock(object). Це не просто синтаксичний цукор, а новий примітив синхронізації з покращеною продуктивністю та семантикою.
System.Threading.Lock — це value type (структура), що інкапсулює механізм блокування. На відміну від lock(object), де використовується будь-який reference type, Lock — це спеціалізований тип з оптимізованою реалізацією.
Оголошення:
namespace System.Threading;
public struct Lock
{
public Scope EnterScope();
// Інші члени...
public ref struct Scope
{
public void Dispose();
}
}
Базове використання:
using System.Threading;
class Counter
{
private int _value;
private Lock _lock; // Не потрібен new()!
public void Increment()
{
using (_lock.EnterScope())
{
_value++;
}
}
public int GetValue()
{
using (_lock.EnterScope())
{
return _value;
}
}
}
Переваги:
EnterScope() — зрозуміліше, що відбуваєтьсяlock(this))Обмеження:
Переваги:
Недоліки:
lock(this) та інших помилокЦе навмисне архітектурне рішення. Reentrancy часто приховує проблеми дизайну:
// Небезпечний код з reentrancy (старий lock):
class BankAccount
{
private decimal _balance;
private readonly object _lock = new();
public void Transfer(BankAccount target, decimal amount)
{
lock (_lock)
{
_balance -= amount;
target.Deposit(amount); // Може викликати callback, що знову захопить _lock
}
}
public void Deposit(decimal amount)
{
lock (_lock) // Reentrancy дозволяє, але це маскує проблему
{
_balance += amount;
}
}
}
З System.Threading.Lock такий код не скомпілюється або викине виняток у runtime, змушуючи переписати архітектуру:
// Правильний дизайн без reentrancy:
class BankAccount
{
private decimal _balance;
private Lock _lock;
public void Transfer(BankAccount target, decimal amount)
{
using (_lock.EnterScope())
{
_balance -= amount;
}
// Lock звільнено перед викликом зовнішнього методу
target.DepositExternal(amount);
}
public void DepositExternal(decimal amount)
{
using (_lock.EnterScope())
{
_balance += amount;
}
}
}
System.Threading.Lock заохочує явну синхронізацію та мінімальні критичні секції. Це зменшує ризик deadlock та покращує продуктивність.Benchmark на 1,000,000 операцій інкременту з 8 потоками:
Чому швидше:
До (старий код):
class ThreadSafeList<T>
{
private readonly List<T> _items = new();
private readonly object _lock = new();
public void Add(T item)
{
lock (_lock)
{
_items.Add(item);
}
}
public T? Find(Predicate<T> predicate)
{
lock (_lock)
{
return _items.Find(predicate);
}
}
}
Після (новий код):
class ThreadSafeList<T>
{
private readonly List<T> _items = new();
private Lock _lock; // Змінили тип
public void Add(T item)
{
using (_lock.EnterScope()) // Змінили синтаксис
{
_items.Add(item);
}
}
public T? Find(Predicate<T> predicate)
{
using (_lock.EnterScope())
{
return _items.Find(predicate);
}
}
}
Кроки міграції:
object _lock = new() на Lock _locklock (_lock) { } на using (_lock.EnterScope()) { }Monitor.Wait/Pulse, міграція складніша — System.Threading.Lock не підтримує condition variables напряму. Використовуйте SemaphoreSlim або ManualResetEventSlim замість цього.Тепер застосуємо всі вивчені концепції у реальному прикладі — побудуємо thread-safe кеш з автоматичним видаленням застарілих записів. Це типова задача у веб-додатках, мікросервісах та high-performance системах.
Наш кеш має підтримувати:
Get, Set, Remove можуть викликатись з багатьох потоків┌─────────────────────────────────────────────────────────┐
│ MemoryCache<TKey, TValue> │
├─────────────────────────────────────────────────────────┤
│ Private Fields: │
│ • Dictionary<TKey, CacheEntry<TValue>> _cache │
│ • Lock _lock (System.Threading.Lock) │
│ • Thread _cleanupThread │
│ • volatile bool _shutdown │
│ • CacheStatistics _stats │
├─────────────────────────────────────────────────────────┤
│ Public API: │
│ • bool TryGet(TKey key, out TValue value) │
│ • void Set(TKey key, TValue value, TimeSpan ttl) │
│ • bool Remove(TKey key) │
│ • CacheStatistics GetStatistics() │
│ • void Dispose() │
└─────────────────────────────────────────────────────────┘
using System;
using System.Collections.Generic;
using System.Threading;
/// <summary>
/// Запис у кеші з метаданими про час створення та TTL.
/// </summary>
record CacheEntry<TValue>(
TValue Value,
DateTime CreatedAt,
TimeSpan TimeToLive)
{
public DateTime ExpiresAt => CreatedAt + TimeToLive;
public bool IsExpired => DateTime.UtcNow >= ExpiresAt;
}
/// <summary>
/// Статистика роботи кешу для моніторингу.
/// </summary>
class CacheStatistics
{
private long _hits;
private long _misses;
private long _evictions;
public long Hits => Interlocked.Read(ref _hits);
public long Misses => Interlocked.Read(ref _misses);
public long Evictions => Interlocked.Read(ref _evictions);
public double HitRate => Hits + Misses == 0 ? 0 : (double)Hits / (Hits + Misses);
internal void RecordHit() => Interlocked.Increment(ref _hits);
internal void RecordMiss() => Interlocked.Increment(ref _misses);
internal void RecordEviction() => Interlocked.Increment(ref _evictions);
public override string ToString() =>
$"Hits: {Hits}, Misses: {Misses}, Evictions: {Evictions}, Hit Rate: {HitRate:P2}";
}
/// <summary>
/// Thread-safe in-memory кеш з автоматичним видаленням застарілих записів.
/// </summary>
class MemoryCache<TKey, TValue> : IDisposable where TKey : notnull
{
private readonly Dictionary<TKey, CacheEntry<TValue>> _cache = new();
private Lock _lock; // System.Threading.Lock (.NET 9)
private readonly Thread _cleanupThread;
private volatile bool _shutdown;
private readonly TimeSpan _cleanupInterval;
private readonly CacheStatistics _stats = new();
public MemoryCache(TimeSpan? cleanupInterval = null)
{
_cleanupInterval = cleanupInterval ?? TimeSpan.FromSeconds(10);
// Запускаємо background thread для cleanup
_cleanupThread = new Thread(CleanupLoop)
{
IsBackground = true,
Name = "CacheCleanup"
};
_cleanupThread.Start();
}
/// <summary>
/// Спроба отримати значення з кешу.
/// </summary>
public bool TryGet(TKey key, out TValue? value)
{
using (_lock.EnterScope())
{
if (_cache.TryGetValue(key, out var entry))
{
if (!entry.IsExpired)
{
value = entry.Value;
_stats.RecordHit();
return true;
}
else
{
// Запис застарів — видаляємо одразу
_cache.Remove(key);
_stats.RecordEviction();
}
}
value = default;
_stats.RecordMiss();
return false;
}
}
/// <summary>
/// Додає або оновлює запис у кеші.
/// </summary>
public void Set(TKey key, TValue value, TimeSpan timeToLive)
{
if (timeToLive <= TimeSpan.Zero)
throw new ArgumentException("TTL must be positive", nameof(timeToLive));
var entry = new CacheEntry<TValue>(value, DateTime.UtcNow, timeToLive);
using (_lock.EnterScope())
{
_cache[key] = entry;
}
}
/// <summary>
/// Видаляє запис з кешу.
/// </summary>
public bool Remove(TKey key)
{
using (_lock.EnterScope())
{
return _cache.Remove(key);
}
}
/// <summary>
/// Повертає поточну статистику кешу.
/// </summary>
public CacheStatistics GetStatistics() => _stats;
/// <summary>
/// Повертає кількість записів у кеші (включно з застарілими).
/// </summary>
public int Count
{
get
{
using (_lock.EnterScope())
{
return _cache.Count;
}
}
}
/// <summary>
/// Background loop для періодичного видалення застарілих записів.
/// </summary>
private void CleanupLoop()
{
while (!_shutdown)
{
Thread.Sleep(_cleanupInterval);
if (_shutdown)
break;
PerformCleanup();
}
}
/// <summary>
/// Видаляє всі застарілі записи з кешу.
/// </summary>
private void PerformCleanup()
{
var keysToRemove = new List<TKey>();
// Фаза 1: Знаходимо застарілі ключі (під lock)
using (_lock.EnterScope())
{
foreach (var kvp in _cache)
{
if (kvp.Value.IsExpired)
{
keysToRemove.Add(kvp.Key);
}
}
// Фаза 2: Видаляємо (все ще під lock)
foreach (var key in keysToRemove)
{
_cache.Remove(key);
_stats.RecordEviction();
}
}
if (keysToRemove.Count > 0)
{
Console.WriteLine($"[Cleanup] Removed {keysToRemove.Count} expired entries");
}
}
/// <summary>
/// Коректно зупиняє cleanup thread і звільняє ресурси.
/// </summary>
public void Dispose()
{
if (_shutdown)
return;
_shutdown = true;
_cleanupThread.Join(timeout: TimeSpan.FromSeconds(5));
using (_lock.EnterScope())
{
_cache.Clear();
}
}
}
class Program
{
static void Main()
{
using var cache = new MemoryCache<string, string>(
cleanupInterval: TimeSpan.FromSeconds(2)
);
Console.WriteLine("=== Thread-Safe Cache Demo ===\n");
// Симулюємо багатопотоковий доступ
var tasks = new List<Task>();
// Producer threads: додають записи з різними TTL
for (int i = 0; i < 3; i++)
{
int threadId = i;
tasks.Add(Task.Run(() => ProducerThread(cache, threadId)));
}
// Consumer threads: читають записи
for (int i = 0; i < 5; i++)
{
int threadId = i;
tasks.Add(Task.Run(() => ConsumerThread(cache, threadId)));
}
// Моніторинг статистики
tasks.Add(Task.Run(() => MonitoringThread(cache)));
Task.WaitAll(tasks.ToArray());
Console.WriteLine("\n=== Final Statistics ===");
Console.WriteLine(cache.GetStatistics());
}
static void ProducerThread(MemoryCache<string, string> cache, int threadId)
{
var random = new Random(threadId);
for (int i = 0; i < 10; i++)
{
var key = $"key-{random.Next(1, 20)}";
var value = $"value-from-thread-{threadId}-{i}";
var ttl = TimeSpan.FromSeconds(random.Next(3, 8));
cache.Set(key, value, ttl);
Console.WriteLine($"[Producer {threadId}] Set {key} (TTL: {ttl.TotalSeconds}s)");
Thread.Sleep(random.Next(100, 500));
}
}
static void ConsumerThread(MemoryCache<string, string> cache, int threadId)
{
var random = new Random(threadId + 100);
for (int i = 0; i < 15; i++)
{
var key = $"key-{random.Next(1, 20)}";
if (cache.TryGet(key, out var value))
{
Console.WriteLine($"[Consumer {threadId}] Hit: {key} = {value}");
}
else
{
Console.WriteLine($"[Consumer {threadId}] Miss: {key}");
}
Thread.Sleep(random.Next(200, 600));
}
}
static void MonitoringThread(MemoryCache<string, string> cache)
{
for (int i = 0; i < 10; i++)
{
Thread.Sleep(1000);
var stats = cache.GetStatistics();
Console.WriteLine($"\n[Monitor] Count: {cache.Count}, {stats}\n");
}
}
}
1. Вибір System.Threading.Lock
Ми використали System.Threading.Lock замість lock(object) з кількох причин:
2. Мінімальні Критичні Секції
Зверніть увагу, що cleanup виконує обидві фази (пошук + видалення) під одним lock. Альтернативний підхід — зробити snapshot ключів, звільнити lock, потім знову захопити для видалення. Але це ускладнює код і може призвести до race conditions.
3. Volatile для Shutdown Flag
private volatile bool _shutdown;
volatile гарантує, що зміни _shutdown одразу видимі cleanup thread без додаткової синхронізації. Це важливо для graceful shutdown.
4. Interlocked для Статистики
internal void RecordHit() => Interlocked.Increment(ref _hits);
Лічильники статистики оновлюються через Interlocked замість lock — це набагато швидше для простих операцій інкременту.
5. Background Thread vs Timer
Ми використали Thread замість System.Threading.Timer для наочності. У production коді краще використовувати Timer або PeriodicTimer (.NET 6+):
private readonly PeriodicTimer _cleanupTimer;
public MemoryCache(TimeSpan? cleanupInterval = null)
{
_cleanupTimer = new PeriodicTimer(cleanupInterval ?? TimeSpan.FromSeconds(10));
_ = Task.Run(CleanupLoopAsync);
}
private async Task CleanupLoopAsync()
{
while (await _cleanupTimer.WaitForNextTickAsync() && !_shutdown)
{
PerformCleanup();
}
}
Цей приклад демонструє фундаментальні концепції, але у production системі можна додати:
ValueTask<T> GetAsync(TKey key) для I/O-bound операційПриклад LRU eviction:
class LruCache<TKey, TValue> where TKey : notnull
{
private readonly Dictionary<TKey, LinkedListNode<CacheEntry<TKey, TValue>>> _cache = new();
private readonly LinkedList<CacheEntry<TKey, TValue>> _lruList = new();
private readonly int _maxSize;
private Lock _lock;
record CacheEntry<TK, TV>(TK Key, TV Value);
public LruCache(int maxSize) => _maxSize = maxSize;
public bool TryGet(TKey key, out TValue? value)
{
using (_lock.EnterScope())
{
if (_cache.TryGetValue(key, out var node))
{
// Переміщуємо у кінець (найбільш нещодавно використаний)
_lruList.Remove(node);
_lruList.AddLast(node);
value = node.Value.Value;
return true;
}
value = default;
return false;
}
}
public void Set(TKey key, TValue value)
{
using (_lock.EnterScope())
{
if (_cache.TryGetValue(key, out var existingNode))
{
// Оновлюємо існуючий
_lruList.Remove(existingNode);
existingNode.Value = new CacheEntry<TKey, TValue>(key, value);
_lruList.AddLast(existingNode);
}
else
{
// Додаємо новий
if (_cache.Count >= _maxSize)
{
// Видаляємо найстаріший (перший у списку)
var oldest = _lruList.First!;
_lruList.RemoveFirst();
_cache.Remove(oldest.Value.Key);
}
var newNode = _lruList.AddLast(new CacheEntry<TKey, TValue>(key, value));
_cache[key] = newNode;
}
}
}
}
У цій статті ми пройшли повний шлях від фундаментальної проблеми багатопотоковості до сучасних примітивів синхронізації:
🧠 Проблема
🔒 Monitor — Фундамент
⚙️ Monitor API
Enter/Exit — базові операціїTryEnter — неблокуючий захват з timeoutIsEntered — перевірка власностіWait/Pulse — condition variables для координації
::🔐 lock Statement
⚡ System.Threading.Lock
EnterScope() для явної семантики
::🎯 Реальний Приклад
| Сценарій | Рекомендація |
|---|---|
| Новий код (.NET 9+) | System.Threading.Lock |
| Потрібен reentrancy | lock(object) або Monitor |
| Потрібен Wait/Pulse | Monitor напряму |
| Timeout при захопленні | Monitor.TryEnter |
| Legacy код (.NET < 9) | lock(object) |
| High-performance hot path | System.Threading.Lock |
Ми розглянули базові примітиви синхронізації. У наступних статтях:
ReaderWriterLockSlim для оптимізації read-heavy сценаріївInterlocked, Volatile, ConcurrentDictionarySemaphoreSlim.WaitAsync, AsyncLocklock), профілюйте, оптимізуйте тільки якщо є проблема. Передчасна оптимізація — корінь усього зла.Завдання 1.1: Thread-Safe Counter
Реалізуйте клас ThreadSafeCounter з методами Increment(), Decrement(), GetValue(). Протестуйте з 10 потоками, кожен робить 100,000 операцій. Результат має бути передбачуваним.
Завдання 1.2: Bank Account Transfer
Створіть клас BankAccount з методом Transfer(BankAccount target, decimal amount). Переконайтесь, що сума на двох рахунках завжди коректна після багатопотокових трансферів.
Завдання 2.1: Bounded Blocking Queue
Реалізуйте BoundedBlockingQueue<T> з використанням Monitor.Wait/Pulse:
Enqueue(T item) — блокується, якщо черга повнаDequeue() — блокується, якщо черга порожняЗавдання 2.2: Rate Limiter
Створіть RateLimiter що дозволяє максимум N операцій за T часу:
var limiter = new RateLimiter(maxRequests: 10, perTimeSpan: TimeSpan.FromSeconds(1));
limiter.WaitForSlot(); // Блокується, якщо ліміт вичерпано
Завдання 3.1: Read-Write Cache
Розширте MemoryCache з прикладу:
GetOrAdd(TKey key, Func<TKey, TValue> factory)Завдання 3.2: Thread Pool
Реалізуйте простий thread pool:
class SimpleThreadPool : IDisposable
{
public SimpleThreadPool(int threadCount);
public void QueueWork(Action work);
public void Dispose();
}
Використайте Monitor.Wait/Pulse для координації між worker threads та черги завдань.
Автор: kostyl.dev
Дата оновлення: 31 березня 2026
Версія .NET: .NET 9 (C# 13)
Проблеми Спільного Стану — Memory Model та volatile
.NET Memory Model у деталях — acquire/release семантика, volatile та її обмеження, Interlocked як перший крок до безпечного коду, Thread.MemoryBarrier і наскрізний приклад Producer-Consumer без синхронізаційних примітивів.
Синхронізація — Наскрізний Приклад та Deadlock Detection
Практична частина теми 06 — Dining Philosophers Problem, Thread-Safe Repository з fine-grained лocking, налагодження Deadlock у production через ProcDump та VS Debugger. Завершений наскрізний проєкт від А до Я.