JavaRush /Курсы /C# SELF /Оптимизации с ReaderWriter...

Оптимизации с ReaderWriterLockSlim

C# SELF
56 уровень , 4 лекция
Открыта

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

lock
ReaderWriterLockSlim
Одновременное чтение Нет Да
Одновременная запись Нет Нет
Поддержка повышаемого чтения Нет Да (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 проще и быстрее.

2
Задача
C# SELF, 56 уровень, 4 лекция
Недоступна
Использование ReaderWriterLockSlim для организации чтения
Использование ReaderWriterLockSlim для организации чтения
1
Опрос
Синхронизация потоков, 56 уровень, 4 лекция
Недоступен
Синхронизация потоков
Проблема общих ресурсов
Комментарии
ЧТОБЫ ПОСМОТРЕТЬ ВСЕ КОММЕНТАРИИ ИЛИ ОСТАВИТЬ КОММЕНТАРИЙ,
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ