Dobre praktyki – wydajność async\await dla skomplikowanych wyrażeń

O dobrych praktykach async\await pisałem już wielokrotnie. Dzisiaj zacznijmy od code review:

private static async void Test()
{
  int result = SyncFunction1() + SyncFunction2()*await TestAsync() + SyncFunction3();
}

private static int SyncFunction1()
{
  // jakas logika
  return 1;
}
private static int SyncFunction2()
{
  // jakas logika
  return 1;
}

private static int SyncFunction3()
{
  // jakas logika
  return 1;
}
private static async Task<int> TestAsync()
{
  return 5;
}

W powyższym kodzie mamy skomplikowane wyrażenie, które wygląda na synchroniczne:

int result = SyncFunction1() + SyncFunction2()*await TestAsync() + SyncFunction3();

Wiemy, że tak naprawdę musi zostać wygenerowana maszyna stanów. Powyższy przykład zawiera również wyrażenie, które jest przetwarzane standardowo od lewej do prawej strony, z uwzględnieniem nawiasów i kolejności wykonywania operatorów. Gdyby nie kod asynchroniczny, byłoby to łatwe – po prostu na stosie kolejne wyniki byłyby przechowywane.

Standardowy stos w async nie może zostać użyty bo po wskoczeniu w metodę asynchroniczną całość zostałaby wyczyszczona.

Z tego względu w .NET zaimplementowano tzw. stack spilling. Polega to na przeniesieniu wartości, które normalnie znajdowałby się na stosie, na stertę (stack->heap). Ma to oczywiście pewne konsekwencje takie jak dodatkowa alokacja obiektów czy boxing. Każdy zaalokowany obiekt referencyjny posiada kilka dodatkowych pól w przeciwieństwie do bardziej oszczędnych struktur. Ponadto GC będzie miał dodatkową robotę i powoduje to najwięcej strat. Przyjrzyjmy się wygenerowanej maszynie stanów:

[CompilerGenerated]
[StructLayout(LayoutKind.Auto)]
private struct <Test>d__0 : IAsyncStateMachine
{
public int <>1__state;
public AsyncVoidMethodBuilder <>t__builder;
public int <result>5__1;
private TaskAwaiter<int> <>u__$awaiter2;
private object <>t__stack;
void IAsyncStateMachine.MoveNext()
{
    try
    {
        int num = this.<>1__state;
        if (num != -3)
        {
            int arg_BF_0;
            int arg_BE_0;
            TaskAwaiter<int> taskAwaiter;
            if (num != 0)
            {
                int expr_1E = arg_BF_0 = Program.SyncFunction1();
                int expr_23 = arg_BE_0 = Program.SyncFunction2();
                taskAwaiter = Program.TestAsync().GetAwaiter();
                if (!taskAwaiter.IsCompleted)
                {
                    Tuple<int, int> tuple = new Tuple<int, int>(expr_1E, expr_23);
                    this.<>t__stack = tuple;
                    this.<>1__state = 0;
                    this.<>u__$awaiter2 = taskAwaiter;
                    this.<>t__builder.AwaitUnsafeOnCompleted<TaskAwaiter<int>, Program.<Test>d__0>(ref taskAwaiter, ref this);
                    return;
                }
            }
            else
            {
                Tuple<int, int> tuple = (Tuple<int, int>)this.<>t__stack;
                arg_BF_0 = tuple.Item1;
                arg_BE_0 = tuple.Item2;
                this.<>t__stack = null;
                taskAwaiter = this.<>u__$awaiter2;
                this.<>u__$awaiter2 = default(TaskAwaiter<int>);
                this.<>1__state = -1;
            }
            int arg_BE_1 = taskAwaiter.GetResult();
            taskAwaiter = default(TaskAwaiter<int>);
            int num2 = arg_BF_0 + arg_BE_0 * arg_BE_1 + Program.SyncFunction3();
            this.<result>5__1 = num2;
        }
    }
    catch (Exception exception)
    {
        this.<>1__state = -2;
        this.<>t__builder.SetException(exception);
        return;
    }
    this.<>1__state = -2;
    this.<>t__builder.SetResult();
}
[DebuggerHidden]
void IAsyncStateMachine.SetStateMachine(IAsyncStateMachine param0)
{
    this.<>t__builder.SetStateMachine(param0);
}
}

Na początku widzimy deklaracje pól wewnętrznych maszyny:

public int <>1__state;
public AsyncVoidMethodBuilder <>t__builder;
public int <result>5__1;
private TaskAwaiter<int> <>u__$awaiter2;
private object <>t__stack;

Wszystkie zmienne (numer stanu, buiilder, awaiter) były już wcześniej omawiane na blogu. Nowością jest _stack czyli wspomniany stos przechowywany na stercie (proszę zauważyć, że jest to zawsze typ referencyjny).

W programowaniu asynchronicznym, jeśli potrzebujemy użyć stosu (evaluation stack) umieszczamy go właśnie w tym polu (__stack). W większości wypadków będzie to lista wartości i wtedy do __stack przypisuje się Tuple<…> ze wszystkimi częściowymi wynikami.

W pierwszym stanie wykonujemy wszystko co jest po lewej stronie await, czyli SyncFunction1 oraz SyncFunction2:

int expr_1E = arg_BF_0 = Program.SyncFunction1();
int expr_23 = arg_BE_0 = Program.SyncFunction2();
taskAwaiter = Program.TestAsync().GetAwaiter();
if (!taskAwaiter.IsCompleted)
{
    Tuple<int, int> tuple = new Tuple<int, int>(expr_1E, expr_23);
    this.<>t__stack = tuple;
    this.<>1__state = 0;
    this.<>u__$awaiter2 = taskAwaiter;
    this.<>t__builder.AwaitUnsafeOnCompleted<TaskAwaiter<int>, Program.<Test>d__0>(ref taskAwaiter, ref this);
    return;
}

Wynik na stosie jest przechowywany jako Tuple<int,int>:

Tuple<int, int> tuple = new Tuple<int, int>(expr_1E, expr_23);
this.<>t__stack = tuple;

Jest to pierwsza “zbędna” alokacja. W kolejnym stanie zdejmujemy wartości ze stosu, uzyskując wynik SyncFunction1 oraz SyncFunction2. Na tym etapie mamy   również wynik asynchronicznej funkcji TestAsync:

Tuple<int, int> tuple = (Tuple<int, int>)this.<>t__stack;
    arg_BF_0 = tuple.Item1;
    arg_BE_0 = tuple.Item2;
    this.<>t__stack = null;
    taskAwaiter = this.<>u__$awaiter2;
    this.<>u__$awaiter2 = default(TaskAwaiter<int>);
    this.<>1__state = -1;
}
int arg_BE_1 = taskAwaiter.GetResult();
taskAwaiter = default(TaskAwaiter<int>);
int num2 = arg_BF_0 + arg_BE_0 * arg_BE_1 + Program.SyncFunction3();
this.<result>5__1 = num2;

Przyjrzyjmy się teraz sytuacji, gdzie niezbędne jest posiadanie więcej niż tylko dwóch stanów:

int result = await TestAsync()*SyncFunction1() + SyncFunction2()*(await TestAsync() + SyncFunction3()*await TestAsync());

Jeśli zajrzymy do IlSpy, zobaczymy masę alokacji m.in:

Tuple<int, int> tuple = new Tuple<int, int>(expr_AA, expr_AB);

Tuple<int, int, int, int> tuple2 = new Tuple<int, int, int, int>(arg_162_0, arg_162_1, expr_139, expr_146);

“Prosty” przykład, może poskutkować wieloma alokacji. Z tego względu, czasami lepiej wywołania asynchroniczne uszeregować poza wyrażeniem tzn.:

int resultAsync = await TestAsync();
int result = SyncFunction1() + SyncFunction2()*resultAsync;

Jeśli możemy kilka metod asynchronicznych wykonać w tym samym czasie, wtedy dużo lepiej jest użyć Task.WhenAll.

Powyższy kod nie doprowadzi do stack spilling, a wyniki tymczasowe będą przechowywane w zwyczajnych zmiennych lokalnych. Skutek będzie taki, że maszyna stanów będzie posiadała wiele pól, ale jest to lepsze w wielu przypadkach niż wspomniane alokacje (czas + overhead). Z drugiej strony, pola w maszynie stanów będą w pamięci aż do jej zakończenia, co może być czasochłonne (programowanie asynchroniczne). Wartości ze sterty zawsze można zwolnić, np. podczas przechodzenia z jednego stanu w drugi.

Leave a Reply

Your email address will not be published.