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 --Читання дозволено--> 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 простіший і швидший.

1
Опитування
Синхронізація потоків, рівень 56, лекція 4
Недоступний
Синхронізація потоків
Проблема спільних ресурсів
Коментарі
ЩОБ ПОДИВИТИСЯ ВСІ КОМЕНТАРІ АБО ЗАЛИШИТИ КОМЕНТАР,
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ