Про закулисье 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) в моменты начала асинхронности, но перед этим заручаются обещанием, что их обязательно позовут опять, чтобы они могли продолжиться.
