将 .NET 任务作为 WinRT 异步操作公开

在博文深入探究 Await 和 WinRT 中,我们讨论了 C# 和 Visual Basic 中新增的 asyncawait 关键字,以及如何使用它们执行 Windows 运行时 (WinRT) 异步操作。

在 .NET 基本类库 (BCL) 的辅助下,您还可以使用这些关键字来设计异步操作,然后通过 WinRT 公开这些操作,以使其适用于使用其他语言构建的组件。在本篇博文中,我们将探究如何实现。(有关使用 C# 或 Visual Basic 实施 WinRT 组件的完整详细信息,请参阅使用 C# 和 Visual Basic 创建 Windows 运行时组件。)

首先,我们来回顾一下 WinRT 中异步 API 的概况。

WinRT 异步接口

WinRT 具有多个与异步操作相关的接口。第一个要说的就是 IAsyncInfo,每个有效的 WinRT 异步操作都会实施该接口。它可提供异步操作的所有常用功能,包括操作的当前状态、操作失败时的错误信息、操作 ID 以及操作请求取消的能力:

 public interface IAsyncInfo
{
    AsyncStatus Status { get; }
    Exception ErrorCode { get; }
    uint Id { get; }

    void Cancel();
    void Close();
}

除实施 IAsyncInfo 之外,所有有效的 WinRT 异步操作还需要实施另外四个接口之一:IAsyncActionIAsyncActionWithProgress<TProgress>IAsyncOperation<TResult>IAsyncOperationWithProgress<TResult,TProgress> 。这四个接口使用户可以设置一个在异步操作完成时调用的回调,还可以支持客户获取结果和/或接收进度报告:

 // No results, no progress
public interface IAsyncAction : IAsyncInfo
{
    AsyncActionCompletedHandler Completed { get; set; }
    void GetResults();
}

// No results, with progress
public interface IAsyncActionWithProgress<TProgress> : IAsyncInfo
{
    AsyncActionWithProgressCompletedHandler<TProgress> Completed { get; set; }
    AsyncActionProgressHandler<TProgress> Progress { get; set; }
    void GetResults();
}

// With results, no progress
public interface IAsyncOperation<TResult> : IAsyncInfo
{
    AsyncOperationCompletedHandler<TResult> Completed { get; set; }
    TResult GetResults();
}

// With results, with progress
public interface IAsyncOperationWithProgress<TResult,TProgress> : IAsyncInfo
{
    AsyncOperationWithProgressCompletedHandler<TResult,TProgress> Completed { get; set; }
    AsyncOperationProgressHandler<TResult,TProgress> Progress { get; set; }
    TResult GetResults();
}

IAsyncAction 提供 GetResults 方法,即使没有返回结果也是如此。这就为失败的异步操作提供了一种抛出异常的方法,而不必迫使所有用户访问 IAsyncInfoErrorCode 属性。这与 C# 和 Visual Basic 中的 awaiter 类型公开 GetResult 方法相类似,尽管 GetResult 方法被类型化为返回 void。)

在构建 WinRT 库时,该库中所有全局公开的异步操作都会强类型化为返回这四个接口之一。与此相对,从 .NET 库公开的新异步操作遵循基于任务的异步模式 (TAP),对于不返回结果的操作返回 Task,而对于返回结果的操作则返回 Task<TResult>

TaskTask<TResult> 不会实施这些 WinRT 接口,公共语言运行时 (CLR) 也不会暗中掩饰它们的差异(对于某些类型会如此,例如 WinRT Windows.Foundation.Uri 类型和 BCL System.Uri 类型)。而是我们需要明确地从一种模式转换到另一种。在博文深入探究 Await 和 WinRT 中,我们了解了 BCL 中的 AsTask 扩展方法如何提供一种明确的可重用机制来从 WinRT 异步接口转换为 .NET 任务。BCL 还支持反向转换,即通过某些方法从 .NET 任务转换为 WinRT 异步接口。

使用 AsAsyncAction 和 AsAsyncOperation 转换

在本篇博文中,我们假设有一个 .NET 异步方法 DownloadStringAsyncInternal。我们向其传递一个指向某网页的 URL,该方法会异步下载并以字符串的形式返回该网页的内容:

 internal static Task<string> DownloadStringAsyncInternal(string url);

如何实施该方法并不重要。我们的目标是将该方法封装成一个 WinRT 异步操作,也就是说作为一种可以返回上述四个接口之一的方法。由于我们的操作会产生一个结果(一个字符串),并且它不支持进度报告,因此我们的 WinRT 异步操作会返回 IAsyncOperation<string>

 public static IAsyncOperation<string> DownloadStringAsync(string url);

为实施此方法,我们可以调用 DownloadStringAsyncInternal 方法来获取结果 Task<string> 。接下来我们需要将该任务转换成所需的 IAsyncOperation<string> …但如何实现?

 public static IAsyncOperation<string> DownloadStringAsync(string url)
{
    Task<string> from = DownloadStringAsyncInternal(url);
    IAsyncOperation<string> to = ...; // TODO: how do we convert 'from'?
    return to;
}

为填补这一空当,.NET 4.5 中的 System.Runtime.WindowsRuntime.dll 程序集包含了用于 TaskTask<TResult> 的扩展方法,可提供所需的转换:

 // in System.Runtime.WindowsRuntime.dll
public static class WindowsRuntimeSystemExtensions 
{
    public static IAsyncAction AsAsyncAction(
        this Task source);
    public static IAsyncOperation<TResult> AsAsyncOperation<TResult>(
        this Task<TResult> source);
    ...
}

这些方法会返回一个新的 IAsyncActionIAsyncOperation<TResult> 实例,该实例会分别封装所提供的 TaskTask<TResult> (由于 Task<TResult> 派生自 Task,因此这两种方法都可以用于 Task<TResult> ,但将 AsAsyncAction 与返回结果的异步方法一同使用是相对少见的情况)。从逻辑上讲,您可以将这些操作视为明确的可重复的异步操作,或者从设计模式的角度出发,视为适配器。它们会返回一个代表底层任务但公开 WinRT 所需的外围应用的实例。使用此类扩展方法,我们可以完成 DownloadStringAsync 实施:

 public static IAsyncOperation<string> DownloadStringAsync(string url)
{
    Task<string> from = DownloadStringAsyncInternal(url);
    IAsyncOperation<string> to = from.AsAsyncOperation();
    return to;
}

我们还可以以更简洁的方式进行编写,重点强调操作的可重用性:

 public static IAsyncOperation<string> DownloadStringAsync(string url)
{
    return DownloadStringAsyncInternal(url).AsAsyncOperation();
}

DownloadStringAsyncInternal 会在我们调用 AsAsyncOperation 之前进行调用。这意味着我们需要异步调用 DownloadStringAsyncInternal 来快速返回,以确保 DownloadStringAsync 封装程序方法有响应。如果出于某些原因,您担心异步操作花费时间过长,或者出于其他原因您明确希望将调用转移到某个线程池,则可以通过使用 Task.Run,然后在其返回的任务中调用 AsAsyncOperation 来实现:

 public static IAsyncOperation<string> DownloadStringAsync(string url)
{
    return Task.Run(()=>DownloadStringAsyncInternal(url)).AsAsyncOperation();
}

AsyncInfo.Run 提供更多灵活性

这些内置的 AsAsyncActionAsAsyncOperation 扩展方法对于从 TaskIAsyncAction 和从 Task<TResult>IAsyncOperation<TResult> 的简单转换而言十分有用。但对于更高级的转换而言效果如何?

System.Runtime.WindowsRuntime.dll 所包含的另一个类型可以提供更多灵活性:AsyncInfo,包含在 System.Runtime.InteropServices.WindowsRuntime 命名空间中。AsyncInfo 公开静态 Run 方法的四个重载,每个分别对应四个 WinRT 异步接口之一:

 // in System.Runtime.WindowsRuntime.dll
public static class AsyncInfo 
{
    // No results, no progress
    public static IAsyncAction Run(
        Func<CancellationToken, 
             Task> taskProvider); 

    // No results, with progress
    public static IAsyncActionWithProgress<TProgress> Run<TProgress>(
        Func<CancellationToken, 
             IProgress<TProgress>, 
             Task> taskProvider);


    // With results, no progress
    public static IAsyncOperation<TResult> Run<TResult>(
        Func<CancellationToken, 
             Task<TResult>> taskProvider);

    // With results, with progress
    public static IAsyncOperationWithProgress<TResult, TProgress> Run<TResult, TProgress>(
        Func<CancellationToken, 
             IProgress<TProgress>, 
             Task<TResult>> taskProvider);
}

我们已检验过的 AsAsyncActionAsAsyncOperation 方法接受 Task 作为参数。与此相对,这些 Run 方法接受返回 Task 的函数委派,而 TaskFunc<…,Task> 之间的差别已足够为我们提供更高级操作所需的额外灵活性。

从逻辑上讲,您可以将 AsAsyncActionAsAsyncOperation 视为对更高级的 AsyncInfo.Run 的简单辅助:

 public static IAsyncAction AsAsyncAction(
    this Task source)
{
    return AsyncInfo.Run(_ => source);
}

public static IAsyncOperation<TResult> AsAsyncOperation<TResult>(
    this Task<TResult> source)
{
    return AsyncInfo.Run(_ => source);
}

这并不是它们在 .NET 4.5 中的确切实施方法,但所发挥的功能就是这样,因此,可以将它们理解为基本支持和高级支持的差别。在简单情况下可以使用 AsAsyncActionAsAsyncOperation,但在更高级的情况下,AsyncInfo.Run 则会大放异彩。

取消

AsyncInfo.Run 可实现通过 WinRT 异步方法支持取消。

继续我们的下载示例,假设我们有另一个接受 CancellationTokenDownloadStringAsyncInternal 重载:

 internal static Task<string> DownloadStringAsyncInternal(
    string url, CancellationToken cancellationToken);

CancellationToken 是一种 .NET Framework 类型,以可组合的方式支持可合作取消。您可以将单个标记传递给任意数量的方法调用,当该标记接收取消请求(通过创建标记的 CancellationTokenSource)时,所有执行中的操作都会看到该取消请求。这种方式与 WinRT 异步所用的方式略有不同,后者是使每个 IAsyncInfo 单独公开其 Cancel 方法。在此前提下,我们如何安排在 IAsyncOperation<string> 上的 Cancel 调用接收在传递给 DownloadStringAsyncInternalCancellationToken 上请求的取消?在这种情况下,AsAsyncOperation 将不起作用:

 public static IAsyncOperation<string> DownloadStringAsync(string uri)
{
     return DownloadStringAsyncInternal(uri, … /* what goes here?? */)
            .AsAsyncOperation();
}

为了解何时请求取消 IAsyncOperation<string> ,该实例需要以某种方式通知监听器其 Cancel 方法已被调用,例如通过请求取消要传递给 DownloadStringAsyncInternalCancellationToken。但在经典的“第 22 条军规”中,要想获得在其上调用 AsAsyncOperationTask<string> ,我们必须先调用 DownloadStringAsyncInternal,而此时我们就应该已经需要提供原本希望 AsAsyncOperation 提供的 CancellationToken 了。

有多种方法可以解决这个难题,其中包括 AsyncInfo.Run 所使用的解决方案。Run 方法负责构建 IAsyncOperation<string> ,并且创建用于请求取消 CancellationToken 的实例,而当调用异步操作的 Cancel 方法时,它也会创建该实例。然后,当调用由用户提供的已传递给 Run 的委派时,该方法会传入此标记,从而避免前面提到的难题:

 public static IAsyncOperation<string> DownloadStringAsync(string uri)
{
    return AsyncInfo.Run(cancellationToken => 
        DownloadStringAsyncInternal(uri, cancellationToken));
}

Lambda 和匿名方法

AsyncInfo.Run 简化了使用 lambda 函数和匿名方法来实施 WinRT 异步方法的过程。

例如,如果我们还没有 DownloadStringAsyncInternal 方法,我们也许会通过以下方式实施该方法和 DownloadStringAsync

 public static IAsyncOperation<string> DownloadStringAsync(string uri)
{
    return AsyncInfo.Run(delegate(CancellationToken cancellationToken)
    {
        return DownloadStringAsyncInternal(uri, cancellationToken));
    });
}

private static async Task<string> DownloadStringAsyncInternal(
    string uri, CancellationToken cancellationToken)
{
    var response = await new HttpClient().GetAsync(
        uri, cancellationToken);
    response.EnsureSuccessStatusCode();
    return await response.Content.ReadAsStringAsync();
}

通过利用 C# 和 Visual Basic 支持来编写异步匿名方法,我们可以通过将这两种方法结合成一种来简化实施过程:

 public static IAsyncOperation<string> DownloadStringAsync(string uri)
{
    return AsyncInfo.Run(async delegate(CancellationToken cancellationToken)
    {
        var response = await new HttpClient().GetAsync(
            uri, cancellationToken);
        response.EnsureSuccessStatusCode();
        return await response.Content.ReadAsStringAsync();
    });
}    

进度

AsyncInfo.Run 还通过 WinRT 异步方法提供进度报告支持。

放弃使用 DownloadStringAsync 方法返回 IAsyncOperation<string> 的想法,假设我们希望该方法返回 IAsyncOperationWithProgress<string,int>

 public static IAsyncOperationWithProgress<string,int> DownloadStringAsync(string uri);

DownloadStringAsync 现在可以提供包含完整数据的进度更新,例如,用户可以设置一个委派作为接口的 Progress 属性,以接收进度变更通知。

AsyncInfo.Run 提供接受 Func<CancellationToken,IProgress<TProgress>,Task<TResult>> 的重载。正如 AsyncInfo.Run 向委派传入一个 CancellationToken 以在调用 Cancel 方法时接收取消请求,它还可以传入 IProgress<TProgress> 实例,该实例的 Report 方法会触发调用用户的 Progress 委派。例如,如果我们希望修改前面的例子,使其在开始时报告进度为 0%,得到响应时报告进度为 50%,在将响应解析为字符串之后报告进度为 100%,那么代码应该类似如下:

 public static IAsyncOperationWithProgress<string,int> DownloadStringAsync(string uri)
{
    return AsyncInfo.Run(async delegate(
        CancellationToken cancellationToken, IProgress<int> progress)
    {
        progress.Report(0);
        try
        {
            var response = await new HttpClient().GetAsync(uri, cancellationToken);
            progress.Report(50);
            response.EnsureSuccessStatusCode();
            return await response.Content.ReadAsStringAsync();
        }
        finally { progress.Report(100); }
    });
}

后台真相

要透彻理解所有这些工作原理,让我们来探讨一下 AsAsyncOperationAsyncInfo.Run 的实施。它们与 .NET 4.5 中存在的实施不同,功能也没有那么强大。它们只是一些近似实施,帮助您深入了解后台的工作方式,并不适用于实际生产。

AsAsyncOperation

AsAsyncOperation 方法调用 Task<TResult> ,并返回 IAsyncOperation<TResult>

 public static IAsyncOperation<TResult> AsAsyncOperation<TResult>(
    this Task<TResult> source);

要实施此方法,我们需要创建一个实施 IAsyncOperation<TResult> 并封装所提供的任务的类型。

 public static IAsyncOperation<TResult> AsAsyncOperation<TResult>(
    this Task<TResult> source)
{
    return new TaskAsAsyncOperationAdapter<TResult>(source);
}

internal class TaskAsAsyncOperationAdapter<TResult> : IAsyncOperation<TResult>
{
    private readonly Task<TResult> m_task;

    public TaskAsAsyncOperationAdapter(Task<TResult> task) { m_task = task; }

    ...
}

此类型上的每个接口方法实施都会委派给所封装任务的功能。万丈高楼平地起,我们先来了解最简单的成员。

首先,IAsyncInfo.Close 方法应该会彻底清除已完成的异步操作所使用过的任何资源。由于我们没有此类资源(我们的对象只封装了一个任务),因此我们的实施是空的:

 public void Close() { /* NOP */ }

使用 IAsyncInfo.Cancel 方法,用户可以请求取消执行的异步操作。这只是一个单纯的请求,不会以任何方式强制操作退出。我们仅使用 CancellationTokenSource 存储发生的请求:

 private readonly CancellationTokenSource m_canceler =
    new CancellationTokenSource();

public void Cancel() { m_canceler.Cancel(); }

IAsyncInfo.Status 属性返回一个 AsyncStatus 来代表操作相对于其异步生命周期而言的当前状态。这可能是以下四个值之一:StartedCompletedErrorCanceled。大部分时候,我们可以仅委派给底层 TaskStatus 属性,并将其返回的 TaskStatus 映射到所需的 AsyncStatus

从 TaskStatus

到 AsyncStatus

RanToCompletion

Completed

Faulted

Error

Canceled

Canceled

所有其他值和已请求的取消

Canceled

所有其他值和未请求的取消

Started

如果 Task 尚未完成就已请求了取消,我们就需要返回 Canceled 而不是 Started。这意味着,尽管 TaskStatus.Canceled 只是一种终端状态,但 AsyncStatus.Canceled 既可以是终端状态,也可以是非终端状态,因为 IAsyncInfo 可以以 Canceled 状态结束,或者 IAsyncInfo 可以从 Canceled 状态转变为 AsyncStatus.CompletedAsyncStatus.Error 状态。

 public AsyncStatus Status
{
    get
    {
        switch (m_task.Status)
        {
            case TaskStatus.RanToCompletion: 
                return AsyncStatus.Completed;
            case TaskStatus.Faulted: 
                return AsyncStatus.Error;
            case TaskStatus.Canceled: 
                return AsyncStatus.Canceled;
            default: 
                return m_canceler.IsCancellationRequested ? 
                    AsyncStatus.Canceled : 
                    AsyncStatus.Started;
        }
    }
}

IAsyncInfo.Id 属性会为操作返回一个 UInt32 标识符。由于 Task 本身已经公开了一个这样的标识符(例如 Int32),我们可以仅仅通过委派到底层 Task 属性来实施此属性:

 public uint Id { get { return (uint)m_task.Id; } }

WinRT 定义 IAsyncInfo.ErrorCode 属性以返回 HResult。但 CLR 会在内部将 WinRT HResult 映射到 .NET Exception,该过程通过托管投影的方式实现。Task 自身也会公开 Exception 属性,因此我们可以直接委派给它:如果 TaskFaulted 状态结束,我们返回其第一个异常(Task 可能会因多个异常出错,例如从 Task.WhenAll 返回的 Task),否则就返回 null:

 public Exception ErrorCode
{
    get { return m_task.IsFaulted ? m_task.Exception.InnerException : null; }
}

这就是实施 IAsyncInfo 的过程。现在,我们需要实施 IAsyncOperation<TResult> 所提供的另外两个成员:GetResultsCompleted

操作成功完成或者发生异常后,用户调用 GetResults。在前一种情况下,它会返回计算出的结果,在后一种情况下,它会抛出相关的异常。如果操作以 Canceled 状态结束,或者操作尚未结束,则不得调用 GetResults。因此,我们可以按如下所示实施 GetResults,依靠任务的 awaiter GetResult 方法来在操作成功时返回结果,或在任务以 Faulted 状态结束时传播相应的异常。

 public TResult GetResults()
{
    switch (m_task.Status)
    {
        case TaskStatus.RanToCompletion:
        case TaskStatus.Faulted:
            return m_task.GetAwaiter().GetResult();
        default:
            throw new InvalidOperationException("Invalid GetResults call.");
    }
}

最后,我们来看一下 Completed 属性。Completed 代表一个应在操作完成时调用的委派。如果在设置 Completed 时操作已完成,则提供的委派必须立即调用或加入计划。此外,用户只能设置一次该属性(尝试设置多次将导致异常)。操作完成后,实施必须将引用降至该委派,以避免内存泄漏。我们可以依靠 TaskContinueWith 方法来实施此行为中的大部分,但是由于 ContinueWith 可以在同一 Task 实例中多次使用,我们需要手动实施更加严格的“设置一次”行为:

 private AsyncOperationCompletedHandler<TResult> m_handler;
private int m_handlerSet;

public AsyncOperationCompletedHandler<TResult> Completed
{
    get { return m_handler; }
    set
    {
        if (value == null) 
            throw new ArgumentNullException("value");
        if (Interlocked.CompareExchange(ref m_handlerSet, 1, 0) != 0)
            throw new InvalidOperationException("Handler already set.");

        m_handler = value;

        var sc = SynchronizationContext.Current;
        m_task.ContinueWith(delegate {
            var handler = m_handler;
            m_handler = null;
            if (sc == null)
                handler(this, this.Status);
            else
                sc.Post(delegate { handler(this, this.Status); }, null);
       }, CancellationToken.None, 
          TaskContinuationOptions.ExecuteSynchronously, 
          TaskScheduler.Default);
    }
}

如此一来,我们的 AsAsyncOperation 实施就已完成,并且我们可以将任何返回 Task<TResult> 的方法用作 IAsyncOperation<TResult> 方法。

AsyncInfo.Run

那么,更高级的 AsyncInfo.Run 有何功能?

 public static IAsyncOperation<TResult> Run<TResult>(
        Func<CancellationToken,Task<TResult>> taskProvider);

为支持此 Run 重载,我们可以使用刚才创建的 TaskAsAsyncOperationAdapter<TResult> 类型,并保持所有现有实施不变。事实上,我们只需要扩展其功能,使其可以以 Func<CancellationToken,Task<TResult>> 的形式运行,而不仅仅以 Task<TResult> 的形式。如果提供有此类委派,我们可以直接以异步方式进行调用,传入我们在前面定义的 m_canceler CancellationTokenSource 并存储返回的任务:

 internal class TaskAsAsyncOperationAdapter<TResult> : IAsyncOperation<TResult>
{
    private readonly Task<TResult> m_task;

    public TaskAsAsyncOperationAdapter(Task<TResult> task) { m_task = task; }

    public TaskAsAsyncOperationAdapter(
        Func<CancellationToken,Task<TResult>> func)
    {
        m_task = func(m_canceler.Token);
    }
    ...
}

public static class AsyncInfo
{
    public static IAsyncOperation<TResult> Run<TResult>(
        Func<CancellationToken, Task<TResult>> taskProvider)
    {
        return new TaskAsAsyncOperationAdapter<TResult>(taskProvider);
    }
    …
}

这一实施进一步证实了其实不存在任何魔力。AsAsyncActionAsAsyncOperationAsyncInfo.Run 都不过是免去您编写样板代码之劳的辅助实施。

总结

至此,希望您已经透彻了解了 AsAsyncActionAsAsyncOperationAsyncInfo.Run 的功能:它们简化了调用 TaskTask<TResult> 的过程,并将其公开为 IAsyncActionIAsyncOperation<TResult>IAsyncActionWithProgress<TProgress>IAsyncOperationWithProgress<TResult,TProgress> 。通过结合使用 C# 和 Visual Basic 中的 asyncawait 关键字,用户就可以非常轻松地通过托管代码实施新的 WinRT 异步操作。

只要您要公开的功能已经作为 TaskTask<TResult> 公开,您就应该依靠这些内置的功能来进行转换,而不是手动实施 WInRT 异步接口。如果您尝试公开的功能还没有作为 TaskTask<TResult> 公开,请首先尝试将其公开为 TaskTask<TResult> ,然后再借助内置转换。要正确掌握所有有关 WinRT 异步接口实施的语义可能十分困难,这些内置的转换正是用于解决这一问题。如果您使用 C++ 实施 WinRT 异步操作,不论是通过基本 AsyncBase 类(包含在 Windows 运行库中)还是通过 create_async 函数(包含在并行模式类库 中)实施都可以获得类似的支持。

预祝您使用愉快,充分享受异步操作!

--Stephen Toub,Visual Studio