拓展C#的异步方法

本文手动翻译自:Extending the async methods in C#

上一篇文章中,我们讨论了 C# 编译器是如何处理异步方法的。在这一篇,我们会重点关注 C# 编译器为自定义异步方法提供的可扩展性。

我们有三种控制异步方法状态机的思路:

  1. System.Runtime.CompilerServices 命名空间中提供自己的异步方法 builder

  2. 自定义 task awaiters

  3. 自定义 task-like 类。

System.Runtime.ComplierServices 命名空间下自定义 AsyncMethodBuilder

从前文我们得知,C# 编译器会依据一些系统提供的类将异步方法转换为状态机。但 C# 编译器并不要求所依据的类来自指定的某个程序集。例如,我们可以在项目中提供自己的 AsyncVoidMethodBuilder ,这样 C# 编译器会将异步机制与该类“绑定”。

下面的例子将帮助我们理解底层转换,并看看代码运行时发生的事情:

namespace System.Runtime.CompilerServices
{
    // AsyncVoidMethodBuilder.cs in your project
    public class AsyncVoidMethodBuilder
    {
        public AsyncVoidMethodBuilder()
            => Console.WriteLine(".ctor");
 
        public static AsyncVoidMethodBuilder Create()
            => new AsyncVoidMethodBuilder();
 
        public void SetResult() => Console.WriteLine("SetResult");
 
        public void Start<TStateMachine>(ref TStateMachine stateMachine)
            where TStateMachine : IAsyncStateMachine
        {
            Console.WriteLine("Start");
            stateMachine.MoveNext();
        }
 
        // AwaitOnCompleted, AwaitUnsafeOnCompleted, SetException 
        // and SetStateMachine are empty
    }   
}

现在,项目中的每个异步方法会采用自定义版本的 AsyncVoidMethodBuilder 。我们可以简单测试一下:

[Test]
public void RunAsyncVoid()
{
    Console.WriteLine("Before VoidAsync");
    VoidAsync();
    Console.WriteLine("After VoidAsync");
 
    async void VoidAsync() { }
}

该测试的输出:

Before VoidAsync
.ctor
Start
SetResult
After VoidAsync

你还可以实现 UnsafeAwaitOnComplete 方法,来试验当异步方法返回还没完成任务的 Task 对象的时候的情况。完整的例子可以在 github 找到。

要修改 async Taskasync Task<T> 方法的行为,你需要提供自定义版本的 AsyncTaskMethodBuilderAsyncTaskMethodBuilder

关于上面提到的类,可以在 github 上的 EduAsync 项目里找到参考: AsyncTaskBuilder.csAsyncTaskMethodBuilderOfT.cs

感谢 Jon Skeet 在这项目上给予我(作者)的灵感。

自定义 awaiters

前面的例子有亿点 “黑魔法” 的味道,并且它不适合用在真正的生产环境里。我们可以通过它学习异步机制,但你肯定不想看到这些代码出现在自己的项目里。好在 C# 作者给编译器内置了可扩展的点,允许在异步方法中 await 不同的对象类型。

可被 await 的类(即在 await 表达式中合法)需要满足以下几个条件:

  • 该类中需要有名为 GetAwaiter 的方法(扩展方法也可以),否则编译器会报错。该方法的返回值也有两处限制:

  • 被返回的对象需要实现 INotifyCompletion 接口。

  • 被返回的对象需要有 bool IsCompleted {get;} 属性,和 GetResult() 方法。

知道这些以后,我们可以写出可等待的 Lazy<T> 类:

public struct LazyAwaiter<T> : INotifyCompletion
{
    private readonly Lazy<T> _lazy;
 
    public LazyAwaiter(Lazy<T> lazy) => _lazy = lazy;
 
    public T GetResult() => _lazy.Value;
 
    public bool IsCompleted => true;
 
    public void OnCompleted(Action continuation) { }
}
 
public static class LazyAwaiterExtensions
{
    public static LazyAwaiter<T> GetAwaiter<T>(this Lazy<T> lazy)
    {
        return new LazyAwaiter<T>(lazy);
    }
}
public static async Task Foo()
{
    var lazy = new Lazy<int>(() => 42);
    var result = await lazy;
    Console.WriteLine(result);
}

这个例子看起来十分做作,但它所展示出的可扩展性的这一点,是非常有用且在开发中广泛应用的。比如, Reactive Extensions for .NET 项目就提供了 自定义awaiter 来在异步方法中等待 IObservable<T> 对象。基础类库(BCL)自己也提供 YieldAwaitableTask.YieldHopToThreadPoolAwaitable 使用:

public struct HopToThreadPoolAwaitable : INotifyCompletion
{
    public HopToThreadPoolAwaitable GetAwaiter() => this;
    public bool IsCompleted => false;
 
    public void OnCompleted(Action continuation) => Task.Run(continuation);
    public void GetResult() { }
}

我们通过下面的例子来演示 HotToThreadPoolAwaitable 的使用:

[Test]
public async Task Test()
{
    var testThreadId = Thread.CurrentThread.ManagedThreadId;
    await Sample();
 
    async Task Sample()
    {
        Assert.AreEqual(Thread.CurrentThread.ManagedThreadId, testThreadId);
 
        await default(HopToThreadPoolAwaitable);
        Assert.AreNotEqual(Thread.CurrentThread.ManagedThreadId, testThreadId);
    }
}

任何“异步”方法的第一部分(第一个 await 关键字之前的部分)都已同步的方式执行。在大多数情况下,对于急切的参数求证是可取的,但有时候我们不想让被调用方法阻塞调用者的线程。HopToThreadPoolAwaitable 就能让异步方法剩下的部分派送给线程池中的其他线程来执行,而不是调用者的线程。

Task-like 类型

C#编译器很早就支持了自定义 awaiter (从C# 5开始)。这项扩展方式确实有用,但也有限制,所有返回值必须为 voidTaskTask<T> 。从 C# 7.2开始,编译器支持 task-like 类型。

Task-like 类型 是由 AsyncMethodBuilderAttribute 特性标识的类或结构体,且该特性与一个 builder 类型 关联。 为了让 task-like 类型发挥作用,它需要像上一节提到的一样可被等待。基本上,在第一节的方法得到官方支持后,task-like 类型就能够让前面两节的扩展点结合起来。

注:现在你可以自定义上文提到的 AsyncMethodBuilderAttribute 。你可以在我(作者)的 github 仓库 找到例子。

下面有一个将 task-like 定义为结构体的简单例子:

public sealed class TaskLikeMethodBuilder
{
    public TaskLikeMethodBuilder()
        => Console.WriteLine(".ctor");
 
    public static TaskLikeMethodBuilder Create()
        => new TaskLikeMethodBuilder();
 
    public void SetResult() => Console.WriteLine("SetResult");
 
    public void Start<TStateMachine>(ref TStateMachine stateMachine)
        where TStateMachine : IAsyncStateMachine
    {
        Console.WriteLine("Start");
        stateMachine.MoveNext();
    }
 
    public TaskLike Task => default(TaskLike);
 
    // AwaitOnCompleted, AwaitUnsafeOnCompleted, SetException 
    // and SetStateMachine are empty

}
 
[System.Runtime.CompilerServices.AsyncMethodBuilder(typeof(TaskLikeMethodBuilder))]
public struct TaskLike
{
    public TaskLikeAwaiter GetAwaiter() => default(TaskLikeAwaiter);
}
 
public struct TaskLikeAwaiter : INotifyCompletion
{
    public void GetResult() { }
 
    public bool IsCompleted => true;
 
    public void OnCompleted(Action continuation) { }
}

现在我们就能定义一个返回类型是 TaskLike 的方法,甚至在方法内我们可以使用不同的 task-like 类型。

public async TaskLike FooAsync()
{
    await Task.Yield();
    await default(TaskLike);
}

使用 task-like 最主要的原因是它能够减少使用异步方法的开销。每一次使用返回 Task<T> 的异步方法,都至少会向堆申请一次内存——因为 task 对象的存在。这对于大部分应用程序来讲还将就,尤其是粗粒度地使用异步方法时。但对于每秒可能跨越数千个小任务的底层代码来说,那就有亿点不优雅了。在这些场景下,减少每次调用的一次堆内存申请可以合理地提高性能。

总结

  • C# 编译器提供了多种方法来让我们扩展异步方法。

  • 你可以自定义自己的 AsyncTaskMethodBuilder 类来改变 Task-base 异步方法的行为表现。

  • 你可以通过实现自定义 awaiter 来让某个对象可被等待。

  • 从 C# 7 开始,你可以自定义自己的 task-like 类。

课外阅读

下次,我们会讨论影响异步方法性能的知识点,并且介绍官方最新的 task-like 类型——System.ValueTask

上一篇
下一篇