Dlaczego nie powinniśmy używać async void ?

Heloł, heloł !

Dziś krótki wpis, który przedstawi na przykładach dlaczego nie powinniśmy używać konstrukcji async void. Jeżeli ktoś z Was nie jest obeznany z tematem asynchroniczności to serdecznie polecam wpis na MSDN. A teraz przejdźmy do problemu. Mamy taki oto kod:

  [Route("api/tests")]
    public class TestController : Controller
    {
        public int MyInteger;
 
        [Route("first")]
        public async void FirstTestAsync()
        {
            await Task.Delay(1000);
            ChangeMyIntegerValue();
 
            if (MyInteger == 0)
                throw new OperationException("The value of myInteger is 0");
        }
 
        private async void ChangeMyIntegerValue()
        {
            await Task.Delay(1000);
            MyInteger = 10;
        }
 
    }

Akcja kontrolera FirstTestAsync początkowo wywołuje metodę statyczną Task.Delay(1000) wobec czego zatrzyma się w tym miejscu na jedną sekundę. Następnie przejdzie do metody ChangeMyIntegerValueAsync, gdzie ponownie zatrzyma się na sekundę po czym przypisze zmiennej MyInteger wartość 10. W dalszej części akcji FirstTestAsync sprawdzana jest wartość zmiennej. Jeżeli jest ona równa 0, rzucony zostaje wyjątek. Sprawdźmy jaki będzie rezultat metody wywołując ją poprzez fiddlera.

test1_exception

Ok, co się właściwie stało? Tu właśnie dochodzimy do pierwszego problemu związanego z async void – działa on w trybie fire and forget. Nazwa mówi sama za siebie. Ponieważ nasza metoda ChangeMyIntegerValueAsync zamiast Task (który reprezentuje zadanie, które wykonujemy) nic nie zwraca, odebrane zostaje to jako „dobra żądanie poszło, idź dalej, nie masz na co czekać”. Wobec tego od razu po tym sprawdzany zostaje warunek, czy wartość naszego inta jest równa 0, po czym rzucony zostaje wyjątek. Tak się dzieje, ponieważ wartość MyInteger zostanie zmieniona dopiero po sekundzie. Co gorsza gdyby metoda ChangeMyIntegerValueAsync nie zawierała Task.Delay zachowanie metody mogłoby być jeszcze bardziej nieprzewidywalne, ponieważ raz udałoby się zmienić wartość MyInteger zanim warunek zostanie sprawdzony, a innym razem już nie. Zmodyfikujmy nieco kod:

 

   [Route("api/tests")]
    public class TestController : Controller
    {
        public int MyInteger;
 
        [Route("first")]
        public async Task FirstTestAsync()
        {
            await Task.Delay(1000);
            await ChangeMyIntegerValue();
 
            if (MyInteger == 0)
                throw new OperationException("The value of myInteger is 0");
        }
 
        private async Task ChangeMyIntegerValueAsync()
        {
            await Task.Delay(1000);
            MyInteger = 10;
        }
 
    }

Obie metody zostały zamienione na async Task, dzięki temu możemy teraz użyć instrukcji await przed wywołaniem ChangeMyIntegerValueAsync. Teraz akcja FirstTestAsync nie przejdzie do sprawdzenia wartości MyInteger nim nie zakończy się zadanie. Ok, odpalmy fiddlera:

test1_correct_result

Teraz rezultat jest taki jakiego oczekiwaliśmy, a serwer zwraca status kod 200.


 

Drugi kod jest jeszcze bardziej zdradliwy:

        [Route("second")]
        public async void SecondTestAsync()
        {
            var isCatchBlockReached = false;
 
            try
            {
                await Task.Delay(1000);
                ThrowOperationException();
 
            }
            catch (Exception e)
            {
                isCatchBlockReached = true;
            }
 
            if (!isCatchBlockReached)
                throw new OperationException("Exception not caught");
        }
 
        private async void ThrowOperationException()
        {
            await Task.Delay(1000);
            throw new OperationException("BOOM !");
        }
    }

Czy ostatecznie wyjątek „Exception not caught” zostanie rzucony? Wszystko wskazuje na to, że nie. Wywołanie metody, która ów wyjątek rzuca jest umieszczone w bloku try. Następnie jest on łapany w catch-u, gdzie wartość zmiennej isCatchBlockReached zostaje zmieniona na true. Po sprawdzeniu warunku OperationException nie powinien zostać rzucony. Sprawdźmy więc:

 

test2_exception

Dochodzimy do drugiej poważnej wady tego „rozwiązania” tj. problem z łapaniem wyjątków. Ponownie jest to związane z fire and forget. Ponieważ znów nie zwrócony zostaje Task nie istnieje potrzeba oczekiwania na rezultat, a co za tym idzie – blok try zostaje od razu opuszczony. Po sprawdzeniu warunku,  rzucony zostaje warunek. Jak temu zaradzić? Ponownie zamieniamy void na Task, oraz używamy instrukcji await. Wygląda to tak:

       [Route("second")]
        public async Task SecondTestAsync()
        {
            var isCatchBlockReached = false;
 
            try
            {
                await Task.Delay(1000);
                await ThrowOperationException();
 
            }
            catch (Exception e)
            {
                isCatchBlockReached = true;
            }
 
            if (!isCatchBlockReached)
                throw new OperationException("Exception not caught");
        }
 
        private async Task ThrowOperationException()
        {
            await Task.Delay(1000);
            throw new OperationException("BOOM !");
        }
    }

Rezultat przedstawiony poniżej. Serwer zwraca 200 🙂

test2_correct_result


 

Ostatnia rzecz dotyczy lambd, a kod z przykładem prezentuje się następująco:

       [Route("third")]
        public async Task ThirdTestAsync()
        {
            var isCatchBlockReached = false;
 
            try
            {
                Action action = async () =>
                {
                    await Task.Delay(1000);
                    throw new OperationException("BOOM !");
                };
 
                Func<Task> task = async () =>
                {
                    await Task.Delay(1000);
                    throw new OperationException("BOOM !");
                };
 
                await Task.Run(action);
            }
            catch (Exception e)
            {
                isCatchBlockReached = true;
            }
 
            if (!isCatchBlockReached)
                throw new OperationException("Exception not caught");
        }

Mamy to zadeklarowane dwie zmienne:

  • action typu Action czyli delegate void
  • task typu Func<Task> czyli delegate TResult, gdzie TResult  to Task

Następnie zmienna action jest wywoływana  w metodzie Task.Run. Pytanie ponownie brzmi: czy rzucony zostanie wyjątek „Exception not catched”? AHA, tak! Teraz użyliśmy await, więc poczekamy na rzucenie pierwszego wyjątku, złapiemy go i zmienimy wartość boola na true. Sprawdźmy zatem:

 

test3_exception

Czemu tak? Otóż jak wspomniałem Action to tak naprawdę delegate void wobec czego problem znów polega na tym, że nie oczekujemy na rezultat, a lambda zostaje uznana za wykonaną. Sterowanie natychmiast zostanie przekazane do operatora await przed instrukcją Task.Run, a następnie opuszczony zostanie blok try. W takim razie czy zamiana action na task coś zmieni ? Tak. W tym przypadku rezultat będzie taki jakiego się spodziewaliśmy, wartość isCatchBlockReached zostanie zmieniona na true, a serwer zwróci 200. Jeszcze jedna sprawa związana z Task.Run. Jeżeli parametr funkcji może być jednocześnie typu Action oraz Func<TResult> (tak jak w naszym przypadku), to domyślnie wybierany zostaje ten drugi – just in case 🙂 Morał tej części wpisu jest taki: kiedy widzicie asynchroniczną lambdę to sprawdźcie czy, aby na pewno zwróci Task.

 

Na dziś to tyle. Mam nadzieję, że wpis był ciekawy i pouczający, a może nawet uchroni Was w przyszłości przed błędami. Już niedługo pojawią się kolejne wpisy dotyczące Aurory, a tym czasem…

await DoWidzeniaAsync();

 

…boże jaki suchar.

You may also like...