События и потоки. Часть 1
Все примеры будут написаны на языке C# для версии фреймворка 4.0 (на 4.6 все несколько проще, но все еще есть много проектов на 4.0). Так же буду пытаться придерживаться версии C# 5.0.
Во-первых хочу заметить, что для системы событий в .NET уже есть готовые делегаты, которые я очень советую вам использовать и не изобретать велосипедов. Например, я нередко встречал вот такие 2 способа организации событий:
class WrongRaiser
{
public event Action
Если вы делаете так, откажитесь от этого. Если вы не делаете приложение из 10 строк кода с минимумом логики и нагрузки, то в конечном счете это может создать проблемы.
class WrongRaiser
{
public event MyDelegate MyEvent;
}
class MyEventArgs
{
public object SomeProperty { get; set; }
}
delegate void MyDelegate(object sender, MyEventArgs e);
Этот способ вполне имеет право на жизнь, но его стоит использовать для частных случаев, когда по каким-либо причинам не подходит способ, описанный ниже.
Теперь о том, что уже создано для событий.
class Raiser
{
public event EventHandler MyEvent;
}
class MyEventArgs : EventArgs
{
public object SomeProperty { get; set; }
}
Как видите, здесь мы используем универсальный класс EventHandler. То есть определять собственный хандлер нет необходимости.
Есть и еще способ — создать новый класс, унаследованный от Delegate или MulticastDelegate, но это уже для совсем специфичных случаев. Фактически, способ 2 это и делает за кулисами.
Для дальнейших примеров будет использован универсальный способ.
Посмотрим на простейший пример генератора событий
class EventRaiser
{
int _counter;
public event EventHandler CounterChanged;
public int Counter
{
get
{
return _counter;
}
set
{
if (_counter != value)
{
var old = _counter;
_counter = value;
OnCounterChanged(old, value);
}
}
}
public void DoWork()
{
new Thread(new ThreadStart(() =>
{
for (var i = 0; i < 10; i++)
Counter = i;
})).Start();
}
void OnCounterChanged(int oldValue, int newValue)
{
if (CounterChanged != null)
CounterChanged.Invoke(this, new EventRaiserCounterChangedEventArgs(oldValue, newValue));
}
}
class EventRaiserCounterChangedEventArgs : EventArgs
{
public int NewValue { get; set; }
public int OldValue { get; set; }
public EventRaiserCounterChangedEventArgs(int oldValue, int newValue)
{
NewValue = newValue;
OldValue = oldValue;
}
}
Имеем класс, который имеет свойство Counter и умеет изменять его от 0 до 10. Причем логика, которая меняет Counter, обрабатывается в отдельном потоке.
А вот наша точка входа.
class Program
{
static void Main(string[] args)
{
var raiser = new EventRaiser();
raiser.CounterChanged += Raiser_CounterChanged;
raiser.DoWork();
Console.ReadLine();
}
static void Raiser_CounterChanged(object sender, EventRaiserCounterChangedEventArgs e)
{
Console.WriteLine(string.Format("OldValue: {0}; NewValue: {1}", e.OldValue, e.NewValue));
}
}
То есть создаем экземпляр нашего генератора, подписываемся на изменение каунтера и в обработчике события выводим в консоль значения.
Вот что мы получим в результате
Пока все ровно. Но подумайте, а в каком потоке выполняется обработчик события?
Задав этот вопрос коллегам, я получил ответ «в основном». Это означало, что никто из моих коллег не понимает как устроены делегаты. Я попытаюсь объяснить это на яблоках под спойлером, кто уже все знает, может не читать.
Есть еще его наследник MulticastDelegate, который имеет более одного элемента.
Так вот, когда вы подписываетесь на событие, создается экземпляр наследника от MulticastDelegate. Каждый следующий подписчик добавляет в уже созданный экземпляр MulticastDelegate новый метод (обработчик события).
И когда вы вызываете метод Invoke, для вашего события, начинают вызываться обработчики всех подписчиков по очереди. При этом поток, в котором вы вызываете эти обработчики ничего не знает о потоке, в котором они были заданы и соответственно не может в этот поток ничего подсунуть.
В общем обработчики события в примере выше выполняются в потоке, порожденном в методе DoWork (). То есть при генерации события, поток, который его сгенерировал таким образом, дожидается выполнения всех обработчиков. Я докажу это без выдергивания Id потоков, просто логически. Для этого я изменил пару мест в примере выше
void OnCounterChanged(int oldValue, int newValue)
{
if (CounterChanged != null)
{
CounterChanged.Invoke(this, new EventRaiserCounterChangedEventArgs(oldValue, newValue));
Console.WriteLine(string.Format("Event Raiser: old = {0}, new = {1}", oldValue, newValue));
}
}
Обработчик
static void Raiser_CounterChanged(object sender, EventRaiserCounterChangedEventArgs e)
{
Console.WriteLine(string.Format("OldValue: {0}; NewValue: {1}", e.OldValue, e.NewValue));
Thread.Sleep(500);
}
В обработчике мы усыпляем текущий поток на полсекунды. Если бы обработчики работали в основном потоке, то этого времени бы хватило, чтобы поток, порождаемый в DoWork () завершил свою работу и вывел свои результаты.
Но вот что мы видим на самом деле.
Я не знаю, кто и как будет обрабатывать события, порожденные написанным мной классом, но мне не очень то хочется, чтобы эти обработчики могли повесить работу моего класса. Поэтому я буду использовать метод BeginInvoke вместо Invoke. BeginInvoke порождает новый поток.
Теперь, изменив метод, в котором генерируется событие, получаем следующее
void OnCounterChanged(int oldValue, int newValue)
{
if (CounterChanged != null)
{
var delegates = CounterChanged.GetInvocationList();
for (var i = 0; i < delegates.Length; i++)
((EventHandler)delegates[i]).BeginInvoke(this, new EventRaiserCounterChangedEventArgs(oldValue, newValue), null, null);
Console.WriteLine(string.Format("Event Raiser: old = {0}, new = {1}", oldValue, newValue));
}
}
Два последних параметра у нас равны null. Первый — это колбэк, второй — некий параметр. Колбэк в данном примере нам не нужен. Он может пригодится для обратной связи, например, чтобы класс, генерирующий событие, мог узнать, было ли обработано событие и/или, если нужно, получить результаты этой обработки.
Если запустим программу, получим такой результат
Думаю, всем понятно, что теперь обработчики событий выполняются в отдельных потоках. То есть генератору событий теперь до лампочки, кто, как и как долго будет обрабатывать его события.
Тут возникает еще вопрос, а как же последовательная обработка? У нас же Counter. А что если это была бы последовательная смена состояний? Но ответ на этот вопрос я вам не дам, это не касается темы текущей статьи. Могу лишь сказать, что способов несколько.
Ну и еще. Чтобы не выполнять аналогичные действия раз за разом, предлагаю вынести их в отдельный класс.
static class AsyncEventsHelper
{
public static void RaiseEventAsync(EventHandler h, object sender, T e) where T: EventArgs
{
if (h != null)
{
var delegates = h.GetInvocationList();
for (var i = 0; i < delegates.Length; i++)
((EventHandler)delegates[i]).BeginInvoke(sender, e, null, null);
}
}
}
Используем его вот так
void OnCounterChanged(int oldValue, int newValue)
{
AsyncEventsHelper.RaiseEventAsync(CounterChanged, this, new EventRaiserCounterChangedEventArgs(oldValue, newValue));
}
Думаю, теперь стало понятно (если не было), зачем нужен был универсальный способ. Если описывать события способом 2, то такая штука не прокатит. Ну или вам придется самостоятельно создавать универсальность для ваших делегатов.
Заключение
Надеюсь, я смог донести информацию о том, как работают события и где работают обработчики. В следующей части я планирую углубиться и рассказать, как можно получить результаты обработки событий при асинхронном вызове.
Жду комментариев с предложениями, дополнениями, вопросами.