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


ссылка на оригинал статьи https://habr.com/ru/articles/799521/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *