JavaRush /Курси /C# SELF /Блокування: lock і кл...

Блокування: lock і клас Monitor

C# SELF
Рівень 56 , Лекція 1
Відкрита

1. Вступ

Розгляньмо знайому ситуацію з роботи з потоками. Припустімо, у вас є спільний лічильник успіхів у дуже простому застосунку.

int counter = 0;

void IncrementCounter()
{
    for (int i = 0; i < 100_000; i++)
    {
        counter++; // Неатомарно!
    }
}

// Запускаємо два потоки:
Thread t1 = new Thread(IncrementCounter);
Thread t2 = new Thread(IncrementCounter);

t1.Start();
t2.Start();

t1.Join();
t2.Join();

Console.WriteLine($"Лічильник: {counter}");

Запустіть цей код кілька разів. Майже ніколи ви не побачите 200_000! Чому? Два потоки постійно заважають одне одному: інколи обидва одночасно читають змінну, збільшують її — і записують той самий результат. У підсумку частина інкрементів «втрачається».

Це і є стан гонки, або «race condition». Без дотримання правил черги потоки буквально б’ються за дані.

Критична секція: що це таке?

Критична секція — це ділянка коду, яку має виконувати лише один потік одночасно. Повернімося до побутової аналогії: це як відкритий кран — якщо двоє намагаються вмиватися над однією раковиною, вода і зубна паста будуть усюди. Домовляємося: у ванну — по одному!

У нашому прикладі критична секція — це рядок counter++.

2. Ключове слово lock

У C# є лаконічний і безпечний спосіб створити критичну секцію — ключове слово lock. Воно приховує від вас складну роботу з примітивом синхронізації й стежить, щоб у захищений блок коду заходив лише один потік за раз.

Як користуватися lock

Синтаксис:

lock (lockerObject)
{
    // Код, який може виконувати лише один потік одночасно
}

lockerObject — це будь-який обʼєкт, який існує протягом усього життя програми. Зазвичай роблять так:

private static object locker = new object();

Зверніть увагу: ніколи не використовуйте для цієї мети рядки, числа або обʼєкти, до яких хтось іще може випадково дістатися! Використовуйте лише приватні обʼєкти, які ви гарантовано не застосовуєте деінде.

Виправмо наш приклад

private static object locker = new object();
int counter = 0;

void IncrementCounter()
{
    for (int i = 0; i < 100_000; i++)
    {
        lock (locker)
        {
            counter++; // Тепер це атомарно!
        }
    }
}

Тепер два чи десять потоків заходитимуть у цей фрагмент коду по черзі. Результат на виході — стабільні 200_000. Усе працює як очікується.

3. Як працює lock всередині? Клас Monitor

Під капотом ключове слово lock працює з класом System.Threading.Monitor. Це ніби секретар, який впускає лише за спецперепусткою.

Синтаксис, еквівалентний lock (але більш «роздягнений»):

Monitor.Enter(locker);
try
{
    // Критична секція
}
finally
{
    Monitor.Exit(locker);
}

Ключова відмінність — ви мусите самостійно гарантувати, що Monitor.Exit буде викликано. Зазвичай для цього й потрібен try...finally. Якщо забути виклик Exit(), потік залишиться «всередині» назавжди, а інші потоки висітимуть безкінечно — програма зависне, як старий Windows під час встановлення оновлень.

Таблиця: lock vs. ручний Monitor

Спосіб Захист від помилок Писати простіше Гнучкість
lock(obj)
Так Так Ні
Monitor
Лише при try/finally Ні Так

У 99 % випадків користуйтеся lock. Ручний Monitor знадобиться лише тоді, коли потрібна максимальна гнучкість: наприклад, якщо хочете зробити блокування з тайм-аутом.

4. Аргументи для lock: що можна, а що не можна?

Дуже часта помилка новачків — використовувати для блокування рядок або інший «видимий» обʼєкт. Наприклад:

lock ("mylock") { /*...*/ } // Дуже погано!

Проблема в тому, що рядки інтерновані (унікальні для всього застосунку), тож легко зіткнутися з конфліктом із чужими бібліотеками і в підсумку отримати «мертву» програму. Завжди використовуйте приватні обʼєкти:

private readonly object myLock = new object();

lock (myLock)
{
    // лише ваш код знає про myLock
}

5. lock: приклад із консольним виводом

Створімо мінізастосунок: два потоки друкують рядки, а доступ до консолі синхронізовано — щоб текст не перемішувався.

private static object consoleLock = new object();

void PrintMessages(string name)
{
    for (int i = 0; i < 5; i++)
    {
        lock (consoleLock)
        {
            Console.WriteLine($"{name}: Повідомлення {i + 1}");
            Thread.Sleep(50); // Моделюємо обробку
        }
    }
}

Thread t1 = new Thread(() => PrintMessages("Потік 1"));
Thread t2 = new Thread(() => PrintMessages("Потік 2"));

t1.Start();
t2.Start();

t1.Join();
t2.Join();

Результат: рядки йдуть акуратно один за одним, жодної мішанини. Такий підхід часто застосовують для логування, щоб не читати «каракулі» в логах.

6. Корисні нюанси

Ручне керування блокуванням: просунутий Monitor

Коли стандартного lock замало (наприклад, якщо хочете спробувати зайти в секцію, не чекаючи вічно), можна скористатися методом Monitor.TryEnter.

if (Monitor.TryEnter(locker, 100)) // 100 мс очікування
{
    try
    {
        // Критична секція
    }
    finally
    {
        Monitor.Exit(locker);
    }
}
else
{
    Console.WriteLine("Не вдалося отримати блокування за 100 мілісекунд");
}

Це зручно, якщо ваша програма не має «зависати»: можна показати користувачеві повідомлення або робити щось корисне, поки доступ до спільного ресурсу зайнятий.

Візуалізація: як працює блокування (схема)

flowchart LR
    A[Потік 1: хоче зайти в критичну секцію]
    B[Потік 2: хоче зайти в критичну секцію]
    C[locker вільний]
    D[Потік 1 виконує код всередині lock]
    E[Потік 2 чекає]
    F[Потік 1 вийшов з lock]
    G[Потік 2 отримує доступ]
    
    A -- Перевірка locker --> C
    C -- locker вільний --> D
    B -- Перевірка locker --> D
    D -- lock зайнятий --> E
    D -- Завершив роботу --> F
    F -- Звільнили locker --> G
    E -- locker тепер вільний --> G

Блокування і продуктивність

Блокування працюють просто: лише один потік за раз може виконувати фрагмент коду між фігурними дужками. Це чудово для цілісності даних, але що більше потоків «стоїть у черзі», то повільніше все працює. Тому синхронізація — не панацея: намагайтеся робити критичні секції якнайменшими.

Порада: якщо виконання критичної секції займає частки мілісекунди — усе добре. Якщо там довгий розрахунок, ввід/вивід, робота з мережею чи файлами — краще винести це за межі блока lock. Спершу зчитайте або порахуйте, а вже потім швидко оновіть спільне значення всередині захисту.

На співбесіді й у реальному житті

У будь-якій серйозній програмі, де використовують потоки, роботодавці часто запитують: «Що робити, якщо два потоки звертаються до тієї самої змінної?» Покажете код із блокуванням — і ваше резюме точно не зникне в чорній скриньці HR-автоматизації.

На практиці ж, особливо у високонавантажених системах, використовують і більш просунуті механізми синхронізації, але lock і Monitor залишаються золотим стандартом для простих випадків.

7. Особливості використання блокувань і типові помилки

Найчастіша помилка — «забути» використовувати один і той самий обʼєкт як замок. Наприклад:

void Foo() { lock (a) { ... } }
void Bar() { lock (b) { ... } }

Якщо в обох методах керуємо однією й тією самою змінною, але обʼєкти a та b різні, ви просто створили фіктивний захист — потоки працюватимуть зі змінною одночасно!

Висновок: завжди використовуйте один і той самий обʼєкт для захисту одних і тих самих даних.

Інший випадок — використання надто «широкого» блокування. Наприклад, робити lock (this) всередині звичайного класу, якщо ви не певні, що ніхто зовні не використовує цей обʼєкт для блокування. Це також загрожує взаємними блокуваннями та іншими неприємними багами.

І насамкінець: не блокуйте довгі або зовнішні операції (роботу з файлами, мережею) всередині lock. Ризикуєте «перекрити» доступ іншим потокам надовго, що зменшує продуктивність. Критична секція — це лише те, що справді не можна робити паралельно!

Коментарі
ЩОБ ПОДИВИТИСЯ ВСІ КОМЕНТАРІ АБО ЗАЛИШИТИ КОМЕНТАР,
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ