W poprzednich wpisach omawialiśmy wewnętrzną implementację async\await. Dziś kolejne przykłady pokazujące, czego należy unikać aby optymalizować nasz kod. Jak wiemy, użycie await generuje masę kodu i niesie ze sobą alokację dodatkowych obiektów – maszyna stanów, wątki, wrappery itp.
W zasadzie sama alokacja w .NET nie jest jakiś wielkim problemem i jest znacząco szybsza niż w językach niezarządzanych. Niestety, pomimo wielu optymalizacji dokonywanych przez GC, późniejsze ich zwolnienie jest czasochłonne. Z tego względu, jeśli tylko to możliwe należy unikać zbędnej alokacji obiektów.
Rozważmy przykład. Załóżmy, że projektujemy interfejs generyczny zawierający metody typu Read, Write Calculate. Wszystkie one mają również odpowiedniki asynchroniczne. Z tego względu każdy kto chce zaimplementować później taki interfejs, musi dostarczy metodę asynchroniczną. W wielu przypadkach nie stanowi to problemu i jest czymś pożądanym. Istnieją jednak pewne sytuacje, w których nie ma najmniejszego sensu używanie wątków ponieważ operacja jest bardzo prosta i szybka. Załóżmy, że mamy taki przypadek i zaimplementowaliśmy naszą metodę następująco:
internal class Program { private static void Main(string[] args) { Test(); } private static async void Test() { int result = await UnoptimizedMethodAsync(5, 3); } private static async Task<int> UnoptimizedMethodAsync(int a, int b) { return a + b; } }
Dobrą stroną powyższego kodu jest fakt, że operacja jest wykonywana tak naprawdę synchronicznie co ma sens dla prostych obliczeń. Kod jednak ma pewną inną wadę.
Zaglądając do Reflector’a zobaczymy, że za każdym razem jest tworzony nowy wątek, który nie jest odpalany, a zwraca tylko wynik:
[DebuggerStepThrough, AsyncStateMachine(typeof(<UnoptimizedMethodAsync>d__3))] private static unsafe Task<int> UnoptimizedMethodAsync(int a, int b) { <UnoptimizedMethodAsync>d__3 d__; AsyncTaskMethodBuilder<int> builder; &d__.a = a; &d__.b = b; &d__.<>t__builder = AsyncTaskMethodBuilder<int>.Create(); &d__.<>1__state = -1; builder = &d__.<>t__builder; &builder.Start<<UnoptimizedMethodAsync>d__3>(&d__); return &&d__.<>t__builder.Task; }
Przechodzimy więc powoli do drugiej optymalizacji. Jeśli wiemy, że metoda zwraca ten sam wynik bardzo często wtedy lepiej buforować wątek. Załóżmy, że mamy deterministyczną metodę, która z definicji powinna wspierać buforowanie ponieważ cześć parametrów wejściowych będzie powtarzać się np. LoadModel:
private static async void Test() { object model = await LoadModelAsync("model.xml"); } private static async Task<object> LoadModelAsync(string path) { return new object(); // to tylko przyklad, niewazne co zwracamy }
Akurat w przypadku ładowania modelu, warto skorzystać z prawdziwego wątku ponieważ proces może trochę zająć. Z tego względu możemy w powyższej metodzie wywołać Task.Factory.New i wykonać operację w tle. Jeśli wywołujemy metodę kilka razy i parametr path powtarza się, wtedy warto buforować model np. za pomocą słownika. W przypadku asynchronicznej metody jednak, warto również buforować sam Task:
private static ConcurrentDictionary<string,Task<object>> _cache=new ConcurrentDictionary<string, Task<object>>(); private static async Task<object> LoadModelAsync(string path) { Task<object> modelTask; if (_cache.TryGetValue(path, out modelTask)) return modelTask; return Task.Factory.StartNew(() => new Object()) .ContinueWith(model => _cache.TryAdd(path, model), TaskContinuationOptions.ExecuteSynchronously); }
W powyższym kodzie buforujemy nie tylko model ale również Task. Z tego względu, gdy wywołujemy drugi raz asynchroniczną metodę nie musimy bez potrzeby alokować pamięci na Task, który i tak by w końcu nic nie robił a tylko zwracał zbuforowany model.
Podsumowując:
- Gdy operacja trwa bardzo krótko a interfejs wymusza zwrócenie Task (async), wykonajmy operację synchroniczne i wywołajmy np. Task.FromResult. Nie wykonujmy operacji asynchronicznie na siłę!
- Druga optymalizacja polega na buforowaniu Task. Często metody z natury zwracają te same wyniki (ładowanie danych z bazy itp.) W takich przypadkach programiści tworzą słownik, gdzie kluczem jest parametr metody a wartością zwracany wynik. W przypadku synchronicznych metod, takie rozwiązanie się sprawdza. Jeśli natomiast mamy metodę asynchroniczną, wtedy lepiej również buforować Task. Jeśli mamy zbuforowany wynik, spodziewamy się, że metoda niemalże zachowa się jak inline – zwróci po prostu wynik. Bez buforowania Task, zostanie wygenerowane masę niepotrzebnego kodu, który m.in. tworzy wątek zwracający natychmiast wynik.