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<int> GetNextIntWithArg(string arg)
{
    var eventToTask = new EventToTask<ResultEventArgs>(
        handler => _eventApi.GetNextIntCompleted += handler,
        handler => _eventApi.GetNextIntCompleted -= handler,
        handler => _eventApi.GetNextIntFailed += handler,
        handler => _eventApi.GetNextIntFailed -= handler,
        () => _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<EventApi, ResultEventArgs>(
    _eventApi,
    (context, handler) => context.GetNextIntCompleted += handler,
    (context, handler) => context.GetNextIntCompleted -= handler,
    (context, handler) => context.GetNextIntFailed += handler,
    (context, handler) => context.GetNextIntFailed -= handler,
    context => 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 => context.GetNextInt())
    .WithResultEvent<ResultEventArgs>((context, handler) => context.GetNextIntCompleted += handler)
    .WithFailureEvent((context, handler) => 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