W kolejnych kilku wpisach, zajmiemy się async\await ale od strony wydajnościowej. Jeśli ktoś nie wie do czego te słowa kluczowe służą, zachęcam przeczytać np. mój artykuł o programowaniu asynchronicznym w .NET 4.5.
Sposób korzystania z async\await jest bardzo prosty. Do tego stopnia, że programowanie asynchroniczne wygląda w zasadzie tak jak synchroniczne podejście. W kodzie nie ma callback’ow a przepływ logiki wygląda na sekwencyjny. Niestety, trzeba być świadomym jakie pułapki czekają na programistów.
Jedną z nich jest przekazanie kontekstu. Wiadomo, żeby zaktualizować interfejs należy skorzystać z SynchronizationContenxt, który potrafi wpompować dane do UI Thread. Await pobiera kontekst w sposób niejawny, przed wywołaniem asynchronicznej metody, a potem po jej zakończeniu, wykorzystywany jest on aby powrócić do wykonywania kodu.
Pobranie kontekstu ma swoją cenę i może spowodować problemy. Poniższy kod jest dowodem, że kontekst jest naprawdę przekazywany:
private static async Task DoAsync() { await AnotherMethodAsync(); // any logic } private static void AnotherMethod() { Thread.Sleep(1000); // any logic } private static Task AnotherMethodAsync() { Task task = Task.Factory.StartNew(AnotherMethod); return task; } private void Button_Click_1(object sender, RoutedEventArgs e) { Task task = DoAsync(); task.Wait(); MessageBox.Show("End"); }
Spodziewalibyśmy się, że na ekranie pojawi się wiadomość. Niestety mamy do czynienia z deadlock. W DoAsync korzystamy z await, który przed wejściem do metody, pobierze kontekst, aby po jej zakończeniu móc kontynuować. W tym problem, że my wywołujemy Task.Wait, który będzie blokował wywołanie aż do momentu zakończenia AnotherMetodAsync. Ta metoda jednak nigdy się nie zakończy bo await czeka aż będzie mógł wpompować dane.
Innymi słowy, await czeka na zakończenie task.Wait co nigdy się nie stanie bo task.Wait czeka aż await się zakończy. Z tego względu dobrą praktyką jest unikanie Wait i korzystanie z await wszędzie:
private async void Button_Click_1(object sender, RoutedEventArgs e) { await DoAsync(); MessageBox.Show("End"); }
Jak wspomniałem, przekazanie kontekstów ma swoją cenę. Zwykle to jest to czego oczekujemy ponieważ po wykonaniu asynchronicznego kodu, chcemy np. zaktualizować interfejs. Czasami jednak, mamy w pętli serie await i nie ma sensu za każdym razem pobierać kontekstu. Wtedy można skorzystać z metody ConfigureAwait i przekazać false, co oznacza, że kontekst nie zostanie pobrany:
private static async Task DoAsync() { await AnotherMethodAsync().ConfigureAwait(false); // any logic } private static void AnotherMethod() { Thread.Sleep(1000); // any logic } private static Task AnotherMethodAsync() { Task task = Task.Factory.StartNew(AnotherMethod); return task; } private void Button_Click_1(object sender, RoutedEventArgs e) { Task task = DoAsync(); task.Wait(); MessageBox.Show("End"); }
Powyższy kod nie spowoduje zakleszczenia ponieważ nie pobieramy kontekstu oraz nie próbujemy do niego wrócić po zakończeniu await. Należy uważać kiedy z takiego podejścia korzystamy. W przypadku, gdy piszemy bibliotekę, wykonującą w jakieś pętli różne metody asynchroniczne wtedy pobieranie kontekstu za każdy razem jest zmarnowaniem zasobów. Powyższe rozważania nie tyczą się wyłącznie SynchronizationContext ale wszystkich dostępnych w .NET.
Dlaczego w przypadku aplikacji konsolowej dla powyższego kodu bez ConfigureAwait(false) nie występuje deadlock?
Na konsoli, domyślnie każdy wątek jest sobie “równy”, więc await uruchamia resztę metody zawsze na ThreadPool (tak jakby był tam ConfigureAwait). W WPF występuje wątek główny i jeśli przed awaitem byliśmy na nim to po powrocie na niego wracamy. Za to jeśli uruchomimy to w WPF na innym wątku to zachowa się jak w konsoli.
Technicznie różnice między tymi wątkami kryją się w SynchronizationContext, który w CLI jest domyślnie null, a WPF ustawia go w głównym wątku na DispatcherSynchronizationContext
>> Dlaczego w przypadku aplikacji konsolowej dla powyższego kodu bez ConfigureAwait(false) nie występuje deadlock?
Vide https://msdn.microsoft.com/en-us/magazine/jj991977.aspx Figure 3 A Common Deadlock Problem When Blocking on Async Code:
“(…) The root cause of this deadlock is due to the way await handles contexts. By default, when an incomplete Task is awaited, the current “context” is captured and used to resume the method when the Task completes. This “context” is the current SynchronizationContext unless it’s null, in which case it’s the current TaskScheduler. GUI and ASP.NET applications have a SynchronizationContext that permits only one chunk of code to run at a time. When the await completes, it attempts to execute the remainder of the async method within the captured context. But that context already has a thread in it, which is (synchronously) waiting for the async method to complete. They’re each waiting for the other, causing a deadlock.
Note that console applications don’t cause this deadlock. They have a thread pool SynchronizationContext instead of a one-chunk-at-a-time SynchronizationContext, so when the await completes, it schedules the remainder of the async method on a thread pool thread. The method is able to complete, which completes its returned task, and there’s no deadlock. This difference in behavior can be confusing when programmers write a test console program, observe the partially async code work as expected, and then move the same code into a GUI or ASP.NET application, where it deadlocks. (…)”