Spotkanie z TaskCompletionSource – Cz. 2 Zdarzenia a zadania

Jedną z prób ogarnięcia asynchroniczności było wykorzystanie mechanizmu zdarzeń (event). Onegdaj istniały takie technologie jak WCF – służący do wystawiania na świat API po http oraz Silverlight, który miał robić to co dzisiaj Flash javascript. I jak się generowało klasy pozwalające korzystać z API WCF w SL to wynikowe klasy przypominały taki kod:

public class Proxy
{
void Method1(int argument);
event EventHandler<EventArgs<Result1>> Method1Completed;
event EventHandler<FailureEventArgs> Method1Failed;
void Method2();
event EventHandler<EventArgs<Result2>> Method1Completed;
event EventHandler<FailureEventArgs> Method1Failed;
}

Ale poczułem się staro. Podobny wzorzec był stosowany też w innych technologiach. Ma on wiele wad w porównaniu do kolbaków. Na początek nie istnieje żadna gwarancja że jeżeli wywołam Method1 z argumentem 1 po czym wywołam Method1 z argumentem 2 to event Method1Completed dla 1 przyjdzie przed tym samym eventem dla 2. Na potrzeby dalszych rozważań uznam, że opakowanie w Task i użycie słów async i await sprawi że wywołanie z 2 nie nastąpi przez eventem dla 1, co nie musi być prawdą ale zazwyczaj jest.

Podejście to sprawdza się nie źle w jednym przypadku: akcja się wywołuje jak się uda podejmowane jest jedno zawsze to samo działanie, jak nie to drugie.

// Constructor
public ClientClass()
{
_eventApi = new EventApi();
_eventApi.GetNextIntCompleted += EventApiOnGetNextIntCompleted;
_eventApi.GetNextIntFailed += EventApiOnGetNextIntFailed;
}
private void EventApiOnGetNextIntCompleted(object sender, ResultEventArgs resultEventArgs)
{
MessageBox.Show(resultEventArgs.Result);
}
private void EventApiOnGetNextIntFailed(object sender, FailureEventArgs failureEventArgs)
{
MessageBox.Show("Something gone wrong");
}

Sytuacja jednak komplikuje się znacząco gdy w momencie zakończenia zadania potrzebna jest informacja znana przy jego rozpoczęciu:

public void GetNextIntForClient(Client client)
{
EventHandler<ResultEventArgs> successHandler = null;
EventHandler<FailureEventArgs> failureHandler = null;
successHandler = (sender, args) =>
{
_eventApi.GetNextIntCompleted -= successHandler;
_eventApi.GetNextIntFailed -= failureHandler;
MessageBox.Show(string.Format("{1} was chosen for client {0}", client.Name, args.Result));
};
failureHandler = (sender, args) =>
{
_eventApi.GetNextIntCompleted -= successHandler;
_eventApi.GetNextIntFailed -= failureHandler;
MessageBox.Show(string.Format("Unable to choose value for client {0}", client.Name));
};
_eventApi.GetNextIntCompleted += successHandler;
_eventApi.GetNextIntFailed += failureHandler;
_eventApi.GetNextInt();
}

Dla porównania kod oparty o async/await

public async void GetNextIntForClient(Client client)
{
try
{
await _taskApi.GetNextInt();
MessageBox.Show(string.Format("{1} was chosen for client {0}", client.Name, args.Result));
}
catch (Exception exception)
{
MessageBox.Show(string.Format("Unable to choose value for client {0}", client.Name));
}
}

Opakowanie zdarzeń w zadania nie jest proste i opiera się na kodzie podobnym do tego w metodzie GetNextIntForClient:

public Task<int> GetNextInt()
{
var completionSource = new TaskCompletionSource<int>();
EventHandler<ResultEventArgs> successHandler = null;
EventHandler<FailureEventArgs> failureHandler = null;
successHandler = (sender, args) =>
{
_eventApi.GetNextIntCompleted -= successHandler;
_eventApi.GetNextIntFailed -= failureHandler;
completionSource.TrySetResult(args.Result);
};
failureHandler = (sender, args) =>
{
_eventApi.GetNextIntCompleted -= successHandler;
_eventApi.GetNextIntFailed -= failureHandler;
completionSource.TrySetException(args.Error);
};
_eventApi.GetNextIntCompleted += successHandler;
_eventApi.GetNextIntFailed += failureHandler;
_eventApi.GetNextInt();
return completionSource.Task;
}

Sporo kodu… wypadałoby go skrócić. Napisanie uniwersalnego helpera jest jednak trudniejsze niż się wydaje. Jednym z problemów jest, wspomiany w poprzednim artykule, brak mapowania delegat „w locie”. Z moich doświadczeń wynika jednak, że twórcy API zazwyczaj korzystają z typów: EventHandler oraz EventHandler<T>, a nawet jeżeli używają innych typów to są spójni w zakresie swojego API. Więc całkiem rozsądne okazuje się napisanie klasy będzie zawierać powtarzający się kod

sprawi że opakowanie API opartego o eventy w zadania skróci się do:

public async Task&lt;int&gt; GetNextIntWithArg(string arg)
{
var eventToTask = new EventToTask&lt;ResultEventArgs&gt;(
handler =&gt; _eventApi.GetNextIntCompleted += handler,
handler =&gt; _eventApi.GetNextIntCompleted -= handler,
handler =&gt; _eventApi.GetNextIntFailed += handler,
handler =&gt; _eventApi.GetNextIntFailed -= handler,
() =&gt; _eventApi.GetNextInt());
var eventArgs = await eventToTask.Invoke();
return eventArgs.Result;
}

Znacznie lepiej ale dalej fatalnie. Problemem, który widać na pierwszy rzut oka, jest konieczność pisania powtarzalnego kodu w którym łatwo się pomylić. W dodatku w przypadku pomyłki programista nie zostanie ostrzeżony podczas kompilacji, ten błąd może zostać także niezauważony przez długi czas przez wszystkie formy testu i objawić się wyciekiem pamięci albo trudnym do odtworzenia crashem. Drugim problemem, którego nie widać, jest fakt iż kompilator wygeneruje na potrzeby tego kodu 6 klas których obiekty będzie tworzył przy wywołaniu tej metody. Zazwyczaj nie jest to duży problem ale można go łatwo ograniczyć zmieniając implementację EventToTask.

Użycie wygląda podobnie:

var eventToTask = new EventToTask&lt;EventApi, ResultEventArgs&gt;(
_eventApi,
(context, handler) =&gt; context.GetNextIntCompleted += handler,
(context, handler) =&gt; context.GetNextIntCompleted -= handler,
(context, handler) =&gt; context.GetNextIntFailed += handler,
(context, handler) =&gt; context.GetNextIntFailed -= handler,
context =&gt; context.GetNextInt());
var eventArgs = await eventToTask.Invoke();
return eventArgs.Result;

Sposobem na zwiększenie wykrywalności i zmniejszenie występowania problemów związanych z pomyłkami programisty w tym kodzie wydaje się być użycie klasy Expression. Przykładowy kod jest trochę przy długi zapraszam do zapoznania się znim na Githubie

Użycie tej klasy jest dość proste:

var eventToTask = EventToTask
.Create(_eventApi)
.WithTrigger(context =&gt; context.GetNextInt())
.WithResultEvent&lt;ResultEventArgs&gt;((context, handler) =&gt; context.GetNextIntCompleted += handler)
.WithFailureEvent((context, handler) =&gt; context.GetNextIntFailed += handler)
.Build();
var eventArgs = await eventToTask.Invoke();
return eventArgs.Result;

jest tylko jeden taki malutki problemik: ten kod się nie kompiluje. Niestety parametr typu Expression<T> nie może przyjąć lambdy zawierającej operator +=. Z drugiej strony po za klasą w której zdarzenie jest deklarowane jedynymi dozwolonymi operatorami które mogą go modyfikować są += i -=.

Podsumowując opieranie asynchroniczności o zdarzenia jest zazwyczaj pomyłką architektoniczną i rodzi problemy. Daje się jednak z nią żyć.

Kody źródłowe

Kod źródłowy projektu stworzonego na potrzeby tej serii udostępniam na Githubie