Начиная с версии .NET 9 во фреймворк был добавлен новый API для синхронизации в виде класса System.Threading.Lock. Класс Lock служит для синхронизации доступа потоков к некоторой критической секции для предотвращения гонки за ресурсы между потоками.
Есть разные варианты применения Lock. Рассмотрим их.
С оператором lock
Самый простой способ использования Lock- с оператором lock:
int x = 0; // некоторый общий ресурс
Lock _lockObj = new(); // объект-заглушка для синхронизации доступа
// запускаем пять потоков
for (int i = 1; i < 6; i++)
{
Thread myThread = new(Print);
myThread.Name = $"Поток {i}";
myThread.Start();
}
void Print()
{
lock(_lockObj) // начало критической секции
{
x = 1;
for (int i = 1; i < 5; i++)
{
Console.WriteLine($"{Thread.CurrentThread.Name}: {x}");
x++;
Thread.Sleep(100);
}
} // завершение критической секции
}
Данный пример похож на обычную синхронизацию с помощью оператора lock, когда в качестве заглушки используется произвольный объект. Здесь мы сначала определяем некоторый общий ресурс, к которому надо синхронизировать доступ - переменную x, и объект Lock синхронизации. Для примера здесь запускаем 5 потоков, каждый из которых обращается к некоторой общей секции в
методе Print, где происходит доступ и изменение переменной x. Когда один поток захватывает объект _lockObj и входит критическую секцию, остальные потоки ждут освобождения _lockObj. После выхода из критической секции
поток освобождает объект _lockObj для других потоков. В итоге мы получим синхронизированный доступ потоков следующего рода:
Поток 1: 1
Поток 1: 2
Поток 1: 3
Поток 1: 4
Поток 1: 5
Поток 2: 1
Поток 2: 2
Поток 2: 3
Поток 2: 4
Поток 2: 5
Поток 4: 1
Поток 4: 2
Поток 4: 3
Поток 4: 4
Поток 4: 5
Поток 5: 1
Поток 5: 2
Поток 5: 3
Поток 5: 4
Поток 5: 5
Поток 3: 1
Поток 3: 2
Поток 3: 3
Поток 3: 4
Поток 3: 5
ПО консольному выводу мы видим, что у нас получился синхронизированный доступ к переменной x.
Метод Enter()
С помощью вызова метода Enter() у объекта Lock можно обозначить начало критической секции, в которую опять же может войти только один поток.
Остальные потоки опять же ожидают выхода этого потока из критической секции. Для выхода из этой секции (ее завершения) у объекта Lock вызывается метод Exit():
int x = 0; // некоторый общий ресурс
Lock _lockObj = new(); // объект-заглушка для синхронизации доступа
// запускаем пять потоков
for (int i = 1; i < 6; i++)
{
Thread myThread = new(Print);
myThread.Name = $"Поток {i}";
myThread.Start();
}
void Print()
{
_lockObj.Enter();; // начало критической секции
try
{
x = 1;
for (int i = 1; i < 6; i++)
{
Console.WriteLine($"{Thread.CurrentThread.Name}: {x}");
x++;
Thread.Sleep(100);
}
}
finally { _lockObj.Exit(); } // завершение критической секции
}
Стоит отметить, что выход из критической секции здесь осуществляется в блоке finally. Таким образом, даже в случае исключений мы гарантируем выход потока.
TryEnter()
Метод TryEnter() также осуществляет вход в критическую секцию. Этот метод возвращает булевое значение - true или false.
Если метод возвращает true, текущий поток является единственным потоком, который удерживает блокировку. Если метод возвращает false, то поток не ждет освобождения блокировки. Например:
int x = 0; // некоторый общий ресурс
Lock _lockObj = new(); // объект-заглушка для синхронизации доступа
// запускаем пять потоков
for (int i = 1; i < 6; i++)
{
Thread myThread = new(Print);
myThread.Name = $"Поток {i}";
myThread.Start();
}
void Print()
{
if (_lockObj.TryEnter()) // начало критической секции
{
try
{
x = 1;
for (int i = 1; i < 6; i++)
{
Console.WriteLine($"{Thread.CurrentThread.Name}: {x}");
x++;
Thread.Sleep(100);
}
}
finally { _lockObj.Exit(); } // завершение критической секции
}
}
Консольный вывод:
Поток 1: 1
Поток 1: 2
Поток 1: 3
Поток 1: 4
Поток 1: 5
Мы видим, что действия критической секции были выполнены только одним потоком. Потому что у нас потоки запускаются одновременно. Первый поток получил блокировку
(метод _lockObj.TryEnter() возвратил true) и этот поток стал выполнять действия из критической секции. Поскольку блокировка стала занята, для других потоков метод _lockObj.TryEnter() возвратил false,
и они не стали выполнять действия из критической секции и не стали ждать освобождения блокировки.
EnterScope()
Еще один способ демонстрирует метод EnterScope(), который является рекомендуемым способом (с точки зрения Microsoft). Он также осуществляет вход в критическую секцию. Если один поток уже захватил блокировку и вошел в эту секцию, другие потоки ждут освобождения блокировки. Данный метод прежде всего предназначен для использования в конструкции using, которая автоматически освобождает все связанные ресурсы:
int x = 0; // некоторый общий ресурс
Lock _lockObj = new(); // объект-заглушка для синхронизации доступа
// запускаем пять потоков
for (int i = 1; i < 6; i++)
{
Thread myThread = new(Print);
myThread.Name = $"Поток {i}";
myThread.Start();
}
void Print()
{
using (_lockObj.EnterScope()) // начало критической секции
{
x = 1;
for (int i = 1; i < 6; i++)
{
Console.WriteLine($"{Thread.CurrentThread.Name}: {x}");
x++;
Thread.Sleep(100);
}
} // завершение критической секции
}
Причем в данном случае не надо вызывать метод Exit(), выход из критической секции и освобождение блокировки происходит автоматически. Консольный вывод:
Поток 1: 1
Поток 1: 2
Поток 1: 3
Поток 1: 4
Поток 1: 5
Поток 5: 1
Поток 5: 2
Поток 5: 3
Поток 5: 4
Поток 5: 5
Поток 4: 1
Поток 4: 2
Поток 4: 3
Поток 4: 4
Поток 4: 5
Поток 3: 1
Поток 3: 2
Поток 3: 3
Поток 3: 4
Поток 3: 5
Поток 2: 1
Поток 2: 2
Поток 2: 3
Поток 2: 4
Поток 2: 5