Programowanie asynchroniczne w .NET 4.5  

Udostępnij na: Facebook

Autor: Piotr Zieliński

Opublikowano: 2012-04-03

Wprowadzenie

Aplikacje współbieżne odgrywają coraz ważniejszą rolę. Panuje trend, że producenci dokładają nowe rdzenie zamiast zwiększać moc obliczeniową pojedynczej jednostki. Z tego względu, aby w pełni wykorzystać możliwości dzisiejszych komputerów, kluczowym zagadnieniem jest zrównoleglenie pewnych operacji. Oczywiście, .NET dostarcza obsługę wątków już od pierwszej wersji, jednak w każdym wydaniu coś jest ulepszane. W .NET 1.0 do dyspozycji była klasa Thread, dosyć niewygodna w obsłudze, ponieważ nie można było nawet przekazać parametru do wątku. W .NET 2.0 pojawiła się taka możliwość, jednak również to rozwiązanie nie spełniało wszystkich oczekiwań. Bardzo ważną zmianą było wprowadzenie Task Parallel Library (TPL) w .NET 4.0 i traktowanie wszelkich obliczeń równoległych, jako zadań, które można ze sobą odpowiednio synchronizować.

Dlaczego programowanie współbieżne jest trudne?

W programowaniu współbieżnym zdecydowanie najtrudniejszą częścią jest programowanie sekwencyjne! Synchronizacja dostępu do danych może spowodować wiele problemów, takich jak zagłodzenie czy zakleszczenie.

Żadna technologia nie rozwiąże całkowicie tych problemów, ponieważ uzależnione są od interpretacji algorytmów. Aczkolwiek, przyjazne API może ułatwić poprawną implementację oraz umożliwić pisanie kodu bardziej przypominającego kod sekwencyjny. We wczesnych wersjach .NET, jedyną możliwością wykonania serii zadań była obsługa tzw. callback – metod wywołanych po zakończeniu zadania. Przepływ informacji, był zatem dość skomplikowany, ponieważ wymagało to stworzenia dodatkowych metod (callback), przekazania parametrów a często również stworzenia nowych klas, kontenerów, umożliwiających przekazanie większej liczby parametrów (w callback można było przekazać wyłącznie jeden parametr typu object).

Ułatwienia w .NET 4.5 – async oraz await.

W .NET 4.0 pojawiły się zadania (Task), które dały początek przyjaźniejszemu API. Celem jest pisanie kodu współbieżnego, przypominającego w swej strukturze jak najbardziej kod sekwencyjny. Rozważmy następujący synchroniczny kod:

private void DownloadAndSort()
{
      int[] allNumbers = DownloadNumbers();
      int[] sortedNumbers = SortNumbers(allNumbers);            
      MessageBox.Show(string.Join(“,”,sortedNumbers));
}
private int[] DownloadNumbers()
{
      Thread.Sleep(5000);
      int[] allNumbers = { 1, 24, 6, 46, 74, 64, 75, 6, 6, 5 };
      return allNumbers;
}
private int[] SortNumbers(int[] numbers)
{
      Thread.Sleep(5000);
      return numbers.OrderBy(n => n).ToArray();
}

W powyższym przykładzie metoda DownloadAndSort odpowiada za ściągniecie np. z sieci pewnych danych, a następnie ich posortowanie. Na końcu, wynik zostaje wyświetlony użytkownikowi. W celu zasymulowania czasochłonnej operacji została użyta po prostu funkcja Thread.Sleep. Po uruchomieniu kodu, interfejs użytkownika zostanie zablokowany, ponieważ obliczenia wykonywane są w sposób synchroniczny, obciążając tym samym główny wątek. W .NET 4.5 asynchroniczna wersja wygląda następująco:

private async void DownloadAndSortAsync()
{
      int[] allNumbers = await DownloadNumbersAsync();
      int[] sortedNumbers = await SortNumbersAsync(allNumbers);            
      MessageBox.Show(string.Join(“,”,sortedNumbers));
}
private Task<int[]> DownloadNumbersAsync()
{
      return Task<int[]>.Factory.StartNew(() => DownloadNumbers());
}
 private Task<int[]> SortNumbersAsync(int[] numbers)
{
      return Task<int[]>.Factory.StartNew(() => SortNumbers(numbers));
}

Jak widać, sporo zmian pojawiło się w .NET 4.5:

  1. Słowo kluczowe await. DownloadNumberAsync oraz SortNumberAsync wykonywane są w sposób asynchroniczny, w osobnym wątku, nie obciążając tym samym UI Thread. Zamiast niewygodnych metod callback, w .NET 4.5 wprowadzono słowo kluczowe await, które czeka na wynik wykonania metody. Po zakończeniu wątku (DownloadNumber), wykonanie metody jest wznawiane, a nowy wątek tworzony jest dla SortNumber. Na końcu, dysponując posortowanymi danymi, wynik zostaje wyświetlony. Słowo await, obserwuje zatem wątek i wznawia wykonanie kodu, gdy zakończy on działanie. Jednak, w przeciwieństwie do Thread.Join, await nie blokuje całej metody –  o tym później.
  2. Słowo kluczowe async. Każda metoda, która zawiera w sobie przynajmniej jedno wywołanie await, musi zostać oznaczona async. W powyższym przypadku, DownloadAndSortAsync wykonywana jest synchronicznie, aż do momentu napotkania wywołania await. Tutaj pojawia się różnica między await a starym Thread.Join. Join po prostu blokował wywołanie, aż do zakończenia wątku. W przypadku async oraz await, .NET framework będzie sprawdzał, czy wątek DownloadNumbersAsync nie zakończył działania, przy czym metoda async nie obciąża głównego wątku. Załóżmy, że w aplikacji jest następująca struktura:
private void button1_Click(object sender, EventArgs e)
{
      DownloadAndSortAsync();
      MessageBox.Show(“test”);
}
private async void DownloadAndSortAsync()
{
      int[] allNumbers = await DownloadNumbersAsync();
      int[] sortedNumbers = await SortNumbersAsync(allNumbers);            
      MessageBox.Show(string.Join(“,”,sortedNumbers));
}

W zdarzeniu button1_Click wywołana jest metoda DownloadAndSortAsync, opatrzona modyfikatorem async. Tak jak zostało to wyjaśnione wcześniej, wykonywana została synchronicznie (blokując interfejs), aż do momentu wywołania DownloadNumbersAsync, która tworzy wątek. Wywołanie jest opatrzone słowem await, zatem .NET Framework zaprzestaje dalszego wykonywania kodu, aż do momentu zakończenia tego wątku. W tym momencie, wykonanie kodu wraca z powrotem do buton1_Click i wyświetlona zostaje wiadomość „test”. Po zakończeniu wątku DownloadNumberAsync, wykonanie skacze z powrotem do następnej linii kodu, jakim jest SortNumbersAsync. Na końcu wyświetlana jest wiadomość z posortowanym wynikiem. Linia MessageBox.Show(„test”) nie zostanie drugi raz wykonana - .NET Framework pamięta, który kod został już wykonany – wiem, na początku może wydawać się to trochę dziwne i nielogiczne, ponieważ metody wyglądają jak synchroniczne! Z tego względu, metody zawierające wywołania await muszą być opatrzone słowem async – one są synchroniczne tylko do pewnego stopnia. Metody async mogą zwracać void, Task lub Task<T>. Jeśli metoda nic nie zwraca (void), nie może potem zostać ponownie opatrzona await. Dla powyższego przypadku, taki kod byłby nieprawidłowy:

await DownloadAndSortAsync

Jeśli potrzebna jest możliwość „czekania”, również na DownloadAndSortAsync należy zmienić zwracany typ na Task.

  1. Ostatnia zmiana to konwencja nazw. Wszystkie metody asynchroniczne powinny mieć nazwę zakończoną słowem Async. Jest to tylko umowna konwencja, niewymagana przez kompilator, jednak niezwykle istotna. W .NET 4.5 wprowadzono bardzo dużo metod asynchronicznych dla istniejących klas, np. tych z przestrzeni nazw System.IO. Obok starych metod, istnieją nowe, asynchroniczne.

Słowo await powoduje zatem wstrzymanie dalszego wykonania kodu. Jednak istnieje czasami potrzeba uruchomienia dwóch wątków jednoczenie, i odczekanie, aż zakończą one swoje działanie. Za pomocą .NET 4.5 jest to również możliwe:

Task<int[]> allNumbersTask1 = DownloadNumbersAsync();
Task<int[]> allNumbersTask2 = DownloadNumbersAsync();

await Task.WhenAll(allNumbersTask1, allNumbersTask2);
int[] numbers1 = allNumbersTask1.Result;
int[] numbers2 = allNumbersTask2.Result;

Nowe podejście w .NET 4.5

Powyższe nowości zmieniają dotychczasowy model programowania. Ze względu na to, że obsługa metod asynchronicznych praktycznie niczym nie różni się od kodu synchronicznego, wiele dotychczasowych operacji może zostać w łatwy sposób zrównoleglona. W .NET 4.5 pojawiło się bardzo dużo asynchronicznych odpowiedników. Szczególnie istotne jest to np. dla operacji na strumieniach, które mogą okazać się bardzo czasochłonne. Przykład asynchronicznego użycia strumieni:

private async void button1_Click(object sender, EventArgs e)
{
      byte[] content = await DownloadWebsiteAsync(@”https://www.microsoft.com”);            
}
private async Task<byte[]> DownloadWebsiteAsync(string url)
{
      var content = new MemoryStream();
      var webReq = (HttpWebRequest)WebRequest.Create(url);
      using (WebResponse response =  webReq.GetResponseAsync())
      {
            using (Stream responseStream = response.GetResponseStream())
            {
                  await responseStream.CopyToAsync(content);
             }
      }
      return content.ToArray();
}

Bez asynchronicznego programowania, kliknięcie w przycisk button1 zablokowałoby główny wątek. Kod bardzo przypomina synchroniczny przepływ, nie ma w nim metod callback. Z tego względu implementacja jest bardzo prosta oraz szybka, zatem w .NET 4.5 powinno zmienić się swoje podejście na bardziej współbieżne.

Na zakończenie, warto jeszcze raz podkreślić, że modyfikator async nie tworzy nowego wątku. Operacje wykonywane są nadal na głównym wątku, jednak w przypadku rozpoczęcia nowego, czasochłonnego zadania (await), metoda async nie blokuje już głównego wątku.

Zakończenie

Zmiany w .NET 4.5 z pewnością wpłyną na sposób pisania aplikacji. Wielowątkowość, która wcześniej była niewygodna w implementacji, stała się podstawowym mechanizmem. Implementacja kodu asynchronicznego nie różni się wiele od synchronicznego, co daje programistom .NET możliwości pisania kodu bardziej wydajnego, przystosowanego do współczesnej architektury komputerów. Oczywiście, nowe operatory upraszczają tylko składnie – problemy związane z zagłodzeniem czy zakleszczeniem pozostają nadal w kwestii programisty. Prostsza składnia pozwala jednak programiście skupić się bardziej na tym, co najważniejsze – algorytmach, a nie na trudnościach związanych z samym językiem.