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
| Способ | Безопасность от ошибок | Писать проще | Гибкость |
|---|---|---|---|
|
Да | Да | Нет |
|
Только при 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. Вы рискуете "перекрыть" доступ другим потокам на долгое время, что уменьшает производительность. Критическая секция = только то, что реально нельзя делать параллельно!
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ