Pull to refresh

Как на самом деле работает Async/Await в C# (Часть 5)

Level of difficultyHard
Reading time6 min
Views11K
Original author: Stephen Toub

Так как оригинальная статья довольно объемная, я взял на себя смелость разбить ее на несколько независимых частей, более легких для перевода и восприятия.

Disclaimer: Я не являюсь профессиональным переводчиком, перевод подготовлен скорее для себя и коллег. Я буду благодарен за любые исправления и помощь в переводе, статья очень интересная давайте сделаем её доступной на русском языке.

  1. Часть 1: В самом начале…

  2. Часть 2: Асинхронная модель на основе событий (EAP)

  3. Часть 3: Появление Tasks (Асинхронная модель на основе задач (TAP)

  4. Часть 4: ...и ValueTasks

  5. Часть 5: Итераторы C# в помощь

  6. Часть 6: Async/await: Внутреннее устройство

  7. Часть 7: SynchronizationContext и ConfigureAwait и поля в State Machine

Итераторы C# в помощь

Проблеск надежды на такое решение появился за несколько лет до появления Task в C# 2.0, когда в нем была добавлена поддержка итераторов.

«Итераторы?» - спросите вы? «Вы имеете в виду для IEnumerable?». Именно так. Итераторы позволяют вам написать один метод, который затем используется компилятором для реализации IEnumerable и/или IEnumerator. Например, если бы я хотел создать перечислитель, который выводил бы последовательность Фибоначчи, я мог бы написать что-то вроде этого:

public static IEnumerable<int> Fib()
{
    int prev = 0, next = 1;
    yield return prev;
    yield return next;

    while (true)
    {
        int sum = prev + next;
        yield return sum;
        prev = next;
        next = sum;
    }
}

Затем я могу перечислить их с помощью функции foreach:

foreach (int i in Fib())
{
    if (i > 100) break;
    Console.Write($"{i} ");
}

Я могу компоновать его с другими IEnumerable с помощью комбинаторов, подобных тем, что используются в System.Linq.Enumerable:

foreach (int i in Fib().Take(12))
{
    Console.Write($"{i} ");
}

Или я могу просто вручную перечислить их непосредственно через IEnumerator:

using IEnumerator<int> e = Fib().GetEnumerator();
while (e.MoveNext())
{
    int i = e.Current;
    if (i > 100) break;
    Console.Write($"{i} ");
}

Все вышеперечисленные действия приводят к такому результату:

0 1 1 2 3 5 8 13 21 34 55 89

Самое интересное в этом то, что для достижения вышеописанного нам нужно иметь возможность входить и выходить из метода Fib несколько раз. Мы вызываем MoveNext, он входит в метод, затем метод выполняется, пока не встретит возврат yield, в этот момент вызов MoveNext должен вернуть true, а последующее обращение к Current должно вернуть значение yield. Затем мы снова вызываем MoveNext, и нам нужно иметь возможность вернуться в Fib сразу после того, как мы остановились, и со всем состоянием предыдущего вызова. Итераторы — это фактически корутины, предоставляемые языком/компилятором C#, причем компилятор расширяет мой итератор Fib до полноценной машины состояний:

public static IEnumerable<int> Fib() => new <Fib>d__0(-2);

[CompilerGenerated]
private sealed class <Fib>d__0 : IEnumerable<int>, IEnumerable, IEnumerator<int>, IEnumerator, IDisposable
{
    private int <>1__state;
    private int <>2__current;
    private int <>l__initialThreadId;
    private int <prev>5__2;
    private int <next>5__3;
    private int <sum>5__4;

    int IEnumerator<int>.Current => <>2__current;
    object IEnumerator.Current => <>2__current;

    public <Fib>d__0(int <>1__state)
    {
        this.<>1__state = <>1__state;
        <>l__initialThreadId = Environment.CurrentManagedThreadId;
    }

    private bool MoveNext()
    {
        switch (<>1__state)
        {
            default:
                return false;
            case 0:
                <>1__state = -1;
                <prev>5__2 = 0;
                <next>5__3 = 1;
                <>2__current = <prev>5__2;
                <>1__state = 1;
                return true;
            case 1:
                <>1__state = -1;
                <>2__current = <next>5__3;
                <>1__state = 2;
                return true;
            case 2:
                <>1__state = -1;
                break;
            case 3:
                <>1__state = -1;
                <prev>5__2 = <next>5__3;
                <next>5__3 = <sum>5__4;
                break;
        }
        <sum>5__4 = <prev>5__2 + <next>5__3;
        <>2__current = <sum>5__4;
        <>1__state = 3;
        return true;
    }

    IEnumerator<int> IEnumerable<int>.GetEnumerator()
    {
        if (<>1__state == -2 &&
            <>l__initialThreadId == Environment.CurrentManagedThreadId)
        {
            <>1__state = 0;
            return this;
        }
        return new <Fib>d__0(0);
    }

    IEnumerator IEnumerable.GetEnumerator() => ((IEnumerable<int>)this).GetEnumerator();
    void IEnumerator.Reset() => throw new NotSupportedException();
    void IDisposable.Dispose() { }
}

Вся логика для Fib теперь находится внутри метода MoveNext, но как часть таблицы переходов, которая позволяет реализации перейти к тому месту, где она остановилась в последний раз, что отслеживается в сгенерированном поле состояния типа enumerator. А переменные, которые я написал как локальные, такие как prev, next и sum, были «подняты» в поля перечислителя, чтобы они могли сохраняться во всех вызовах MoveNext.

(Обратите внимание, что предыдущий фрагмент кода, показывающий, как компилятор C# выдает реализацию, не будет компилироваться как есть. Компилятор C# синтезирует "невыразительные" имена, то есть он называет создаваемые им типы и члены так, как это допустимо в IL, но недопустимо в C#, чтобы не вступать в конфликт с любыми пользовательскими типами и членами. Я назвал все так, как это делает компилятор, но если вы хотите поэкспериментировать с компиляцией, вы можете переименовать все так, чтобы вместо этого использовать имена, допустимые в C#).

В моем предыдущем примере последняя форма перечисления, которую я показал, включала ручное использование IEnumerator. На этом уровне мы вручную вызывали MoveNext(), решая, когда наступит подходящий момент для повторного входа в корутину. Но... что если вместо того, чтобы вызывать ее таким образом, я мог бы сделать так, чтобы следующий вызов MoveNext был частью работы продолжения, выполняемой при завершении асинхронной операции? Что если бы я мог yield return что-то, представляющее асинхронную операцию, а потребляющий код подключал бы продолжение к этому объекту, и это продолжение затем выполняло бы MoveNext? При таком подходе я мог бы написать вспомогательный метод следующего вида:

static Task IterateAsync(IEnumerable<Task> tasks)
{
    var tcs = new TaskCompletionSource();

    IEnumerator<Task> e = tasks.GetEnumerator();

    void Process()
    {
        try
        {
            if (e.MoveNext())
            {
                e.Current.ContinueWith(t => Process());
                return;
            }
        }
        catch (Exception e)
        {
            tcs.SetException(e);
            return;
        }
        tcs.SetResult();
    };
    Process();

    return tcs.Task;
}

Теперь это становится интересным. Нам дано перечислимое множество задач, которые мы можем перебирать. Каждый раз, когда мы переходим к следующей Task и получаем ее, мы подключаем продолжение к этой Task; когда эта Task завершается, она просто разворачивается и обращается обратно к той же логике, которая выполняет MoveNext, получает следующую Task и так далее. Это основано на идее Task как единого представления для любой асинхронной операции, поэтому перечислимое, которое мы получаем, может быть последовательностью любых асинхронных операций. Откуда может взяться такая последовательность? Конечно же, из итератора. Помните наш предыдущий пример CopyStreamToStream и то, насколько ужасной была реализация на основе APM? Рассмотрим это вместо него:

static Task CopyStreamToStreamAsync(Stream source, Stream destination)
{
    return IterateAsync(Impl(source, destination));

    static IEnumerable<Task> Impl(Stream source, Stream destination)
    {
        var buffer = new byte[0x1000];
        while (true)
        {
            Task<int> read = source.ReadAsync(buffer, 0, buffer.Length);
            yield return read;
            int numRead = read.Result;
            if (numRead <= 0)
            {
                break;
            }

            Task write = destination.WriteAsync(buffer, 0, numRead);
            yield return write;
            write.Wait();
        }
    }
}

Вау, это почти разборчиво. Мы вызываем помощника IterateAsync, и перечислимое, которое мы ему передаем, создается итератором, который обрабатывает весь поток управления для копии. Он вызывает Stream.ReadAsync и затем yield return эту Task; эта отданная задача - то, что будет передано IterateAsync после вызова MoveNext, и IterateAsync подключит продолжение к этой Task, которая, когда завершится, просто вызовет MoveNext и окажется снова в этом итераторе сразу после yield. В этот момент логика Impl получает результат метода, вызывает WriteAsync и снова выдает созданную Task. И так далее.

И это, друзья мои, начало async/await в C# и .NET. Примерно 95% логики поддержки итераторов и async/await в компиляторе C# является общей. Разный синтаксис, разные типы, но в основе своей это одно и то же преобразование. Присмотритесь к yield return, и вы почти увидите вместо них awaits.

На самом деле, некоторые смекалистые разработчики использовали итераторы подобным образом для асинхронного программирования еще до появления async/await. Подобное преобразование было прототипировано в экспериментальном языке программирования Axum, который послужил ключевым источником вдохновения для поддержки асинхронности в C#. Axum предоставлял ключевое слово async, которое можно было поместить в метод, точно так же, как async сейчас в C#. Task еще не был повсеместным, поэтому внутри методов async компилятор Axum эвристически сопоставлял вызовы синхронных методов с их APM-аналогами, например, если он видел, что вы вызываете stream.Read, он находил и использовал соответствующие методы stream.BeginRead и stream.EndRead, синтезируя соответствующий делегат для передачи методу Begin, а также генерировал полную APM-реализацию для определяемого метода async, чтобы она была композиционной. Он даже интегрировался с SynchronizationContext! Хотя Axum в конечном итоге был отложен, он послужил потрясающим и мотивирующим прототипом для того, что в итоге стало async/await в C#.

Tags:
Hubs:
Total votes 11: ↑11 and ↓0+11
Comments3

Articles