Wiele razy pisałem już o słowach async\await i z pewnością ułatwiają one życie. Dla przypomnienia rozważmy kod:
private async void DownloadAndSortAsync() { int[] allNumbers = await DownloadNumbersAsync(); int[] sortedNumbers = await SortNumbersAsync(allNumbers); MessageBox.Show(string.Join(“,”,sortedNumbers)); } private Task<int[]> DownloadNumbersAsync() { return Task<int[]>.Factory.StartNew(() => DownloadNumbers()); } private Task<int[]> SortNumbersAsync(int[] numbers) { return Task<int[]>.Factory.StartNew(() => SortNumbers(numbers)); }
Ciekawą możliwością jest implementacja własnego await’er,a który nie musi być związany z zadaniami (Task). Jako przykład, pokażę zaproponowany przez J.Richter awaiter dla zdarzeń. Załóżmy, że chcemy czekać na zdarzenie aż zostanie wysłane, ale nie chcemy jednocześnie blokować wątku.
Stwórzmy najpierw klasy, na których będziemy testować rozwiązanie:
public class MessageEventArgs : EventArgs { public MessageEventArgs(string message) { Message = message; } public string Message { get; private set; } } public class MessageController { public event EventHandler<MessageEventArgs> MessageReceived; public void SendMessage(string message) { if(MessageReceived!=null) MessageReceived(this,new MessageEventArgs(message)); } }
Prosty kontroler eksponujący zdarzenie. Kolejnym celem jest czekanie aż zdarzenie zostanie odebrane. Innymi słowy, chcemy odbierać wiadomości w pętli i wykonać jakieś zadanie tzn.:
while(true) { MessageEventArgs args = await _controller.MessageReceived; Console.WriteLIne(args.Message); }
Oczywiście powyższy kod nie zadziała ponieważ zdarzenie c# nie wspiera słowa kluczowego await. Musimy najpierw zaimplementować interfejs INotifyCompletion:
/// <summary> /// Represents an operation that schedules continuations when it completes. /// </summary> public interface INotifyCompletion { /// <summary> /// Schedules the continuation action that's invoked when the instance completes. /// </summary> /// <param name="continuation">The action to invoke when the operation completes.</param><exception cref="T:System.ArgumentNullException">The <paramref name="continuation"/> argument is null (Nothing in Visual Basic).</exception> void OnCompleted(Action continuation); }
Do zaimplementowania mamy tylko jedną metodę z interfejsu. OnCompleted przekaże nam tzw. continuationPoint. Po odebraniu zdarzenia właśnie tą delegatę będziemy wywoływać, aby powiadomić maszynę stanów await o zakończeniu operacji.
Najprostsza implementacja await’era wygląda następująco:
public class MessageEventAwaiter : INotifyCompletion { private Action _continuationPoint; private MessageEventArgs _result; public void OnCompleted(Action continuation) { _continuationPoint = continuation; } public void EventHandler(object sender, MessageEventArgs messageEventArgs) { _result = messageEventArgs; if (_continuationPoint != null) _continuationPoint(); } public MessageEventAwaiter GetAwaiter() { return this; } public MessageEventArgs GetResult() { return _result; } public bool IsCompleted { get { return _result != null; } } }
W metodzie OnCompleted po prostu buforujemy continuationPoint, który zostanie przekazany nam przez state machine:
public void OnCompleted(Action continuation) { _continuationPoint = continuation; }
EventHandler to metoda, którą przekażemy zdarzeniu. To właśnie ona zostanie najpierw wywołana przez zdarzenie:
public void EventHandler(object sender, MessageEventArgs messageEventArgs) { _result = messageEventArgs; if (_continuationPoint != null) _continuationPoint(); }
Innymi słowy, zdarzenie wywołuje metodę EventHandler, a ona z kolei powiadomi state machine za pomocą _continuationPoint.
Następnie mamy trzy metody, które są wymagane dla wsparcia słowa kluczowego await. Bez nich, kod po prostu by się nie skompilował:
public MessageEventAwaiter GetAwaiter() { return this; } public MessageEventArgs GetResult() { return _result; } public bool IsCompleted { get { return _result != null; } }
Nie są one zbyt skomplikowane, po prostu sprawdzają czy zadanie zostało już wykonane itp.
Tak naprawdę powyższy kod nie jeszcze idealny, ale spróbujmy go przetestować za pomocą:
var messageController = new MessageController(); var messageAwaiter=new MessageEventAwaiter(); Task.Factory.StartNew(() => { int i = 0; while (true) { Thread.Sleep(2000); messageController.SendMessage(i.ToString(CultureInfo.InvariantCulture)); i++; } }); messageController.MessageReceived+=messageAwaiter.EventHandler; while (true) { MessageEventArgs result = await messageAwaiter; Console.WriteLine(result.Message); }
Najpierw tworzymy wątek, który co dwie sekundy wysyła zdarzenie, a potem w pętli odbieramy je za pomocą słowa kluczowego await. Gdybyśmy teraz uruchomili aplikację na ekranie zobaczylibyśmy wyłącznie pierwsze zdarzenie:
Dlaczego? Widzimy, że zadanie jest uznane za ukończone jak tylko _result jest ustawiony na jakaś wartość. Ma to miejsce już po pierwszym zdarzeniu.
Z tego wniosek, że musimy kolejkować zdarzenia:
public class MessageEventAwaiter : INotifyCompletion { private Action _continuationPoint; private Queue<MessageEventArgs> _results = new Queue<MessageEventArgs>(); public void OnCompleted(Action continuation) { _continuationPoint = continuation; } public void EventHandler(object sender, MessageEventArgs messageEventArgs) { _results.Enqueue(messageEventArgs); if (_continuationPoint != null) _continuationPoint(); } public MessageEventAwaiter GetAwaiter() { return this; } public MessageEventArgs GetResult() { return _results.Dequeue(); } public bool IsCompleted { get { return _results.Count > 0; } } }
Teraz, zamiast przechowywać wyłącznie jedną wartość, mamy kolekcję danych. IsCompleted zwróci true, gdy cała kolejka będzie pusta. Na ekranie, po uruchomieniu, zobaczymy prawidłowe dane:
Kod wciąż nie jest idealny. W praktyce lepiej napisać generyczny awaiter, a nie taki, który obsługuje wyłącznie jeden typ zdarzenia. Kolejne możliwe ulepszenie to napisanie kodu thread-safe – powyższy przykład nie będzie działał w środowisku wielowątkowym, co zwykle ma miejsce w przypadku await. Tak czy inaczej, nie są to trudne zmiany do naniesienia, a myślę, że łatwiej było wytłumaczyć idee na przykładzie prostego przykładu a nie produkcyjnego kodu.
Czy o tym myślałeś piszącz generyczna i thread-safe, czy czegoś tu jeszcze brakuje?
public class EventAwaiter : INotifyCompletion where T : EventArgs
{
private Action continuationPoint;
private readonly ConcurrentQueue results = new ConcurrentQueue();
public void OnCompleted(Action continuation)
{
continuationPoint = continuation;
}
public void EventHandler(object sender, T messageEventArgs)
{
results.Enqueue(messageEventArgs);
continuationPoint?.Invoke();
}
public EventAwaiter GetAwaiter()
{
return this;
}
public T GetResult()
{
T result;
results.TryDequeue(out result);
return result;
}
public bool IsCompleted => results.Count > 0;
}