Pull to refresh

Другой способ понять, как работает async/await в C#

Level of difficultyMedium
Reading time7 min
Views8.2K

Про закулисье async/await написано предостаточно. Как правило, авторы декомпилируют IL-код, смотрят на IAsyncStateMachine и объясняют, вот дескать какое преобразование случилось с нашим исходным кодом. Из бесконечно-длинной прошлогодней статьи Стивена Тауба можно узнать мельчайшие детали реализации. Короче, всё давно рассказано. Зачем ещё одна статья?

Я приглашаю читателя пройти со мной обратным путём. Вместо изучения декомпилированного кода мы поставим себя на место дизайнеров языка C# и шаг за шагом превратим async/await в код, который почти идентичен тому, что синтезирует Roslyn.

Начнём с простого async-метода:

async Task Example1() { 
    var text = await File.ReadAllTextAsync("input"); 
    await File.WriteAllTextAsync("output", text); 
    Console.WriteLine("done"); 
} 

С помощью TaskCompletionSource и инфраструктурного хелпера GetAwaiter() линейный асинхронный код легко переписывается на continuation-passing style:

Task Example1() {
    var resultSource = new TaskCompletionSource();
    var awaiter1 = File.ReadAllTextAsync("input").GetAwaiter();

    awaiter1.OnCompleted(delegate {
        var text = awaiter1.GetResult();
        var awaiter2 = File.WriteAllTextAsync("output", text).GetAwaiter();

        awaiter2.OnCompleted(delegate {
            Console.WriteLine("done");
            resultSource.SetResult();
        });
    });

    return resultSource.Task;
}
// Эх, во времена jQuery каждый день писал такие конструкции...

В этом коде две проблемы:

  • Callback hell — пирамида из вложенных анонимных функций, захватывающих переменные из разных областей.

  • Ускользают исключения. Нужно их всех поймать и перенаправить в resultSource.SetException(...).

Чтобы выпрямить вложенность, сделаем каждую функцию отдельным методом нового класса, а некоторые локальные переменные — его полями:

class Example1 {
    TaskCompletionSource ResultSource;
    TaskAwaiter<string> Awaiter1;
    TaskAwaiter Awaiter2;

    public Task Invoke() {
        ResultSource = new();
        Awaiter1 = File.ReadAllTextAsync("input").GetAwaiter();
        Awaiter1.OnCompleted(Continuation1);
        return ResultSource.Task;
    }

    void Continuation1() {
        var text = Awaiter1.GetResult();
        Awaiter2 = File.WriteAllTextAsync("output", text).GetAwaiter();
        Awaiter2.OnCompleted(Continuation2);
    }

    void Continuation2() {
        Console.WriteLine("done");
        ResultSource.SetResult();
    }
}

Чтобы не терять исключения, обрамим все методы в try/catch:

public Task Invoke() {
    ResultSource = new();
    try {
        // . . .
    } catch(Exception x) {
        ResultSource.SetException(x);
    }
    return ResultSource.Task;
}

void Continuation1() {
    try {
        // . . .
    }
}

void Continuation2() {
    try {
        Awaiter2.GetResult(); // Check for Exception
        // . . .
    }
}

Важно: у каждого Awaiter-а нужно тронуть GetResult(), даже если результат не нужен. Именно оттуда кидаются исключения, сигнализирующие о неуспехе ожидаемой операции.

По идее, дальше мы должны задуматься о том, как не повторять одинаковый try/catch. Но давайте приостановимся и посмотрим на более интересный исходник:

async Task Example2() {
    for(var i = 1; i < 3; i++) {
        var text = await File.ReadAllTextAsync("input" + i);
        await File.WriteAllTextAsync("output" + i, text);
    }
    Console.WriteLine("done");
}

Тут цикл с await внутри. Переписывание его на continuations уже не видится очевидным. Но надо же что-то делать... Давайте "упростим" цикл:

async Task Example2() {
    var i = 1;

loop:
    if(i < 3) {
        var text = await File.ReadAllTextAsync("input" + i);
        await File.WriteAllTextAsync("output" + i, text);
        i++;
        goto loop;
    } else {
        Console.WriteLine("done");
    }
}

Прелестно! Зато теперь легче смекнуть, что goto можно превратить в асинхронно-рекурсивную локальную функцию:

Task Example2() {
    var resultSource = new TaskCompletionSource();
    var i = 1;

    void Loop() {
        if(i < 3) {
            var awaiter1 = File.ReadAllTextAsync("input" + i).GetAwaiter();

            awaiter1.OnCompleted(delegate {
                var text = awaiter1.GetResult();
                var awaiter2 = File.WriteAllTextAsync("output" + i, text).GetAwaiter();

                awaiter2.OnCompleted(delegate {
                    i++;
                    Loop(); // goto
                });
            });
        } else {
            Console.WriteLine("done");
            resultSource.SetResult();
        }
    }

    Loop();

    return resultSource.Task;
}

Движемся по проторенной дорожке. Переписываем в виде класса:

class Example2 {
    TaskCompletionSource ResultSource;
    TaskAwaiter<string> Awaiter1;
    TaskAwaiter Awaiter2;
    int I;

    public Task Invoke() {
        ResultSource = new();
        I = 1;
        Loop();
        return ResultSource.Task;
    }

    void Loop() {
        if(I < 3) {
            Awaiter1 = File.ReadAllTextAsync("input" + I).GetAwaiter();
            Awaiter1.OnCompleted(Loop_Continuation1);
        } else {
            Console.WriteLine("done");
            ResultSource.SetResult();
        }
    }

    void Loop_Continuation1() {
        var text = Awaiter1.GetResult();
        Awaiter2 = File.WriteAllTextAsync("output" + I, text).GetAwaiter();
        Awaiter2.OnCompleted(Loop_Continuation2);
    }

    void Loop_Continuation2() {
        I++;
        Loop(); // goto
    }
}

Обратите внимание, счётчик цикла тоже стал полем I.

В прошлый раз мы стали добавлять try/catch в каждый метод, и это вылилось в повторение одинакового кода. Теперь, предвидя этот недостаток, сделаем ещё одно преобразование — склеим методы в единую конструкцию switch/case:

class Example2 {
    enum State {
        Initial,
        Loop,
        Loop_Continuation1,
        Loop_Continuation2,
        End
    }

    State CurrentState;

    TaskCompletionSource ResultSource;
    TaskAwaiter<string> Awaiter1;
    TaskAwaiter Awaiter2;
    int I;

    public Task Invoke() {
        ResultSource = new();
        CurrentState = State.Initial;
        InvokeCore();
        return ResultSource.Task;
    }

    void InvokeCore() {
        switch(CurrentState) {

            case State.Initial:
                I = 1;
                CurrentState = State.Loop;
                InvokeCore();
                return;

            case State.Loop:
                if(I < 3) {
                    Awaiter1 = File.ReadAllTextAsync("input" + I).GetAwaiter();
                    CurrentState = State.Loop_Continuation1;
                    Awaiter1.OnCompleted(InvokeCore);
                } else {
                    Console.WriteLine("done");
                    CurrentState = State.End;
                    ResultSource.SetResult();
                }
                return;

            case State.Loop_Continuation1:
                var text = Awaiter1.GetResult();
                Awaiter2 = File.WriteAllTextAsync("output" + I, text).GetAwaiter();
                CurrentState = State.Loop_Continuation2;
                Awaiter2.OnCompleted(InvokeCore);
                return;

            case State.Loop_Continuation2:
                I++;
                CurrentState = State.Loop;
                InvokeCore();
                return;
        }
    }
}

Вот и получилась стейт-машина (она же — конечный автомат). Метод InvokeCore переключает состояния и рекурсивно вызывает сам себя, пока не достигнет финала State.End.

InvokeCore() перед return — это хвостовая рекурсия, которую можно закоротить через goto case:

case State.Initial:
    I = 1;
    goto case State.Loop;
case State.Loop_Continuation2:
    I++;
    goto case State.Loop;

Аналогично можно поступить, если Awaiter1 или Awaiter2 сообщат, что операция фактически завершилась синхронно:

Awaiter1 = File.ReadAllTextAsync("input" + I).GetAwaiter();
if(Awaiter1.IsCompleted) {
    goto case State.Loop_Continuation1;
} else {
    CurrentState = State.Loop_Continuation1;
    Awaiter1.OnCompleted(InvokeCore);
}
Awaiter2 = File.WriteAllTextAsync("output" + I, text).GetAwaiter();
if(Awaiter2.IsCompleted) {
    goto case State.Loop_Continuation2;
} else {
    CurrentState = State.Loop_Continuation2;
    Awaiter2.OnCompleted(InvokeCore);
}

Теперь, когда весь код сосредоточен в одном обычном методе, можно его обернуть единым try/catch:

void InvokeCore() {
    try {
        // . . .
    } catch(Exception x) {
        CurrentState = State.End;
        ResultSource.SetException(x);
    }
}

И снова не забудем дёрнуть Awaiter2.GetResult(), чтобы не потерять исключения:

case State.Loop_Continuation2:
    Awaiter2.GetResult(); // Check for Exception
    I++;
    goto case State.Loop;

Наконец, если мы заменим TaskCompletionSource на похожий по API объект AsyncTaskMethodBuilder, то в результате получим код, практически идентичный "официальному", но более понятный, благодаря именованным меткам:

class Example2 : IAsyncStateMachine {
    // . . .
    AsyncTaskMethodBuilder ResultSource;
    // . . .

    public Task Invoke() {
        ResultSource = AsyncTaskMethodBuilder.Create();
        CurrentState = State.Initial;

        var stateMachine = this;
        ResultSource.Start(ref stateMachine);

        return ResultSource.Task;
    }

    void IAsyncStateMachine.MoveNext() {
        // Renamed from InvokeCore()
    }
}
-   Awaiter1.OnCompleted(InvokeCore);
+   var stateMachine = this;
+   ResultSource.AwaitUnsafeOnCompleted(ref Awaiter1, ref stateMachine);

-   Awaiter2.OnCompleted(InvokeCore);
+   var stateMachine = this;
+   ResultSource.AwaitUnsafeOnCompleted(ref Awaiter2, ref stateMachine);
Посмотреть код полностью
class Example2 : IAsyncStateMachine {
    enum State {
        Initial,
        Loop,
        Loop_Continuation1,
        Loop_Continuation2,
        End
    }

    State CurrentState;

    AsyncTaskMethodBuilder ResultSource;
    TaskAwaiter<string> Awaiter1;
    TaskAwaiter Awaiter2;
    int I;

    public Task Invoke() {
        ResultSource = AsyncTaskMethodBuilder.Create();
        CurrentState = State.Initial;

        var stateMachine = this;
        ResultSource.Start(ref stateMachine);

        return ResultSource.Task;
    }

    void IAsyncStateMachine.MoveNext() {
        try {
            switch(CurrentState) {

                case State.Initial:
                    I = 1;
                    goto case State.Loop;

                case State.Loop:
                    if(I < 3) {
                        Awaiter1 = File.ReadAllTextAsync("input" + I).GetAwaiter();
                        if(Awaiter1.IsCompleted) {
                            goto case State.Loop_Continuation1;
                        } else {
                            CurrentState = State.Loop_Continuation1;
                            var stateMachine = this;
                            ResultSource.AwaitUnsafeOnCompleted(ref Awaiter1, ref stateMachine);
                        }
                    } else {
                        Console.WriteLine("done");
                        CurrentState = State.End;
                        ResultSource.SetResult();
                    }
                    return;

                case State.Loop_Continuation1:
                    var text = Awaiter1.GetResult();
                    Awaiter2 = File.WriteAllTextAsync("output" + I, text).GetAwaiter();
                    if(Awaiter2.IsCompleted) {
                        goto case State.Loop_Continuation2;
                    } else {
                        CurrentState = State.Loop_Continuation2;
                        var stateMachine = this;
                        ResultSource.AwaitUnsafeOnCompleted(ref Awaiter2, ref stateMachine);
                    }
                    return;

                case State.Loop_Continuation2:
                    Awaiter2.GetResult(); // Check for Exception
                    I++;
                    goto case State.Loop;
            }
        } catch(Exception x) {
            CurrentState = State.End;
            ResultSource.SetException(x);
        }
    }

    void IAsyncStateMachine.SetStateMachine(IAsyncStateMachine stateMachine) {
        // https://stackoverflow.com/q/32548509
    }
}

В заключение, перечислю ключевые факты об async/await, большинство из которых удалось воочию увидеть в ходе выполнения этого упражнения:

  • Оригинальный код async-метода режется в точках, где возникает нелинейность — await или петля цикла.

  • Нарезанные фрагменты склеиваются в большое ветвление, которое становится новым методом IAsyncStateMachine.MoveNext.

  • Локальные переменные поднимаются в поля класса.

  • Выполнение продолжается синхронно до тех пор, пока не возникнет фактическая асинхронность — !Awaiter.IsCompleted.

  • Перед подпиской на асинхронные продолжения сохраняется метка ветви, с которой надо будет начать при следующем вызове MoveNext.

  • async-методы не запускают потоков. Возвращаемая Task — это так называемая promise-style task, которая ничего сама не делает, а только ожидает, что в конце концов её объявят завершённой через SetResult или SetException.

  • Уточнение: в предыдущем пункте правильнее было бы написать "само по себе преобразование async-метода в стейт-машину не приводит к запуску потока".

  • async-методы — это сопрограммы (coroutines), работающие по принципу кооперативной многозадачности. Они добровольно выходят (натурально делают return) в моменты начала асинхронности, но перед этим заручаются обещанием, что их обязательно позовут опять, чтобы они могли продолжиться.

Tags:
Hubs:
Total votes 21: ↑21 and ↓0+21
Comments26

Articles