Как стать автором
Обновить

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

Уровень сложностиСложный
Время на прочтение6 мин
Количество просмотров13K
Автор оригинала: 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

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

В .NET Framework 4.0 появился тип System.Threading.Tasks.Task. По своей сути Task - это просто структура данных, которая представляет собой возможное завершение некоторой асинхронной операции (в других платформах аналогичный тип называется «promise» или «future»).

Task создается для представления некоторой операции, а затем, когда операция, которую она логически представляет, завершается, результаты сохраняются в Task. Достаточно просто. Но ключевая особенность Task, которая делает его гораздо полезнее IAsyncResult, заключается в том, что она встраивает в себя понятие продолжения. Эта особенность означает, что вы можете подойти к любой задаче и попросить асинхронно уведомить вас о ее завершении, причем сама задача будет обрабатывать синхронизацию, чтобы обеспечить вызов продолжения независимо от того, завершилась ли задача, еще не завершилась или завершается одновременно с запросом на уведомление. Почему это так важно? Если вы вспомните наше обсуждение старого шаблона APM, то там было две основные проблемы.

  1. Вы должны были реализовать собственную реализацию iasyncresult для каждой операции: не было встроенной реализации IAsyncResult, которую можно было бы просто использовать для своих нужд.

  2. Вы должны были знать до вызова метода Begin, что вы хотите сделать после его завершения. Это делает реализацию комбинаторов и других обобщенных процедур для потребления и составления произвольных асинхронных реализаций весьма сложной задачей.

В отличие от Task, это общее представление позволяет вам подойти к асинхронной операции после того, как вы уже инициировали операцию, и предоставить продолжение после того, как вы уже инициировали операцию... вам не нужно предоставлять это продолжение методу, который инициирует операцию. Каждый, кто имеет асинхронные операции, может производить Task, а каждый, кто потребляет асинхронные операции, может потреблять Task, и для связи между ними не нужно делать ничего особенного: Task становится универсальным языком для общения производителей и потребителей асинхронных операций. И это изменило лицо .NET. Подробнее об этом чуть позже...

Вместо того чтобы погружаться в сложный код Task, мы поступим по-педагогически и просто реализуем простую версию. Это не претендует на большую реализацию, а лишь достаточно полная функциональность, чтобы помочь понять суть Task, которая, в конце концов, является просто структурой данных, координирующей подачу и прием сигнала о завершении. Мы начнем всего с нескольких полей:

class MyTask
{
    private bool _completed;
    private Exception? _error;
    private Action<MyTask>? _continuation;
    private ExecutionContext? _ec;
    ...
}

Нам нужно поле, чтобы знать, завершилась ли задача (_completed), и нам нужно поле для хранения любой ошибки, которая привела к неудаче задачи (_error); если бы мы также реализовывали общий MyTask<TResult>, было бы также private TResult _result для хранения успешного результата операции. Пока что это очень похоже на нашу пользовательскую реализацию IAsyncResult (не случайно, конечно). Но теперь самое главное - поле _continuation. В этой простой реализации мы поддерживаем только одно продолжение, но этого достаточно для пояснения (в настоящей Task используется поле object, которое может быть либо отдельным объектом продолжения, либо List<> объектов продолжения). Это делегат, который будет вызван, когда задача завершится.

Теперь немного о сути. Как уже отмечалось, одним из фундаментальных достижений Task по сравнению с предыдущими моделями была возможность предоставлять работу по продолжению (обратный вызов) после того, как операция была инициирована. Нам нужен метод, который позволит нам это сделать, поэтому добавим ContinueWith:

public void ContinueWith(Action<MyTask> action)
{
    lock (this)
    {
        if (_completed)
        {
            ThreadPool.QueueUserWorkItem(_ => action(this));
        }
        else if (_continuation is not null)
        {
            throw new InvalidOperationException("Unlike Task, this implementation only supports a single continuation.");
        }
        else
        {
            _continuation = action;
            _ec = ExecutionContext.Capture();
        }
    }
}

Если к моменту вызова ContinueWith задача уже была помечена как завершенная, ContinueWith просто ставит в очередь выполнение делегата. В противном случае метод сохраняет делегат, так что продолжение может быть поставлено в очередь, когда задача завершится (он также сохраняет нечто, называемое ExecutionContext, и затем использует это при последующем вызове делегата, но пока не беспокойтесь об этой части... мы к ней еще вернемся). Все достаточно просто.

Затем нам нужно иметь возможность пометить MyTask как завершенную, что означает, что асинхронная операция, которую она представляет завершилась. Для этого мы реализуем два метода, один для отметки успешного завершения ("SetResult"), а другой для отметки завершения с ошибкой ("SetException"):

public void SetResult() => Complete(null);

public void SetException(Exception error) => Complete(error);

private void Complete(Exception? error)
{
    lock (this)
    {
        if (_completed)
        {
            throw new InvalidOperationException("Already completed");
        }

        _error = error;
        _completed = true;

        if (_continuation is not null)
        {
            ThreadPool.QueueUserWorkItem(_ =>
            {
                if (_ec is not null)
                {
                    ExecutionContext.Run(_ec, _ => _continuation(this), null);
                }
                else
                {
                    _continuation(this);
                }
            });
        }
    }
}

Мы сохраняем любую ошибку, помечаем задачу как выполненную, а затем, если ранее было зарегистрировано продолжение, ставим его в очередь на вызов.

Наконец, нам нужен способ распространить любое исключение, которое могло произойти в задаче (и, если бы это была типовая MyTask, вернуть ее _result); для поддержки определенных сценариев мы также разрешаем этому методу блокировать ожидание завершения задачи, что мы можем реализовать в терминах ContinueWith (продолжение просто сигнализирует ManualResetEventSlim, который вызывающая сторона затем блокирует в ожидании завершения).

public void Wait()
{
    ManualResetEventSlim? mres = null;
    lock (this)
    {
        if (!_completed)
        {
            mres = new ManualResetEventSlim();
            ContinueWith(_ => mres.Set());
        }
    }

    mres?.Wait();
    if (_error is not null)
    {
        ExceptionDispatchInfo.Throw(_error);
    }
}

И это, по сути, все. Конечно, настоящая Task намного сложнее, с гораздо более эффективной реализацией, с поддержкой любого количества продолжений, с множеством настроек о том, как она должна себя вести (например. должны ли продолжения ставиться в очередь, как это сделано здесь, или они должны вызываться синхронно как часть завершения задачи), с возможностью хранить несколько исключений, а не только одно, со специальными знаниями об отмене, с тоннами вспомогательных методов для выполнения общих операций (например, Task.Run, который создает Task для представления делегата, поставленного в очередь для вызова на пуле потоков) и так далее. Но во всем этом нет никакой магии; в своей основе это просто то, что мы видели здесь.

Вы также можете заметить, что у моей простой MyTask есть публичные методы SetResult/SetException непосредственно на ней, в то время как у Task их нет. На самом деле у Task есть такие методы, просто они внутренние, с типом System.Threading.Tasks.TaskCompletionSource, служащим отдельным "производителем" для задачи и ее завершения; это было сделано не из технической необходимости, а как способ удержать методы завершения от вещи, предназначенной только для потребления. Вы можете передавать задачу, не беспокоясь о том, что она будет завершена из-под вашего контроля; сигнал завершения является деталью реализации того, что создало задачу, а также оставляет за собой право завершить ее, оставляя источник TaskCompletionSource при себе. (CancellationToken и CancellationTokenSource работают по аналогичной схеме: CancellationToken - это просто структурная обертка для CancellationTokenSource, предоставляющая только публичную доступную область, связанную с потреблением сигнала отмены, но без возможности его создания, которая является возможностью, ограниченной тем, кто имеет доступ к CancellationTokenSource).

Конечно, мы можем реализовать комбинаторы и помощники для этой MyTask, аналогичные тем, что предоставляет Task. Хотите простой MyTask.WhenAll? Вот, пожалуйста:

public static MyTask WhenAll(MyTask t1, MyTask t2)
{
    var t = new MyTask();

    int remaining = 2;
    Exception? e = null;

    Action<MyTask> continuation = completed =>
    {
        e ??= completed._error; // just store a single exception for simplicity
        if (Interlocked.Decrement(ref remaining) == 0)
        {
            if (e is not null) t.SetException(e);
            else t.SetResult();
        }
    };

    t1.ContinueWith(continuation);
    t2.ContinueWith(continuation);

    return t;
}

Хотите MyTask.Run? Он у вас есть:

public static MyTask Run(Action action)
{
    var t = new MyTask();

    ThreadPool.QueueUserWorkItem(_ =>
    {
        try
        {
            action();
            t.SetResult();
        }
        catch (Exception e)
        {
            t.SetException(e);
        }
    });

    return t;
}

Как насчет MyTask.Delay? Конечно:

public static MyTask Delay(TimeSpan delay)
{
    var t = new MyTask();

    var timer = new Timer(_ => t.SetResult());
    timer.Change(delay, Timeout.InfiniteTimeSpan);

    return t;
}

Вы поняли идею.

С появлением Task все предыдущие асинхронные паттерны в .NET ушли в прошлое. Везде, где асинхронная реализация ранее была реализована с помощью паттерна APM или EAP, появились новые методы, возвращающие Task.

Теги:
Хабы:
Всего голосов 12: ↑12 и ↓0+12
Комментарии3

Публикации

Истории

Работа

Ближайшие события