Async\Await–wydajność, część II (implementacja wewnętrzna)

Zanim przejdziemy do pokazania przykładów jak optymalizować async\await najpierw trzeba zrozumieć implementację wewnętrzną w .NET. Bez tego ciężko będzie cokolwiek optymalizować. Na początku async\await wydawał mi się również czymś niezwykłym, a jak zacząłem zagłębiać się w kod IL, okazało się, że to bardzo prosty mechanizm i mógłby być napisany przez każdego z nas.

Kod korzystający z async\await wygląda na synchroniczny. Pod spodem jednak są zwykłe callback’i – dokładnie tak jakby było to napisane w poprzednich wersjach framework’a. Przyjrzyjmy się następującemu przykładowi:

internal class Program
{
   private static void Main(string[] args)
   {
       var task= DoAsync(3);
   }

   private static async Task<int> DoAsync(int inputParameter)
   {
       int localVariable = 3;

       int before = Before();
       int asyncResult1 = await TaskMethod1(inputParameter);
       int afterTaskMethod1 = AfterTaskMethod1();
       int asyncResult2 = await TaskMethod2(inputParameter + asyncResult1+localVariable);
       AfterTaskMethod2();

       return asyncResult1 + asyncResult2 + inputParameter;
   }

   private static int Before()
   {
       return 1;
   }

   private static int AfterTaskMethod1()
   {
       return 1;
   }

   private static int AfterTaskMethod2()
   {
       return 1;
   }

   private static Task<int> TaskMethod1(int arg)
   {
       return Task.Factory.StartNew(() => arg);
   }

   private static Task<int> TaskMethod2(int arg)
   {
       return Task.Factory.StartNew(() =>
       {
           Thread.Sleep(500000);
           return arg;
           ;
       });
   }
}

Po kolei… Mamy asynchroniczną metodę DoAsync. Najpierw wywołuje ona zwykłą synchroniczną metodę Before. Kolejne wywołanie to TaskMethod1, która jest już asynchroniczna i korzystamy tutaj z await, aby nie blokować wywołania. Potem wywołujemy znów synchroniczną metodę a na końcu TaskMethod2, która jest asynchroniczna. Przekazujemy także wynik z TaskMethod1 do niej. Na końcu zwracamy jakąś tam liczbę, która składa się z z wyników dostarczonych przez poszczególne metody.

Logika nie ma kompletnie sensu tutaj. Stworzyłem tak przykład, aby pokazać jak to działa, gdy przeplatamy ze sobą asynchroniczne i synchroniczne metody.

Proszę zauważyć, że DoAsync zwraca nie integer ale Task<int>. Wszystkie metody oznaczone słowem async muszą zwracać Task (jawnie lub niejawnie). Teraz możemy zajrzeć do Reflector’a, aby przekonać się, co tak naprawdę jest generowane. W DoAsync mamy:

[AsyncStateMachine(typeof(<DoAsync>d__0)), DebuggerStepThrough]
private static unsafe Task<int> DoAsync(int inputParameter)
{
    <DoAsync>d__0 d__;
    Task<int> task;
    AsyncTaskMethodBuilder<int> builder;
    &d__.inputParameter = inputParameter;
    &d__.<>t__builder = AsyncTaskMethodBuilder<int>.Create();
    &d__.<>1__state = -1;
    builder = &d__.<>t__builder;
    &builder.Start<<DoAsync>d__0>(&d__);
    task = &&d__.<>t__builder.Task;
Label_003C:
    return task;
}

Proszę zauważyć, że nie ma tutaj słowa kluczowego async – tak naprawdę CLR nic o tym nie wiem. Cała funkcjonalność async\await nie wprowadza modyfikacji w CLR. Podczas kompilacji jest po prostu generowany specjalny kod, przedstawiony wyżej.

Przede wszystkim wygenerowana jest maszyna stanów. Atrybut [AsyncStateMachine(typeof(<DoAsync>d__0)), DebuggerStepThrough] po prostu wskazuje typ implementacji tej maszyny.

Maszyna stanów zawiera w formie pól publicznych wszelkie zmienne będące w obrębie metody async (w naszym przypadku DoAsync). Na przykład w DoAsync mamy inputParameter i jak widać w maszynie stanów również mamy publiczne pole inputParameter. Z kolei localVariable zostało przeniesione do samej implementacji maszyny, ponieważ nie ma konieczności ustawiania jej z poziomu DoAsync. Pole state określa aktualny stan maszyny – o tym później. Proszę zauważyć, że StateMachine zwraca Task, czyli wątek reprezentujący operację.

Przejdźmy teraz do samej implementacji StateMachine:

[CompilerGenerated]
private struct <DoAsync>d__0 : IAsyncStateMachine
{
    // Fields
    public int <>1__state;
    public AsyncTaskMethodBuilder<int> <>t__builder;
    private object <>t__stack;
    private TaskAwaiter<int> <>u__$awaiter6;
    public int <afterTaskMethod1>5__4;
    public int <asyncResult1>5__3;
    public int <asyncResult2>5__5;
    public int <before>5__2;
    public int <localVariable>5__1;
    public int inputParameter;

    // Methods
    private unsafe void MoveNext()
    {
        bool flag;
        int num;
        Exception exception;
        int num2;
        TaskAwaiter<int> awaiter;
        TaskAwaiter<int> awaiter2;
        int num3;
    Label_0000:
        try
        {
            flag = 1;
            num2 = this.<>1__state;
            switch ((num2 - -3))
            {
                case 0:
                    goto Label_0028;

                case 1:
                    goto Label_0034;

                case 2:
                    goto Label_0034;

                case 3:
                    goto Label_002D;

                case 4:
                    goto Label_002F;
            }
            goto Label_0034;
        Label_0028:
            goto Label_016F;
        Label_002D:
            goto Label_0089;
        Label_002F:
            goto Label_011A;
        Label_0034:;
        Label_0036:
            this.<localVariable>5__1 = 3;
            this.<before>5__2 = Program.Before();
            awaiter = Program.TaskMethod1(this.inputParameter).GetAwaiter();
            if (&awaiter.IsCompleted != null)
            {
                goto Label_00A8;
            }
            this.<>1__state = 0;
            this.<>u__$awaiter6 = awaiter;
            &this.<>t__builder.AwaitUnsafeOnCompleted<TaskAwaiter<int>, Program.<DoAsync>d__0>(&awaiter, this);
            flag = 0;
            goto Label_019F;
        Label_0089:
            awaiter = this.<>u__$awaiter6;
            this.<>u__$awaiter6 = new TaskAwaiter<int>();
            this.<>1__state = -1;
        Label_00A8:
            int introduced7 = &awaiter.GetResult();
            awaiter = new TaskAwaiter<int>();
            num3 = introduced7;
            this.<asyncResult1>5__3 = num3;
            this.<afterTaskMethod1>5__4 = Program.AfterTaskMethod1();
            awaiter = Program.TaskMethod2((this.inputParameter + this.<asyncResult1>5__3) + this.<localVariable>5__1).GetAwaiter();
            if (&awaiter.IsCompleted != null)
            {
                goto Label_0139;
            }
            this.<>1__state = 1;
            this.<>u__$awaiter6 = awaiter;
            &this.<>t__builder.AwaitUnsafeOnCompleted<TaskAwaiter<int>, Program.<DoAsync>d__0>(&awaiter, this);
            flag = 0;
            goto Label_019F;
        Label_011A:
            awaiter = this.<>u__$awaiter6;
            this.<>u__$awaiter6 = new TaskAwaiter<int>();
            this.<>1__state = -1;
        Label_0139:
            int introduced8 = &awaiter.GetResult();
            awaiter = new TaskAwaiter<int>();
            num3 = introduced8;
            this.<asyncResult2>5__5 = num3;
            Program.AfterTaskMethod2();
            num = (this.<asyncResult1>5__3 + this.<asyncResult2>5__5) + this.inputParameter;
            goto Label_0189;
        Label_016F:
            goto Label_0189;
        }
        catch (Exception exception1)
        {
        Label_0171:
            exception = exception1;
            this.<>1__state = -2;
            &this.<>t__builder.SetException(exception);
            goto Label_019F;
        }
    Label_0189:
        this.<>1__state = -2;
        &this.<>t__builder.SetResult(num);
    Label_019F:
        return;
    }

    [DebuggerHidden]
    private unsafe void SetStateMachine(IAsyncStateMachine param0)
    {
        &this.<>t__builder.SetStateMachine(param0);
        return;
    }
}
 

Najważniejsza metoda to MoveNext. Służy ona do wykonania kolejnego stanu. Co jest zatem poszczególnym stanem? Przyjrzyjmy się pierwszemu z nich:

this.<localVariable>5__1 = 3;
this.<before>5__2 = Program.Before();
awaiter = Program.TaskMethod1(this.inputParameter).GetAwaiter();
if (&awaiter.IsCompleted != null)
{
 goto Label_00A8;
}
this.<>1__state = 0;
this.<>u__$awaiter6 = awaiter;
&this.<>t__builder.AwaitUnsafeOnCompleted<TaskAwaiter<int>, Program.<DoAsync>d__0>(&awaiter, this);
flag = 0;

Najpierw wywoływana jest synchroniczna metoda Before a wynik jest przechowywany znów w polu klasy. Następnie przechodzimy do TaskMethod1. Ze względu, że jest ona asynchroniczna, korzystamy ze specjalnej klasy, TaskWaiter’a. Więcej tak naprawdę nie możemy zrobić w tym stanie więc wywołujemy return. Teraz musimy poczekać na zakończenie metody TaskMethod1. W momencie jej zakończenia, MoveNext znów zostanie wywołany, ale teraz pole state będzie zawierało numer kolejnego stanu, co spowoduje, że następujący kod zostanie wywołany:

int introduced7 = &awaiter.GetResult();
awaiter = new TaskAwaiter<int>();
num3 = introduced7;
this.<asyncResult1>5__3 = num3;
this.<afterTaskMethod1>5__4 = Program.AfterTaskMethod1();
awaiter = Program.TaskMethod2((this.inputParameter + this.<asyncResult1>5__3) + this.<localVariable>5__1).GetAwaiter();
if (&awaiter.IsCompleted != null)
{
 goto Label_0139;
}
this.<>1__state = 1;
this.<>u__$awaiter6 = awaiter;
&this.<>t__builder.AwaitUnsafeOnCompleted<TaskAwaiter<int>, Program.<DoAsync>d__0>(&awaiter, this);
flag = 0;
goto Label_019F;

Analogicznie w kolejnym stanie najpierw wywołujemy synchroniczną metodę AfterTaskMethod1, a potem, za pomocą TaskAwaiter’a wywołujemy TaskMethod2. Synchroniczne metody nie potrzebują żadnej szczególnej opieki, dlatego nie są one kryterium rozdzielania metody na kilka stanów.

Na końcu, ostatni stan wykonuje:

int introduced8 = &awaiter.GetResult();
awaiter = new TaskAwaiter<int>();
num3 = introduced8;
this.<asyncResult2>5__5 = num3;
Program.AfterTaskMethod2();
num = (this.<asyncResult1>5__3 + this.<asyncResult2>5__5) + this.inputParameter;

&this.<>t__builder.SetResult(num);

Kod po prostu zwróci wyjątek z rezultatem ustawionym na to, co w oryginalnym kodzie mamy w return.

Wiem, że w moim opisie pominąłem wiele stanów ale nie ma to znaczenia. Ogólnie zasada polega na wygenerowaniu nowej klasy, opakowującej lokalne zmienne i parametry wejściowe. Jak widać, nie ma żadnej czarnej magii tutaj – wszystko to stary, dobrze znany kod c#. Cała maszyna jest wykonywana w osobnym wątku i jak widać w DoAsync, natychmiast jest zwracany wątek:

[AsyncStateMachine(typeof(<DoAsync>d__0)), DebuggerStepThrough]
private static unsafe Task<int> DoAsync(int inputParameter)
{
    <DoAsync>d__0 d__;
    Task<int> task;
    // inicjalizacja maszyny itp.
    task = &&d__.<>t__builder.Task;
Label_003C:
    return task;
}

DoAsync zatem nie blokuje wywołania. Reszta logiki jest zawarta w MoveNext – wewnętrznie pole state jest aktualizowane co przełącza logikę do konkretnego stanu. Kryterium podziału metody asynchronicznej na stany jest liczba await.

W następnym postach będę kontynuował tematykę async\await i pokażę, jak drobne zmiany mają wpływ na to, co zostanie wygenerowane w StateMachine.

Leave a Reply

Your email address will not be published.