Другой способ понять, как работает async/await в C#
Про закулисье 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 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 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 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 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
-методы — это сопрограммы (coroutines), работающие по принципу кооперативной многозадачности. Они добровольно выходят (натурально делаютreturn
) в моменты начала асинхронности, но перед этим заручаются обещанием, что их обязательно позовут опять, чтобы они могли продолжиться.