Гонки и подтверждения

Допустим, мы хотим посигналить рабочему потоку пять раз подряд:

class Race { static object locker = new object(); static bool go;   static void Main() { new Thread(SaySomething).Start();   for (int i = 0; i < 5; i++) { lock (locker) { go = true; Monitor.Pulse(locker); } } }   static void SaySomething() { for (int i = 0; i < 5; i++) { lock (locker) { while (!go) Monitor.Wait(locker);   go = false; }   Console.WriteLine("Wassup?"); } } }

Ожидаемый вывод:

Wassup? Wassup? Wassup? Wassup? Wassup?

Реальный вывод:

Wassup? (зависание)

 

ПРИМЕЧАНИЕ При тестировании этого примера мы получили «ожидаемый результат», запуская программу, скомпилированную в debug-режиме (не из под отладчика), и «реальный результат» (то есть, зависание) в release-версии – прим.ред.

Эта программа с дефектом: цикл for в главном потоке может прокрутить все пять своих итераций в то время, когда рабочий поток не блокирован. Возможно, даже до того, как он вообще стартует! Наш пример с очередью Поставщик/Потребитель не страдал от этой проблемы, так как если главный поток забегал вперёд рабочего, каждый запрос просто ставился в очередь. Но в данном случае требуется блокировка главного потока на каждой итерации, если рабочий все еще занят предыдущей задачей.

В качестве простого решения можно в каждой итерации цикла for ожидать, пока флаг go не будет сброшен рабочим потоком. Рабочий поток после сброса флага должен вызвать Pulse:

class Acknowledged { static object locker = new object(); static bool go;   static void Main() { new Thread(SaySomething).Start();   for (int i = 0; i < 5; i++) { lock (locker) { go = true; Monitor.Pulse(locker); }   lock (locker) { while (go) Monitor.Wait(locker); } } }   static void SaySomething() { for (int i = 0; i < 5; i++) { lock (locker) { while (!go) Monitor.Wait(locker);   go = false; Monitor.Pulse(locker); // Надо посигналить }   Console.WriteLine("Wassup?"); } } }

Консольный вывод:

Wassup? Wassup? Wassup? Wassup? Wassup? (пять повторов)

Важная особенность такой программы заключается в том, что рабочий поток освобождает блокировку перед выполнением длительного задания (которое должно быть на месте вызова Console.WriteLine). Это гарантирует, что инициатор не будет заблокирован все время, пока рабочий поток исполняет задачу, о которой ему только что посигналили (и будет блокирован, только если рабочий поток занят предыдущей задачей).

В данном примере только один поток (главный) сигналит рабочему потоку о необходимости выполнить задачу. Если несколько потоков начнут сигналить рабочему – используя текущую логику из метода Main – у нас начнутся проблемы. Два сигналящих потока могли бы последовательно исполнить следующую строку кода:

lock (locker) { go = true; Monitor.Pulse(locker); }

что привело бы к потере второго сигнала, если рабочий поток в это время не до конца отработал по первому сигналу. Можно учесть такую ситуацию, использовав пару флажков – “ready” и “go”. Флажок “ready” показывает, что рабочий поток готов принять новую задачу, “go”, как и раньше, – сигнал начинать. Решение аналогично предыдущему примеру, который делал то же самое, используя два AutoResetEvent, за исключением лучшей расширяемости. Вот переработанный шаблон, с нестатическими полями:

Wait/Pulse шаблон #3: Двусторонняя сигнализация

public class Acknowledged { object locker = new object(); bool ready; bool go;   public void NotifyWhenReady() { lock (locker) { // ожидать, если рабочий поток занят предыдущей задачей while (!ready) Monitor.Wait(locker);   ready = false; go = true; Monitor.PulseAll(locker); } }   public void AcknowledgedWait() { // Отобразить готовность принять запрос lock (locker) { ready = true; Monitor.Pulse(locker); }   lock (locker) { while (!go) Monitor.Wait(locker); // Ожидать установки "go"   go = false; Monitor.PulseAll(locker); // Подтвердить сигнал }   Console.WriteLine("Wassup?"); // Выполнить задачу } }

Для проверки запустим два параллельных потока, каждый из которых посигналит рабочему потоку пять раз. Тем временем главный поток будет ожидать десяти уведомлений:

public class Test { static Acknowledged a = new Acknowledged();   static void Main() { new Thread(Notify5).Start(); // Запустить два параллельных new Thread(Notify5).Start(); // "уведомляльщика"... Wait10(); // ... и одного ожидающего }   static void Notify5() { for (int i = 0; i < 5; i++) a.NotifyWhenReady(); }   static void Wait10() { for (int i = 0; i < 10; i++) a.AcknowledgedWait(); } }

Консольный вывод:

Wassup? Wassup? ... Wassup? (десять повторов)

В методе Notify флаг ready очищается перед выходом из lock. Это жизненно важно: таким образом предотвращается последовательная сигнализация двумя уведомляющими потоками без перепроверки флага. Для простоты установка флага go и вызов PulseAll выполняются в той же самой конструкции lock, однако можно поместить эти две инструкции в отдельные конструкции lock, и ничего не сломается.