MonitorКонструкция lock (obj) { /* do something */ } в C# — синтаксический сахар для методов класса Monitor:
Monitor.Enter(obj);
try
{
// do something
}
finally
{
Monitor.Exit(obj);
}
Но класс Monitor — самодостаточный. Его использование напрямую, без конструкции lock позволяет реализовать несколько интересных механик.
Happy pathМетод Monitor.TryEnter позволяет зайти в критическую секцию, если она свободна от других потоков. Можно сделать разную логику в зависимости от того, есть ли конкуренция с другими потоками.
В библиотеке
Serilog.Sinks.RawConsole я использовал TryEnter, чтобы реализовать следующую логику — если конкуренции нет, то log event рендерится в текст/json под локом, сразу в общий буфер. Если не повезло и лок занят — рендеринг текста происходит в локальный для потока буфер, затем берётся лок и результат копируется в общий буфер.
В итоге, рендеринг лог-сообщений в текст/json выполняется параллельно, с дополнительным бонусом в виде отсутствия копирования, если конкуренции нет (повезло, или используется асинхронная обёртка Serilog.Sinks.Async, Serilog.Sinks.Background)
Также Monitor.TryEnter позволяет задать таймаут ожидания блокировки.
СигналыКроме блокировок, класс Monitor можно использовать для передачи сигналов между потоками.
Чтобы это сделать, вначале нужно захватить lock на объекте.
Затем, вызвав метод Monitor.Wait, можно заблокировать поток и освободить лок.
Другой поток может вызвать Pulse или PulseAll — тогда, при выходе из критической секции возобновится выполнение потоков, ждущих с помощью Wait. PulseAll сигнализирует все потоки, а Pulse — один (при этом, Pulse можно вызвать несколько раз чтобы сигнализировать нужное число потоков).
Пример с двумя потоками:
var obj = new object();
var t1 = new Thread(() =>
{
lock (obj)
{
Console.WriteLine("entered lock on thread 1");
Monitor.Wait(obj);
Console.WriteLine("got signal on thread 1");
}
});
var t2 = new Thread(() =>
{
Thread.Sleep(1000);
lock (obj)
{
Console.WriteLine("entered lock on thread 2");
Monitor.Pulse(obj);
Console.WriteLine("exiting lock on thread 2"); // ещё не разбудили первый поток, это случится лишь по выходу из лока.
}
});
t1.Start();
t2.Start();
t1.Join();
t2.Join();
Результат:
entered lock on thread 1
entered lock on thread 2
exiting lock on thread 2
got signal on thread 1
Пример с N потоками и M сигналами остаётся на самостоятельное изучение
Такой способ передачи сигналов я использовал в библиотеке
Serilog.Sinks.Background для того, чтобы сигнализировать background-поток о появлении новых лог-сообщений. Но, с дополнительными доработками:
- логирующие потоки не ждут на локе друг друга — они выбирают, какой пойдёт сигнализировать через Interlocked
- один log event не активирует background-поток — нужно, чтобы в очереди набралось некоторое их количество
- на случай, если логов мало — background-поток активируется по таймауту. В итоге при отладке и выводе логов в консоль — они появляются на экране почти мгновенно, в 60 ФПС (дальше зависит от того, как быстро рендерит терминал)
Однако, такой способ очень рискованный — если никакой поток не вызвал Monitor.Wait, то сигнал, отправленный через Monitor.Pulse будет потерян, что может приводить к дедлокам. Избежать этого помогают более высокоуровневые примитивы синхронизации, но об этом будет уже следующий пост.
МетрикиЧерез свойство Monitor.LockContentionCount можно узнать, сколько раз за время выполнения программы потоки останавливались на блокировках, реализованных через класс Monitor. SpinLock, например, в эту статистику не входит, да и вообще не проявляется в метриках, чем уступает обычным локам в плане поддерживаемости.
@epeshkblog | Поддержать канал█░░░░░░░░░░░░
28 / 300