Самодельная машина состояний - асинхронный метод без 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 шт):

Автор решения: aepot

Есть небольшая сложность в реализации самодельной Машины состояний: поместиться ровно в 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;
}

Это и есть реализация шаблона проектирования Конечный автомат или его еще называют - Машина состояний.

Теперь можно ставить точку останова и смотреть в отладчике, как это работает наглядно. Напомню, что эта реализация сильно упрощена и имеет свои ограничения, например все сломается, если метод запустить параллельно дважды.

→ Ссылка
Автор решения: Pavel Mayorov

Приведу более приближенную к реальности реализацию. Для начала, заменим все циклы на циклы 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;
    }
}

Получившийся конечный автомат примерно соответствует тому, который строит компилятор, со следующими отличиями:

  1. компилятор создаёт completion не сразу, а лениво, чтобы не создавать лишних объектов если функция вернулась без выполнения операторов await;
  2. компилятор в релизном режиме использует структуру вместо класса для тех же целей;
  3. компилятор кеширует делегат Next;
  4. компилятор запоминает и восстанавливает контекст исполнения (ExecutionContext).
→ Ссылка