[翻译]扩展C#中的异步方法

翻译自一篇博文,原文:Extending the async methods in C#

异步系列

  • 剖析C#中的异步方法
  • 扩展C#中的异步方法
  • C#中异步方法的性能特点。
  • 用一个用户场景来掌握它们

在上一篇中我们讨论了C#编译器是如何转换异步方法的。在这一篇,我们将重点讨论C#编译器为自定义异步方法的行为提供的可扩展性。

关于如何控制异步方法机制有3种方法:

  • System.Runtime.CompilerServices命名空间中提供你自己的async method builder。
  • 使用自定义的task awaiter。
  • 定义你自己的“类任务”(task-like)类型

System.Runtime.CompilerServices 命名空间中的自定义类型

上一篇文章中我们已经知道,异步方法被C#编译器转换从而生成的状态机是依靠于某些预定义的类型的。但是C#编译器却并不一定要求这些众所周知的类型来自于某个特定的程序集。例如,你可以在你的项目中提供自己对AsyncVoidMethodBuilder的实现,然后C#编译器就会把异步机制“绑定”到你的自定义类型。

这种方式可以很好地让我们来探索底层的转换,以及运行时发生了什么:

namespace System.Runtime.CompilerServices
{
    // 你自己项目中的AsyncVoidMethodBuilder.cs
    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 
        // 和SetStateMachine都不提供具体实现
    }   
}

现在,在你的项目中的每一个异步方法都会使用这个自定义的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方法来测试带有await字句,也就是返回一个未完成的任务的异步方法的行为。完整的例子可以在这里找到:github.

若想改变async Task方法和async Task<T>方法的行为,则需要提供自定义的AsyncTaskMethodBuilderAsyncTaskMethodBuilder的实现。

这些类型的完整示例可以在我的名为EduAsync的github项目中的AsyncTaskBuilder.csAsyncTaskMethodBuilderOfT.cs中分别找到。

感谢Jon Skeet为这个项目带来的灵感。这真的是一个很好的方法来更加深入地学习异步机制。

自定义的awaiter

前面的例子并不优雅,显然不适合用于生产环境。我们可以通过这种方式学习异步机制,但肯定不希望在自己的代码库中看到这样的代码。C#的作者们已在编译器中内置了适当的可扩展点,从而允许在异步方法中“等待(await)”不同的类型。

为了使一个类型是“可等待的”(即在await表达式的上下文中是有效的),这个类型应该遵循一个特殊的模式:

  • 编译器应该能找到一个叫GetAwaiter实例方法或扩展方法。这个方法的返回类型应该满足某些条件:
  • 实现了INotifyCompletion接口。
  • bool IsCompleted {get;}属性和T 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用于在异步方法中等待(await)IObservable<T>的实例。BCL自己也有用于Task.YieldHopToThreadPoolAwaitableYieldAwaitable

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

下面的单元测试展示了上面的awaiter的运用:

[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语句之前的部分)是同步执行的。在大多数情况下,这是没问题并且对“预先参数验证(eager argument validation )”是必要的,但有时候我们希望确保方法的主体不会阻塞调用者的线程。HopToThreadPoolAwaitable确保了方法的剩余部分在一个线程池线程而不是调用者线程中执行。

“类任务”(Task-like)类型

从支持async/await的编译器的第一个版本(即C# 5)开始,就可以自定义awaiter了。这个可扩展性十分有用但是却是有限的,因为所有的异步方法都必须返回void, TaskTask<T>。从C# 7.2开始,编译器支持“类任务”类型。

“类任务”类型是一个class或者struct,它与一个通过AsyncMethodBuilderAttribute标识的builder类型 相关联。要使“类任务”类型有用,它应该像我们前面描述的awaiter那样是可等待的。基本上,“类任务”类型结合了前面描述的两种可扩展性的方法,并且使第一种方法得到了正式支持。

现在你还必须自己定义这个attribute,例子:my github repo

下面是一个简单的例子,一个定义为struct的自定义“类任务”类型:

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类型的方法了,甚至可以在方法内部使用不同的“类任务”类型:

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

使用“类任务”类型的主要原因是为了减少异步操作的开销。每一个返回Task<T>的异步操作都至少在托管堆中分配了一个对象——这个任务本身。这对大多数应用程序来说都不是什么问题,特别是当他们处理粗粒度的异步操作时。但对可能会造成每秒执行数千个小任务的基础结构级代码来说,情况并非如此。对于这样的场景,每次调用减少一次分配可以合理地提高性能。

异步模式可扩展性

  • C#编译器提供了扩展异步方法的各种方式。
  • 你可以通过提供自己的AsyncTaskMethodBuilder类型来改变现有的基于Task的异步方法的行为。
  • 你可以通过实现“可等待模式(awaitable pattern)”使一个类型变得“可等待(awaitable)”。
  • 从C# 7开始你可以构建自己的“类任务”(task-like)类型。

其他参考资料

下次我们将讨论异步方法的性能特点,我们将会看到最新的“类任务”(task-like)类型System.ValueTask是如何影响性能的。

posted @ 2018-06-03 21:27  raytheweak  阅读(3276)  评论(1编辑  收藏  举报