Spotkanie z TaskCompletionSource – Cz. 3 Niespełnione obietnice

Trzecią i ostatnią część artykułu poświęcam rzadkiemu ale bardzo trudnemu w analizie problemowi, który może pojawić się w czasie adaptacji API w wykorzystaniem TaskCompletionSource.

Zacznę od takiego niewinnego kawałku kodu:

class Adapter
{
    public Task<int> GetNextInt()
    {
        var api = new CallbacksApi();
        var taskCompletionSource = new TaskCompletionSource<int>();
    
        api.GetNextInt((result, exception) =>
        {
            if (exception != null)
            {
                taskCompletionSource.SetException(exception);
            }
            else
            {
                taskCompletionSource.SetResult(result);
            }
        });
        
        return taskCompletionSource.Task;
    }
}

Kod wydaje się poprawny. Co lepsze będzie działać w czasie testów dymnych, prawdopodobnie przejdzie testy funkcjonalne. Kod ten może jednak powodować pojawianie się błędów typu: „Ta operacja czasami nie kończy się przez pół godziny”, „aplikacja chodzi wolno” albo „program się zawiesza”. Co gorsza nikt nie będzie w stanie dostarczyć powtarzalnego scenariusza.

Winnym okazuje się być Garbage Collector, który może w tym przypadku błędnie stwierdzić, że nikt nie potrzebuje obiektu CallbacksApi, bo nikt nie posiada do niego referencji.

Referencje pomiędzy obiektami

Pierwszym sposobem „obejścia” tego problemu może być „przytrzymanie” referencji do API w jakimś obiekcie, który żyje dłużej. W kodzie powyżej może być to klasa obiekt typu Adapter.

class Adapter
{
    private CallbacksApi _api = new CallbacksApi();
    public Task<int> GetNextInt()
    {
        var taskCompletionSource = new TaskCompletionSource<int>();
    
        _api.GetNextInt((result, exception) =>
        {
            if (exception != null)
            {
                taskCompletionSource.SetException(exception);
            }
            else
            {
                taskCompletionSource.SetResult(result);
            }
        });
        
        return taskCompletionSource.Task;
    }
}

Sprawa się komplikuje gdy nowe CallbacksApi musi być tworzone z każdym wywołaniem GetNextInt. Brzydkim ale szybkim obejściem tego problemu jest użycie kolekcji:

class Adapter
{
    private List<CallbacksApi> _apiReferences = new List<CallbacksApi>();
    private object _apiReferencesLockRoot = new object();
    public Task<int> GetNextInt()
    {
        var api = new CallbacksApi();
        var taskCompletionSource = new TaskCompletionSource<int>();
        lock(_apiReferencesLockRooti)
        {
            _apiReferences.Add(api);
        }
    
        api.GetNextInt((result, exception) =>
        {
            lock(_apiReferencesLockRooti)
            {
                _apiReferences.Add(api);
            }
            if (exception != null)
            {
                taskCompletionSource.SetException(exception);
            }
            else
            {
                taskCompletionSource.SetResult(result);
            }
        });
        
        return taskCompletionSource.Task;
    }
}

Ten kod działa ale wszyscy się chyba zgodzą że najładniejszy to on nie jest. W dodatku jeżeli referencja do obiektu typu Adapter nie jest nigdzie przetrzymywana przez dłuższy czas to powyższe rozwiązania i tak nie zadziałają. Na szczęście Microsoft pomyślał o tym problemie i TaskCompletionSource posiada dodatkowy parametr konstruktora „state”, którego można przekazać cokolwiek. Obiekt ten jest przypisany do właściwości AsyncState wynikowego Taska, przez co GC zostawi go w spokoju.

class Adapter
{
    public Task<int> GetNextInt()
    {
        var api = new CallbacksApi();
        var taskCompletionSource = new TaskCompletionSource<int>(api);
    
        api.GetNextInt((result, exception) =>
        {
            if (exception != null)
            {
                taskCompletionSource.SetException(exception);
            }
            else
            {
                taskCompletionSource.SetResult(result);
            }
        });
        
        return taskCompletionSource.Task;
    }
}

Kod klas, które stworzyłem na potrzeby tego wpisu znajduje się w tym punkie Githuba, natomiast testy do nich tutaj. Testy zwracają oczekiwany wynik tylko po zbudowaniu projektu w trybie Release, a właściwie to nie do końca zwracają. Test dla klasy FixedAdapter przechodzi i tak ma być. Test dla klasy BrokenAdapter nie przechodzi i to także jest zgodne z moimi oczekiwaniami.

Mam natomiast problem z testem dla klasy WorkedAroundAdapter, teoretycznie linijka Assert.NotNull(adapter) powinna sprawić że test przejdzie, ten jednak przejść nie chce i nie mam pojęcia dlaczego. Postanowiłem więc postawić znaczną ilość piwa, zgrzewkę 24 Tyskiego albo jego równowartość w innym gatunku, pierwszej osobie która mi to wytłumaczy.

Jedna odpowiedź do “Spotkanie z TaskCompletionSource – Cz. 3 Niespełnione obietnice”

Możliwość komentowania jest wyłączona.