Task.WhenAll vs Multiple awaits in foreach

Mateusz Gajda11/04/2018 - 2 min read

Photo by: Melinda Gimpel

Cześć, pod ostatnim postem, użytkownik DD zwrócił mi słuszną uwagę, którą chciałbym tutaj rozwinąć. Otóż chodziło o to aby zamiast w każdej iteracji pętli, wykonywać await na handlerze, można je wszystkie odpalić za pomocą metody Task.WhenAll(). W tym wpisie chciałbym omówić różnicę między tymi dwoma podejściami. Postaram się opisać za i przeciw a także samemu sprawdzić, w praktyce co okazuję się szybsze i mniej zawodne.

Różnice

Metoda Task.WhenAll() przyjmuje jako argument tablicę zadań a następnie zwraca nam obiekt typu Task. Jego ukończenie następuje po wykonaniu ostatniego z przekazanych do niego wszystkich zadań. W przypadku wystąpienia wyjątku, zostaje on zapisany do AggregateException, a status Taska ustawiony na Faulted. Program nie poinformuje nas o błędzie, a try catch który zastosujemy złapie tylko pierwszy wyjątek z góry. Musimy pamiętać że chcąc dostać pełną listę błędów, które wystąpiły w różnych zadaniach jesteśmy zmuszeni zrobić to sami, na przykład w ten sposób:

public async Task WhenAllTasks(List<Task> tasks)
{
var totalTasks = Task.WhenAll(tasks);
try{
await totalTasks;
}
catch{
throw totalTasks.Exception;
}
}

Ważnym faktem, o którym trzeba pamiętać decydując się na skorzystanie z metody WhenAll jest brak możliwości ustalenia kolejności wykonywania zadań. Jako że metoda ta jest częścią Task Parallel Library, zadania które zostaną do niej przekazane wykonywane są w sposób równoległy. Warto się zastanowić, czy to, co chcemy wykonać powinno mieć swoją ustaloną kolejność, tak aby nie doprowadzić np. do deadlocka. Innym ograniczeniem tej funkcji są zwracane typy. Mamy do wyboru albo skorzystać z podstawowej jej wersji, która zwraca nam jedynie obiekt typu Task a w nim informację o tym czy wszystko się powiodło, lub też skorzystanie z jej generycznej postaci Task.WhenAll<T>(). Narzuca nam to swojego rodzaju ograniczenie do jednego zwracanego typu. Decydując się na skorzystanie z await mamy pełną dowolność w zwracanym typie danych. Dzieje się tak dlatego że wywołujemy za pomocą niego tylko jedną interesującą nas metodę, a nie cały ich zestaw. Dodatkowo możemy zarządzać kolejnością wywoływań, co dość często jest bardzo porządane. Znaczącą różnicę również możemy zauważyć w przypadku wyjątków. Otóż każdy który wystąpi, a nie zostanie odpowiednio obsłużony, przerwie działanie programu. Warto więc pamiętać o tych różnicach.

Foreach, z czego skorzystać?

Z metody Task.WhenAll() powinniśmy korzystać za każdy razem, kiedy posiadamy zbiór zadań do wykonania, które spełniają wyżej opisane wymagania. Oczywiście możemy przy każdej iteracji pętli czekać na każde z nich za pomocą await ale będzie to bardzo nieefektywne. Żeby lepiej zobrazować co mam na myśli posłużę się przykładem:

public async Task<IEnumerable<UserDictionary>> GetUserDictionaries(string[] dictionaryNames)
{
var dictionaries = new List<UserDictionary>();
foreach (var dictionaryName in dictionaryNames)
{
var dictionary = await _dictionaryRepository.GetUserDictionary(dictionaryName);
dictionaries.Add(dictionary);
}
return dictionaries;
}

Powyższy kod ma za zadanie dla przekazanej tablicy stringów odpowiadającym nazwom słowników, zwrócić ich zawartość z bazy danych. Możemy zrobić to w sposób jak powyżej, czyli za pomocą foreach, dla każdej wartości z tablicy dictionaryNames wywołać metodę GetUserDictionary i odpowiedni słownik dopisać do listy, którą następnie zwrócimy. Warto jednak tu zauważyć, tak jak wcześniej wspominałem, że przy każdym wywołaniu metody z repozytorium będziemy czekać na jej zrealizowanie. Aby sprawić że proces będzie bardziej efektywny możemy zrefaktoryzować nasz kod w następujący sposób:

public async Task<IEnumerable<UserDictionary>> GetUserDictionaries(string[] dictionaryNames)
{
var tasks = new List<Task<UserDictionary>>();
foreach (var dictionaryName in dictionaryNames)
{
tasks.Add(_dictionaryRepository.GetUserDictionary(dictionaryName));
}
return await Task.WhenAll(tasks);
}

Tworzymy więc listę tasks, do ktorej dodamy wywołanie metody GetUserDictionary dla każdej wartości z tablicy dictionaryNames. Następnie za pomocą Task.WhenAll równolegle wykonamy wszystkie zadanie z tej listy. Zdaję sobie sprawę że powyższa implementacja mogłaby być zawarta bezpośrednio w repozytorium, aczkolwiek potrzebowałem jakiegoś dobrego przykładu który będzie pokazywał to o czym piszę, a te dwa powyższe idealnie się do tego nadają :) Sprawdziłem również które z tych dwóch podejść jest szybsze, w następujący sposób:

static async Task Main(string[] args)
{
var watch = Stopwatch.StartNew();
var tasks = new List<Task>();
foreach (var value in new[] { 100,200,300})
{
tasks.Add(Task.Delay(value));
}
await Task.WhenAll(tasks);
watch.Stop();
Console.WriteLine(WhenAll: + watch.ElapsedMilliseconds);
watch.Restart();
foreach (var value in new[] { 100, 200, 300 })
{
await Task.Delay(value);
}
watch.Stop();
Console.WriteLine(Multiple awaits: + watch.ElapsedMilliseconds);
}

Dzięki Krzysztof za zwrócenie uwagi na to że poprzedni przykład nie był poprawny. Pozwoliłem sobie skorzystać z twojej pomocy i edytować go według twoich uwag :) Wyniki prezentowały się następująco: ``` WhenAll: 301 Multiple awaits: 625

Jak widzimy dzięki WhenAll dwukrotnie przyśpieszyliśmy wykonanie naszych zadań.
# Podsumowanie
*Task.WhenAll()* odpowiednio zastosowany na pewno da kopa, jeżeli chodzi o szybkość wykonywanych przez niego zadań w stosunku do wielu awaitów. Jak wszystko, nieodpowiednio zastosowany może zrodzić nam problemy z deadlockami, czy niespodziewanymi wyjątkami, których zapomnieliśmy sprawdzić. Dlatego tam gdzie możemy korzystajmy z metody *WhenAll(),* a robiąc to z głową na pewno tylko na tym skorzystamy :)
export const _frontmatter = {"date":"2018-11-04","title":"Task.WhenAll vs Multiple awaits in foreach","cover":"cover.jpg","photoauthor":"Melinda Gimpel","hash":"45C57E6C801A4DE89EC4D3DA4EF30548","categories":["Dotnet"]}