Самодельная машина состояний - асинхронный метод без async/await
Есть разные примеры декомпиляции асинхронного кода, которые показывают внутренности асинхронной Машины состояний. Но она выглядит сложно, и сходу непонятно, что к чему, и как это работает.
Можно ли взять для примера простой асинхронный метод и написать точно такой же по поведению, только без async/await? Чтобы поиграться с отладчиком, выполнить метод пошагово, понять, как работает.
Например этот:
private static async Task GoAsync()
{
for (int i = 1; i <= 10; i++)
{
await Task.Delay(200);
Console.WriteLine(i);
}
for (int i = 10; i > 0; i--)
{
await Task.Delay(200);
Console.WriteLine(i);
}
}
Метод просто считает с задержками с 1 до 10 и с 10 до 1.
Запуск:
static async Task Main(string[] args)
{
await GoAsync();
Console.WriteLine("Done.");
Console.ReadKey();
}
Ответы (2 шт):
Есть небольшая сложность в реализации самодельной Машины состояний: поместиться ровно в 1 метод не получится. Помимо состояния в методе есть переменные, в данном случае i, и ее где-то надо хранить. Поэтому будет использована пара дополнительных полей.
Для того, чтобы не нырять с головой в ручное управление потоками, я буду использовать пул потоков ThreadPool, а чтобы управлять таском, буду использовать TaskCompletionSource. Так же для простоты реализации я буду использовать Thread.Sleep вместо его асинхронного аналога, иначе мне бы пришлось писать еще одну машину состояний и связывать ее с основной. Цель данного ответа (как и вопроса) - показать максимально простую Машину состояний, сделанную своими руками.
Вот решение, в комментариях пометил, какие строки кода выполняют ту же функцию, что и в исходном методе:
private static int counter;
private static TaskCompletionSource tcs;
private static Task GoAsync(int state = 0)
{
switch (state)
{
case 0:
tcs = new TaskCompletionSource();
counter = 1; // for (int i = 1;
state = 1;
break;
case 1:
if (counter <= 10) // i <= 10;
{
Thread.Sleep(200); // await Task.Delay(200);
Console.WriteLine(counter); // Console.WriteLine(i);
counter++; // i++)
}
else
state = 2;
break;
case 2:
counter = 10; // for (int i = 10;
state = 3;
break;
case 3:
if (counter > 0) // i > 0;
{
Thread.Sleep(200); // await Task.Delay(200);
Console.WriteLine(counter); // Console.WriteLine(i);
counter--; // i--)
}
else
state = 4;
break;
case 4:
tcs.SetResult();
return null;
}
ThreadPool.QueueUserWorkItem(s => GoAsync(s), state, false);
return tcs.Task;
}
Это и есть реализация шаблона проектирования Конечный автомат или его еще называют - Машина состояний.
Теперь можно ставить точку останова и смотреть в отладчике, как это работает наглядно. Напомню, что эта реализация сильно упрощена и имеет свои ограничения, например все сломается, если метод запустить параллельно дважды.
Приведу более приближенную к реальности реализацию. Для начала, заменим все циклы на циклы while и пронумеруем состояния:
private static async Task GoAsync()
{
int i;
/*0*/ i = 1;
/*1*/ while (i <= 10)
{
await Task.Delay(200);
/*2*/ Console.WriteLine(i);
i++;
}
i = 10;
/*3*/ while (i > 0)
{
await Task.Delay(200);
/*4*/ Console.WriteLine(i);
i--;
}
}
Условия нумерации состояний простые - нумеровать нужно начальную точку (0), любую точку, в которую исполнение может прийти несколькими путями (1 и 3), а также точки возврата из оператора await (2 и 4).
Теперь можно начать писать конечный автомат:
class FSM
{
private readonly TaskCompletionSource<object> completion = new TaskCompletionSource<object>();
private TaskAwaiter awaiter;
private int i;
private int state = 0;
void Next()
{
try
{
switch (state)
{
case 0:
i = 1;
goto case 1;
case 1:
if (i <= 10)
{
awaiter = Task.Delay(200).GetAwaiter();
if (awaiter.IsCompleted) goto case 2;
state = 2;
awaiter.OnCompleted(Next);
break;
}
i = 10;
goto case 3;
case 2:
awaiter.GetResult();
Console.WriteLine(i);
i++;
goto case 1;
case 3:
if (i > 0)
{
awaiter = Task.Delay(200).GetAwaiter();
if (awaiter.IsCompleted) goto case 4;
state = 4;
awaiter.OnCompleted(Next);
break;
}
state = -1;
completion.SetResult(null);
break;
case 4:
awaiter.GetResult();
Console.WriteLine(i);
i--;
goto case 3;
}
}
catch (Exception ex)
{
state = -1;
completion.SetException(ex);
}
}
public static Task GoAsync()
{
var fsm = new FSM();
fsm.Next();
return fsm.completion.Task;
}
}
Получившийся конечный автомат примерно соответствует тому, который строит компилятор, со следующими отличиями:
- компилятор создаёт
completionне сразу, а лениво, чтобы не создавать лишних объектов если функция вернулась без выполнения операторов await; - компилятор в релизном режиме использует структуру вместо класса для тех же целей;
- компилятор кеширует делегат Next;
- компилятор запоминает и восстанавливает контекст исполнения (ExecutionContext).