Реализация асинхронного шаблона, основанного на задачах

Можно реализовать асинхронную модель на основе задач (TAP) тремя способами: с помощью компиляторов C# и Visual Basic в Visual Studio, вручную или путем сочетания этих методов. Каждый метод подробно обсуждается в следующих разделах. Модель TAP можно применять для создания асинхронных операций, связанных с операциями ввода-вывода и ограниченных по скорости вычислений. В разделе Рабочие нагрузки рассматриваются операции каждого типа.

Создание методов TAP

С помощью компиляторов

Начиная с версии .NET Framework 4.5 любой метод, который помечен ключевым словом async (Async в Visual Basic), считается асинхронным, и компиляторы C# и Visual Basic применяют к нему преобразования, необходимые для асинхронной реализации метода по модели TAP. Асинхронный метод должен возвращать объект System.Threading.Tasks.Task или System.Threading.Tasks.Task<TResult>. Во втором случае функция должна возвращать TResult, а компилятор обеспечивает доступность этого результата через создаваемый объект задачи. Аналогичным образом все исключения, которые не обрабатываются в теле метода, маршаллируются в выходной задаче и приводят к концу результирующей задачи в TaskStatus.Faulted состоянии. Исключение из этого правила возникает, когда OperationCanceledException (или производный тип) остаются необработанными. В этом случае результирующая задача заканчивается в состоянии TaskStatus.Canceled.

Создание методов TAP вручную

Можно реализовать шаблон TAP для улучшения контроля над реализацией вручную. Компилятор использует общую контактную зону, предоставленную из пространства имен System.Threading.Tasks, и вспомогательные типы в пространстве имен System.Runtime.CompilerServices. Чтобы реализовать шаблон TAP самостоятельно, необходимо создать объект TaskCompletionSource<TResult>, выполнить асинхронную операцию и после ее завершения вызвать метод SetResult, SetException или SetCanceled, или версии одного из этих методов Try. При реализации метода TAP вручную необходимо выполнить результирующую задачу после завершения представленной асинхронной операции. Например:

public static Task<int> ReadTask(this Stream stream, byte[] buffer, int offset, int count, object state)
{
    var tcs = new TaskCompletionSource<int>();
    stream.BeginRead(buffer, offset, count, ar =>
    {
        try { tcs.SetResult(stream.EndRead(ar)); }
        catch (Exception exc) { tcs.SetException(exc); }
    }, state);
    return tcs.Task;
}
<Extension()>
Public Function ReadTask(stream As Stream, buffer() As Byte,
                         offset As Integer, count As Integer,
                         state As Object) As Task(Of Integer)
    Dim tcs As New TaskCompletionSource(Of Integer)()
    stream.BeginRead(buffer, offset, count, Sub(ar)
                                                Try
                                                    tcs.SetResult(stream.EndRead(ar))
                                                Catch exc As Exception
                                                    tcs.SetException(exc)
                                                End Try
                                            End Sub, state)
    Return tcs.Task
End Function

Гибридный подход

Может оказаться полезным реализовать шаблон TAР вручную, но делегировать основную логику для реализации компилятору. Например, можно использовать гибридный подход, когда требуется проверить аргументы за пределами асинхронного метода, созданного компилятором, для того, чтобы исключения могли переходить к непосредственному вызывающему объекту метода, а не предоставлялись через объект System.Threading.Tasks.Task.

public Task<int> MethodAsync(string input)
{
    if (input == null) throw new ArgumentNullException("input");
    return MethodAsyncInternal(input);
}

private async Task<int> MethodAsyncInternal(string input)
{

   // code that uses await goes here

   return value;
}
Public Function MethodAsync(input As String) As Task(Of Integer)
    If input Is Nothing Then Throw New ArgumentNullException("input")

    Return MethodAsyncInternal(input)
End Function

Private Async Function MethodAsyncInternal(input As String) As Task(Of Integer)

    ' code that uses await goes here

    return value
End Function

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

Рабочие нагрузки

Реализацию асинхронных операций, связанных с вводом-выводом и ограниченных по скорости вычислений, можно выполнить как методы TAP. Тем не менее, когда методы TAP предоставляются открыто из библиотеки, они должны быть предоставлены только для рабочих нагрузок, связанных с операциями ввода-вывода (они также могут включать в себя вычисления, но не должны быть исключительно вычислительными). Если метод ограничен только по скорости вычислений, его следует предоставлять только в синхронной реализации. При использовании этого метода в коде можно заключить вызов этого синхронного метода в отдельную задачу, чтобы передать часть задач в другой поток или выполнять их параллельно. Если метод имеет привязку к операциям ввода-вывода, его следует предоставлять только в асинхронной реализации.

Задачи, ограниченные по скорости вычислений

Класс System.Threading.Tasks.Task идеально подходит для представления ресурсоемких вычислительных операций. По умолчанию он использует специальную поддержку в классе ThreadPool, чтобы обеспечить эффективное выполнение, а также обеспечивает значительный контроль над тем, когда, где и как выполнять асинхронные вычисления.

Задачи ограниченных по скорости вычислений можно создать одним из следующих способов:

  • В .NET Framework 4.5 и более поздних версий (включая .NET Core и .NET 5 и более поздние версии) используйте статический метод Task.Run в качестве псевдонима для TaskFactory.StartNew. Вы можете использовать Run для простого запуска ограниченных по скорости вычислений задач, предназначенных для пула потоков. Это предпочтительный механизм для запуска задачи, ограниченной по скорости вычислений. Используйте StartNew непосредственно, только когда требуется более точный контроль над задачей.

  • В .NET Framework 4 используйте метод TaskFactory.StartNew, который принимает делегат (обыкновенно Action<T> или Func<TResult>) для асинхронного выполнения. Если предоставить делегат Action<T>, этот метод возвращает объект System.Threading.Tasks.Task, представляющий асинхронное выполнение этого делегата. Если предоставить делегат Func<TResult>, этот метод возвращает объект System.Threading.Tasks.Task<TResult>. Перегрузки метода StartNew принимают маркер отмены (CancellationToken), параметры создания задач (TaskCreationOptions) и планировщик заданий (TaskScheduler), которые обеспечивают точное управление планированием и выполнением задачи. Экземпляр фабрики, предназначенный для текущего планировщика задач доступен как статическое свойство (Factory) из класса Task; Например: Task.Factory.StartNew(…).

  • Используйте конструкторы Task типа и метода, если вы хотите создать и Start запланировать задачу отдельно. Открытые методы должны возвращать только задачи, которые уже были начаты.

  • Можно использовать перегрузки метода Task.ContinueWith. Этот метод создает новую задачу, которая запланирована после завершения другой задачи. Некоторые перегрузки ContinueWith принимают токен отмены, параметры продолжения и планировщик задач для улучшения контроля над планированием и выполнением задачи продолжения.

  • Используйте методы TaskFactory.ContinueWhenAll и TaskFactory.ContinueWhenAny. Эти методы создают новую задачу, которая планируется по завершении всех или какой-либо из предоставленного набора задач. Эти методы также предоставляют перегрузки для управления планированием и исполнением этих задач.

В задачах, ограниченных по скорости вычислений, система может предотвратить выполнение запланированной задачи при получении запроса отмены до запуска задачи. Таким образом, если предоставить токен отмены (объектCancellationToken), можно передать его асинхронному коду, который следит за токеном. Можно также предоставить токен для одного из вышеупомянутых методов таких, как StartNew или Run для того, чтобы среда выполнения , Task могла также осуществлять его мониторинг.

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

internal Task<Bitmap> RenderAsync(
              ImageData data, CancellationToken cancellationToken)
{
    return Task.Run(() =>
    {
        var bmp = new Bitmap(data.Width, data.Height);
        for(int y=0; y<data.Height; y++)
        {
            cancellationToken.ThrowIfCancellationRequested();
            for(int x=0; x<data.Width; x++)
            {
                // render pixel [x,y] into bmp
            }
        }
        return bmp;
    }, cancellationToken);
}
Friend Function RenderAsync(data As ImageData, cancellationToken As _
                            CancellationToken) As Task(Of Bitmap)
    Return Task.Run(Function()
                        Dim bmp As New Bitmap(data.Width, data.Height)
                        For y As Integer = 0 to data.Height - 1
                            cancellationToken.ThrowIfCancellationRequested()
                            For x As Integer = 0 To data.Width - 1
                                ' render pixel [x,y] into bmp
                            Next
                        Next
                        Return bmp
                    End Function, cancellationToken)
End Function

Задача, ограниченная по скорости вычислений, завершается в состоянии Canceled, если хотя бы одно из следующих условий верно:

  • запрос отмены поступает через объект CancellationToken, который предоставляется как аргумент метода создания (например, StartNew или Run) до того, как задача переходит в состояние Running.

  • исключение OperationCanceledException остается необработанным в теле такой задачи, исключение содержит тот же токен отмены CancellationToken, который передается задаче, а токен указывает на наличие запроса отмены.

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

Задачи с привязкой к операциям ввода-вывода

Чтобы создать задачу, которое не должно непосредственно поддерживаться потоком во время всего его выполнения, используйте тип TaskCompletionSource<TResult>. Этот тип предоставляет свойство Task, которое возвращает связанный экземпляр Task<TResult>. Жизненный цикл этой задачи управляется методами TaskCompletionSource<TResult>, такими как SetResult, SetException, SetCanceled, и их вариантами TrySet.

Предположим, что вы хотите создать задачу, которая будет завершена после указанного периода времени. Например, можно отложить выполнение в пользовательском интерфейсе. Класс System.Threading.Timer обеспечивает возможность асинхронного вызова делегат после определенного периода времени, а используя TaskCompletionSource<TResult>, можно установить начало Task<TResult> на таймере, например:

public static Task<DateTimeOffset> Delay(int millisecondsTimeout)
{
    TaskCompletionSource<DateTimeOffset> tcs = null;
    Timer timer = null;

    timer = new Timer(delegate
    {
        timer.Dispose();
        tcs.TrySetResult(DateTimeOffset.UtcNow);
    }, null, Timeout.Infinite, Timeout.Infinite);

    tcs = new TaskCompletionSource<DateTimeOffset>(timer);
    timer.Change(millisecondsTimeout, Timeout.Infinite);
    return tcs.Task;
}
Public Function Delay(millisecondsTimeout As Integer) As Task(Of DateTimeOffset)
    Dim tcs As TaskCompletionSource(Of DateTimeOffset) = Nothing
    Dim timer As Timer = Nothing

    timer = New Timer(Sub(obj)
                          timer.Dispose()
                          tcs.TrySetResult(DateTimeOffset.UtcNow)
                      End Sub, Nothing, Timeout.Infinite, Timeout.Infinite)

    tcs = New TaskCompletionSource(Of DateTimeOffset)(timer)
    timer.Change(millisecondsTimeout, Timeout.Infinite)
    Return tcs.Task
End Function

Метод Task.Delay предоставляется для этой цели, и можно использовать его внутри другого асинхронного метода, например для реализации асинхронного цикла опроса.

public static async Task Poll(Uri url, CancellationToken cancellationToken,
                              IProgress<bool> progress)
{
    while(true)
    {
        await Task.Delay(TimeSpan.FromSeconds(10), cancellationToken);
        bool success = false;
        try
        {
            await DownloadStringAsync(url);
            success = true;
        }
        catch { /* ignore errors */ }
        progress.Report(success);
    }
}
Public Async Function Poll(url As Uri, cancellationToken As CancellationToken,
                           progress As IProgress(Of Boolean)) As Task
    Do While True
        Await Task.Delay(TimeSpan.FromSeconds(10), cancellationToken)
        Dim success As Boolean = False
        Try
            await DownloadStringAsync(url)
            success = true
        Catch
            ' ignore errors
        End Try
        progress.Report(success)
    Loop
End Function

Класс TaskCompletionSource<TResult> не имеет неуниверсального эквивалента. Теме не менее, Task<TResult> является производным от Task и это дает возможность использовать универсальный объект TaskCompletionSource<TResult> для связанных с вводом-выводом методов, которые просто возвращают задачу. Чтобы это сделать, можно использовать источник с фиктивным TResult (Boolean — неплохой выбор по умолчанию, однако, если вас беспокоит пользователь Task, приводящие его к Task<TResult>, можно вместо этого использовать частный тип TResult). Например, метод Delay в предыдущем примере возвращает текущее значение времени и конечного смещения (Task<DateTimeOffset>). Если значение результата не нужно, метод можно вместо этого построить следующим образом (Обратите внимание на изменение типа возвращаемого значения и изменения аргумент для TrySetResult):

public static Task<bool> Delay(int millisecondsTimeout)
{
     TaskCompletionSource<bool> tcs = null;
     Timer timer = null;

     timer = new Timer(delegate
     {
         timer.Dispose();
         tcs.TrySetResult(true);
     }, null, Timeout.Infinite, Timeout.Infinite);

     tcs = new TaskCompletionSource<bool>(timer);
     timer.Change(millisecondsTimeout, Timeout.Infinite);
     return tcs.Task;
}
Public Function Delay(millisecondsTimeout As Integer) As Task(Of Boolean)
    Dim tcs As TaskCompletionSource(Of Boolean) = Nothing
    Dim timer As Timer = Nothing

    Timer = new Timer(Sub(obj)
                          timer.Dispose()
                          tcs.TrySetResult(True)
                      End Sub, Nothing, Timeout.Infinite, Timeout.Infinite)

    tcs = New TaskCompletionSource(Of Boolean)(timer)
    timer.Change(millisecondsTimeout, Timeout.Infinite)
    Return tcs.Task
End Function

Смешанные задачи с привязкой к операциям ввода-вывода и ограниченные по скорости вычислений

Асинхронные методы не ограничиваются только операциями, связанными с вводом-выводом или ограниченными по скорости вычислений, а могут представлять собой их комбинацию. На практике несколько асинхронных операций часто объединяются в большие по размеру смешанные операции. Например, метод RenderAsync в предыдущем примере выполнил операцию с большим количеством вычислений, чтобы отобразить изображение, зависящее от некоторых входных данных imageData. Это изображение imageData могло бы быть получено из веб-службы, к которой осуществляется асинхронный доступ:

public async Task<Bitmap> DownloadDataAndRenderImageAsync(
    CancellationToken cancellationToken)
{
    var imageData = await DownloadImageDataAsync(cancellationToken);
    return await RenderAsync(imageData, cancellationToken);
}
Public Async Function DownloadDataAndRenderImageAsync(
             cancellationToken As CancellationToken) As Task(Of Bitmap)
    Dim imageData As ImageData = Await DownloadImageDataAsync(cancellationToken)
    Return Await RenderAsync(imageData, cancellationToken)
End Function

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

См. также