вторник, июля 15, 2008

Как работает ManualResetEvent

Ну вот я вернулся из отпуска и после долгого молчания решил разразиться техническим постом.
Меня уже несколько раз приходилось объяснять принцип работы и использования ManualResetEvent. И вот я решил все это подробно разжевать с примерами кода.
Продвинутых коллег сразу предупреждаю, ничего нового и эксклюзивного здесь не будет, если ManualResetEvent для вас не является белым пятном, то дальше можете не читать.

Итак, когда наш код выполняется в одном потоке, инструкции выполняются в определенном программой порядке. Но стоит нам запустить код в нескольких потоках у нас сразу возникает неопределенность в каком порядке выполняются инструкции нашего кода. Рассмотрим простейшим пример.


static void Main(string[] args)
{
Thread thread = new Thread(new ThreadStart(ParallelWork));
thread.Start();
Console.WriteLine("Main:thread work finished");
}

static void ParallelWork()
{
Console.WriteLine("Thread:begin work");
Thread.SpinWait(1000);// что то полезное делаем тут
Console.WriteLine("Thread:end work");
}


В методе Main() мы создаем и запускаем поток, в котором будет выполнен код метода ParallelWork(). Запустив несколько раз этот код мы можем получить на выходе:
Thread:begin work
Thread:end work
Main:thread work finished

или
Thread:begin work
Main:thread work finished
Thread:end work

или
Main:thread work finished
Thread:begin work
Thread:end work

Обычно ничего страшного в этом нет, ведь параллельное исполнение кода именно для этого и задумывалось. Но иногда у нас возникает необходимость синхронизировать исполнение отдельных участков кода в разных потоках.
Если взять наш куцый примерчик, то предположим, мы хотим, чтобы метод Main дождался завершения метода ParallelWork, и только потом завершился сам. Достичь этого можно разными способами. Например, можно объявить булеву переменную флаг, в метод Main вставить цикл проверки этого флага, а в конце метода ParallelWork выставлять флаг в true. Вот так:


static bool flag = false;
static void Main(string[] args)
{
Thread thread = new Thread(new ThreadStart(ParallelWork));
thread.Start();
while (!flag) ;
Console.WriteLine("Main:thread work finished");
}

static void ParallelWork()
{
Console.WriteLine("Thread:begin work");
Thread.SpinWait(1000);// что то полезное делаем тут
Console.WriteLine("Thread:end work");
flag = true;
}


На выходе получим искомое:

Thread:begin work
Thread:end work
Main:thread work finished


за счет того что в методе Main будет молотить холостой цикл while(!flag) ; пока метод ParallelWork() не выставит flag = true. Основной недостаток такого способа синхронизации состоит в том, что холостой цикл очень хорошо грузит процессор.

Ту же самую задачу можно решить при помощи ManualResetEvent.
Вот так:


static ManualResetEvent sync;
static void Main(string[] args)
{
sync = new ManualResetEvent(false);
Thread thread = new Thread(new ThreadStart(ParallelWork));
thread.Start();
sync.WaitOne();
Console.WriteLine("Main:thread work finished");
}

static void ParallelWork()
{
Console.WriteLine("Thread:begin work");
Thread.SpinWait(1000);// что то полезное делаем тут
Console.WriteLine("Thread:end work");
sync.Set();
}


Как это работает? Очень просто.
У ManualResetEvent есть внутреннее состояние: сигнальное и несигнальное. В сигнальное он переводится методом Set() в несигнальное - методом Reset(). Также начальное состояние ManualResetEvent можно задать и в конструкторе. В нашем случае мы создаем экземпляр ManualResetEvent в несигнельном состоянии.

Самый главный его метод WaitOne(). Когда в коде встречается вызов WaitOne() исполнение приостанавливается, если ManualResetEvent в несигнальном состоянии. А если в сигнальном – WaitOne исполняется без задержки. На этом и основано его использование. Мы заставляем код в главном потоке остановиться и ждать на вызове WaitOne(), пока где нибудь в другом потоке не вызовут Set(). Вызов Set() как-бы подает сигнал другому потоку (или нескольким), который висит и ждет на вызове WaitOne(), после чего этот поток может продолжить свое исполнение. Т.е. WaitOne() по своему эффекту похож на Thread.Sleep(), но с возможностью разбудить поток в нужный момент из другого потока.

Благодаря тому, что ManualResetEvent использует synchronization handle операционной системы, поток ожидающий на вызове WaitOne() действительно приостанавливается, ему не выделяются кванты процессорного времени и он не тормозит исполнение других потоков. И это основное отличие от синхронизации с флагом и холостым циклом. Висеть на WaitOne() может не один а несколько потоков, и все они выдут из ожидания при вызове одного Set().

Напоследок, хочу упомянуть, что рассматриваемую в примере задачу синхронизации можно решить и без ManualResetEvent, при помощи Thread.Join(), однако стоить заметить, что Thread.Join() использует внутри те же механизмы, что и ManualResetEvent


static void Main(string[] args)
{
sync = new ManualResetEvent(false);
Thread thread = new Thread(new ThreadStart(ParallelWork));
thread.Start();
thread.Join();
Console.WriteLine("Main:thread work finished");
}

static void ParallelWork()
{
Console.WriteLine("Thread:begin work");
Thread.SpinWait(1000);// что то полезное делаем тут
Console.WriteLine("Thread:end work");
}

11 комментариев:

Сергей Звездин комментирует...

В примере с флагом можно еще использовать свойство IsAlive объекта Thread чтобы узнать завершился поток или нет.

Кроме того, есть еще механизм блокировки (lock {}) и класс Monitor. Впрочем, с которыми нужно тоже работать аккуратно.

Анонимный комментирует...

Здравствуйте :)

А можно объяснить чем отличается AutoResetEvent?

Sergey Rozovik комментирует...

to Анонимный

AutoResetEvent отличается от ManualResetEvent тем, что он возвращается из сигнального в несигнальное состояние автоматически в первом ожидающем на WaitOne потоке. ManualResetevent надо переводить в несигнальное состояние надо "вручную" вызовом Reset().
AutoResetEvent удобно использовать когда надо подавать сигналы одному потоку, особенно если это надо делать периодически. Но AutoResetEvent нельзя использовать для подачи сигналов нескольким потокам, потому что разблокирован будет только один из них, AutoResetEvent тут-же перйдет в несигнальное состояние и остальные потоки останутся висеть заблокированными на WaitOne. Для подачи сигнала нескольким потокам следует использовать ManualResetEvent.

to Сергей Звездин

Механизмы блокировки и Monitor это несколько другой аспект. Там синхронизация выполняется с конкретной целью - опеспечить последовательный (эксклюзивный) доступ к конкретным данным.
Механизмы, основанные на WaitHandle (ManualResetEvent, AutoResetEvent, Mutext и Semaphore), используются для синхронизации исполнения потоков вне привязки к каким то определенным данным.
Кстати, в примере в флагом для доступа к флагу не используются блокировки, потому что они там не нужны вовсе. Не всегда многопоточный доступ к данным требует блокировок. Существует даже целый класс неблокирующих алгоритмов. Я даже хотел о них написать статью да никак не соберусь с духом.

Сергей Звездин комментирует...

Это да.
Ну я упомняул их для полноты картины, ибо в итоге то все равно все сводится к блокировкам.

Насчет ситуаций когда не нужно блокировать данные вопрос вообще не однозначный. Был опыт, когда видно что данные блокировать не нужно, но при малейшем изменении в требованиях/сценариях блокировки нужны, и от этого много головной боли.

Кстати, было бы интерсено почитать заметку на эту тему ;)

Анонимный комментирует...

Спасибо большое!
Коротко и функционально!

Анонимный комментирует...

Спасибо! Все очень наглядно, легко и быстро воспринимается.

cent комментирует...

Спасибо, дошло=)

Анонимный комментирует...

Сергей, как всегда кратко и чётко!
спасибо

Анонимный комментирует...

Отлично все преподнесено читателю, спасибо!

Sever комментирует...

Спасибо! Все разложили по полочкам.

Анонимный комментирует...

Везде через жопу написано, а тут хорошо. Спасибо.