1. Чому звичайного lock може бути недостатньо?
Давайте поміркуємо: у нас є спільний ресурс — наприклад, список клієнтів, який ми зберігаємо десь у нашому багатопотоковому застосунку. Більшість часу ми лише читаємо цей список — скажімо, показуємо дані в інтерфейсі користувача, будуємо звіти, фільтруємо й сортуємо інформацію. Лише зрідка хтось додає нового клієнта або видаляє старого.
Якщо ми використовуємо звичний lock, то будь-який потік, що хоче прочитати дані, змушений чекати, доки інший потік завершить читання або, що гірше, запис. А якщо на читання припадає близько 99 % усіх операцій? Виходить, ми даремно сповільнюємо всіх читачів, хоча вони могли б працювати паралельно.
Тут і стає в пригоді особливий тип блокування — Reader-Writer Lock (блокування для читачів і записувачів). Його ключова ідея: багато потоків можуть читати одночасно, але якщо хтось хоче щось змінити, він захоплює ексклюзивний доступ, блокуючи читачів.
2. Теорія: що таке ReaderWriterLockSlim
Коротко про класи
- ReaderWriterLock — більш старий, повільний, може призводити до взаємоблокувань (deadlock). Використовуйте лише якщо треба підтримувати .NET Framework 2.0.
- ReaderWriterLockSlim — швидка сучасна альтернатива (ключове слово — Slim: «стрункий», «полегшений»). Цей клас оптимізовано для частих конкуруючих операцій читання/запису й рекомендують до використання в застосунках, де потоків багато, а читають дані значно частіше, ніж пишуть.
Принцип роботи
ReaderWriterLockSlim реалізує три режими блокування:
- Read Lock (блокування на читання): багато потоків можуть читати одночасно.
- Write Lock (блокування на запис): лише один потік може змінювати дані, і жоден інший не читає.
- Upgradeable Read Lock (підвищуване блокування на читання): лише один потік може одночасно тримати таке блокування; він читає, але за потреби може «підвищитися» до write lock.
Схема блокування
| Потік-читач | Потік-записувач | Потік з Upgradeable Read Lock | |
|---|---|---|---|
| Читач | ✅ | ⛔ | ✅/⛔ |
| Записувач | ⛔ | ⛔ | ⛔ |
| Upgradeable Read | ✅* | ⛔ | ⛔ |
✅ — дозволено
⛔ — блокується
* — лише якщо upgradeable read lock ще не було «підвищено» до write
Коли варто (і не варто) використовувати ReaderWriterLockSlim
Рекомендується:
- Багато одночасних потоків, які здебільшого читають спільні дані (довідники, кеш, конфігурації).
- Запис відбувається рідше (наприклад, раз на секунду/хвилину/годину).
- Важливо забезпечити максимальну продуктивність під час читання, не заважаючи потокам.
Не варто:
- Запис і читання відбуваються у близьких пропорціях (краще звичайний lock).
- Дані часто змінюються (виграш невеликий).
3. Використання ReaderWriterLockSlim на практиці
Розширімо наш навчальний застосунок. Раніше ми створили простий довідник клієнтів і спробували багатопотоковий доступ до колекції. Тепер ускладнімо задачу. Припустімо, у нас є список клієнтів, до якого звертаються кілька потоків: один сервіс сповіщає всіх клієнтів про нову акцію, а інший потік додає нових клієнтів.
Спершу опишемо нашу колекцію клієнтів:
// Клас клієнта — знайомий герой із попередніх лекцій
public class Client
{
public int Id { get; set; }
public string Name { get; set; }
}
Тепер створимо обгортку для потокобезпечного доступу через ReaderWriterLockSlim:
using System;
using System.Collections.Generic;
using System.Threading;
public class ClientDirectory
{
private readonly List<Client> _clients = new List<Client>();
// Slim-lock для читання/запису
private readonly ReaderWriterLockSlim _lock = new ReaderWriterLockSlim();
// Додавання клієнта (write lock)
public void AddClient(Client client)
{
_lock.EnterWriteLock(); // Лише один потік може додати клієнта
try
{
_clients.Add(client);
Console.WriteLine($"[Потік {Thread.CurrentThread.ManagedThreadId}] Додано клієнта {client.Name}");
}
finally
{
_lock.ExitWriteLock();
}
}
// Отримання копії списку клієнтів (read lock)
public List<Client> GetClients()
{
_lock.EnterReadLock(); // Багато потоків можуть читати одночасно
try
{
// Повертаємо копію, щоб ніхто випадково не спробував змінити оригінал
return new List<Client>(_clients);
}
finally
{
_lock.ExitReadLock();
}
}
}
4. Багатопотокове читання і періодичний запис
Припустімо, є 5 потоків, які періодично читають усіх клієнтів (будують звіти або просто цікавляться, хто в нас є), і окремий потік, який інколи додає нового клієнта.
using System.Threading;
class Program
{
static void Main()
{
var directory = new ClientDirectory();
// Потік для додавання клієнтів
var writerThread = new Thread(() =>
{
for (int i = 1; i <= 5; i++)
{
directory.AddClient(new Client { Id = i, Name = $"Клієнт {i}" });
Thread.Sleep(700); // Імітація тривалих операцій
}
});
// Кілька читачів
for (int j = 0; j < 5; j++)
{
int readerId = j + 1;
new Thread(() =>
{
for (int k = 0; k < 10; k++)
{
var clients = directory.GetClients();
Console.WriteLine($"[Читач {readerId}][Потік {Thread.CurrentThread.ManagedThreadId}] Клієнтів усього: {clients.Count}");
Thread.Sleep(200); // Читаємо частіше, ніж пишемо
}
}).Start();
}
writerThread.Start();
writerThread.Join();
// Даємо читачам завершити цикл:
Thread.Sleep(3000);
}
}
Майже завжди кілька потоків можуть читати список клієнтів одночасно (без очікування одне одного), але коли потік додає клієнта — інші потоки мають дочекатися, доки операція запису завершиться. Це дозволяє обслуговувати багато запитів без зайвого блокування читачів.
5. Підвищуване блокування: UpgradeableReadLock
Інколи буває так: потік читає дані й лише за умови певної перевірки хоче їх змінити. Здавалося б, достатньо отримати read lock, перевірити умову, потім вийти з read lock і отримати write lock. Але за цей час інший потік уже міг змінити дані. На допомогу приходить підвищуване блокування.
Користуватися ним просто:
public bool AddClientIfNotExists(string name)
{
_lock.EnterUpgradeableReadLock(); // Лише один потік може тримати таке блокування
try
{
bool exists = _clients.Exists(c => c.Name == name);
if (!exists)
{
_lock.EnterWriteLock(); // Підвищуємо до write lock
try
{
_clients.Add(new Client { Name = name });
return true;
}
finally
{
_lock.ExitWriteLock();
}
}
return false;
}
finally
{
_lock.ExitUpgradeableReadLock();
}
}
Спочатку потік читає, а якщо потрібно змінити — підвищує рівень блокування, не відпускаючи список іншим потокам.
6. Корисні нюанси
Порівняння: ReaderWriterLockSlim vs lock
|
|
|
|---|---|---|
| Одночасне читання | Ні | Так |
| Одночасний запис | Ні | Ні |
| Підтримка підвищуваного читання | Ні | Так (EnterUpgradeableReadLock) |
| Швидкість | Дуже висока | Вища за частих читань |
| Памʼять | Мінімум | Трохи більше |
| Простота | Легко | Складніше, є нюанси |
Як це виглядає в реальних проєктах
У великих застосунках, де є великі таблиці або кеш даних, які читаються сотнями потоків, але змінюються рідко, використання ReaderWriterLockSlim — спосіб уникнути «вузького місця» на читанні. Наприклад:
- Кеш конфігурації, з яким працюють мікросервіси.
- Сховище довідників для фінансових розрахунків.
- Внутрішній кеш для маршрутизації повідомлень.
Підкреслимо: у більшості випадків, особливо якщо ви лише починаєте з багатопоточністю, звичайний lock простіший і безпечніший. ReaderWriterLockSlim — це рішення для тих задач, де справді є часте одночасне читання.
Візуалізація: схема доступу
flowchart TD
A[Потік-читач 1] --Read--> D[ReaderWriterLockSlim]
B[Потік-читач 2] --Read--> D
C[Потік-записувач] --Write--> D
D --Читання дозволено--> E[Читання спільної колекції]
D --Запис блокує читання/запис--> F[Запис спільної колекції]
Поки лише читання — доступ відкритий для всіх; при спробі запису — усі чекають, доки запис не завершиться.
7. ReaderWriterLockSlim: тонкощі й типові помилки
Lock Recursion (зациклення): За замовчуванням ReaderWriterLockSlim не дозволяє одному й тому самому потоку повторно захоплювати той самий тип блокування. Тобто якщо потік уже тримає read lock і спробує вдруге його отримати — буде кинуто виняток. Аналогічно з write lock. Втім, UpgradeableReadLock можна підвищити до write lock ізсередини.
Не плутайте порядок: Спочатку EnterUpgradeableReadLock() → всередині нього EnterWriteLock(). Але не навпаки.
Винятки: Якщо ви не відпустите lock (наприклад, не викличете ExitWriteLock()), то інші потоки чекатимуть безкінечно. Тому завжди використовуйте конструкції try { ... } finally { ... }.
Не тримайте довго: Не виконуйте тривалі I/O-операції всередині блокування. Чим довше потік тримає блокування, тим більша затримка для решти. Намагайтеся якнайшвидше виходити з блоків lock.
Оцініть складність: Не використовуйте ReaderWriterLockSlim для захисту дрібних операцій, де звичайний lock простіший і швидший.
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ