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 --Read разрешено--> E[Чтенее общей коллекции]
D --Write блокирует чтение/запись--> F[Запись общей коллекции]
Пока только чтения — доступ открывается всем; при попытке записи — все ждут, пока запись не завершится.
7. ReaderWriterLockSlim: тонкости и типичные ошибки
Lock Recursion (Зацикливание): По умолчанию ReaderWriterLockSlim не позволяет одному и тому же потоку повторно захватывать один и тот же тип блокировки. То есть, если поток уже держит read lock и попытается второй раз его получить — будет выброшено исключение. Аналогично с write lock. Однако, UpgradeableReadLock можно повысить до write lock изнутри.
Нельзя перепутать порядок: Захватите EnterUpgradeableReadLock() → внутри него EnterWriteLock(). Но не наоборот!
Исключения: Если вы не отпустите lock (например, не вызовете ExitWriteLock()), то другие потоки будут ждать вечно. Поэтому всегда используйте конструкции try { ... } finally { ... }.
Не держите долго: Не используйте длительные IO-операции внутри замка. Чем дольше поток держит блокировку, тем больше задержек для остальных. Старайтесь как можно быстрее выходить из lock-блоков.
Оцените сложность: Не используйте ReaderWriterLockSlim для защиты «мелких» операций, где обычный lock проще и быстрее.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ