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: {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. Вы рискуете "перекрыть" доступ другим потокам на долгое время, что уменьшает производительность. Критическая секция = только то, что реально нельзя делать параллельно!

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