• [翻译]剖析C#中的异步方法


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

    有些括号里的是译注或我自己的理解。

    异步系列

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

    C#这门语言对开发者的生产效率有很大帮助,我很高兴最近的推动让它变得对高性能应用更加合适。

    举例来说:C# 5引入了“async”方法(async表示异步,也是关键字)。这个特性从用户的角度看是很实用的,因为它能将几个基于Task的操作合并为一个。但是这种抽象是需要代价的。Task是引用类型,每次实例化的时候都会造成堆上的内存分配,就算是“async”方法同步地执行完毕的情况下也不例外。有了C# 7,在某些场景下,异步方法可以返回类似Task的类型,比如ValueTask,来减少或避免在堆上的内存分配。

    为了理解如何将上述一切变为可能,我们需要看看异步方法在底层是如何实现的。

    但首先,先来回顾一点历史。

    TaskTask<T>都是.Net 4.0时引入的,在我看来,这对.Net的异步和并行编程带来了巨大的观念性的改变。不像早期的异步模式,如.Net 1.0的BeginXXX/EndXXX模式(也叫异步编程模型),或是来自.Net 2.0的基于事件的异步模式,如BackgroundWorker,任务(即Task实例)是可以组合的。

    一个任务代表一个单位的工作(或者说一件事,可能完成了,也可能还没完成),它承诺会在将来把这个工作的结果给你。这个承诺可以是基于IO操作,或计算密集型(computation-intensive)操作,但这不重要,重要的是这个操作的结果是“自给自足”的(早期的异步模型做不到这点),是一等公民。你可以传递一个“未来”:你可以将它存储在一个变量中,从一个方法返回它,或者将它传递给另一个方法。你可以把两个“未来”合并,形成另一个新的,你可以给这个“未来”添加continuation(就是这个任务完成之后的回调,或者说“任务完成后的延续”),然后同步地等待(即await,也是关键字)结果。仅仅依靠一个任务实例,你就可以根据操作是成功了还是失败了,或是被取消了,来决定下一步执行什么。

    任务并行库(Task Parallel Library)(TPL)改变了我们对并行的思考方式,C# 5通过引入async/await而向前迈进了一步。Async/await能帮我们将任务组合起来,让我们能使用像try/catchusing等著名的结构。但正如其他任何抽象,async/await这个特性是有代价的。要了解这个代价是什么,我们必须去底层看看。

    异步方法的本质

    通常来说一个方法只有一个进入点,一个出口点(它确实可以有多个return语句,但是在运行时,一次调用只有一个出口点)。但是异步方法和迭代器(有yield return的方法)却不同。就异步方法来说,调用方几乎能立即得到结果(也就是TaskTask<T>),然后通过这个得到的任务,等待(await)实际的结果。

    让我们将“异步方法”定义为一个被上下文(contextual)关键字async所标记的方法。这并不意味着这个方法异步地执行。甚至这并不意味着这个方法是异步的。这个关键字的意思只是:编译器会对这个方法进行一些特殊的转换处理。

    让我们考虑下面这个异步方法:

    class StockPrices
    {
        private Dictionary<string, decimal> _stockPrices;
        public async Task<decimal> GetStockPriceForAsync(string companyId)
        {
            await InitializeMapIfNeededAsync();
            _stockPrices.TryGetValue(companyId, out var result);
            return result;
        }
     
        private async Task InitializeMapIfNeededAsync()
        {
            if (_stockPrices != null)
                return;
     
            await Task.Delay(42);
            // 从外部数据源或内存中的缓存得到股票价格
            _stockPrices = new Dictionary<string, decimal> { { "MSFT", 42 } };
        }
    }
    
    

    GetStockPriceForAsync方法保证了_stockPrices这个map被初始化,然后从缓存(即_stockPrices)中获得结果。

    为了更好地理解编译器做了或能做什么,让我们试着手写一个转换。

    手动转换一个异步方法

    TPL提供了两个主要的构建快,帮助我们构建和连接任务:Task.ContinueWith用于任务继续,TaskCompletionSource<T>用户手动构建任务。

    class GetStockPriceForAsync_StateMachine
    {
        enum State { Start, Step1, }
        private readonly StockPrices @this;
        private readonly string _companyId;
        private readonly TaskCompletionSource<decimal> _tcs;
        private Task _initializeMapIfNeededTask;
        private State _state = State.Start;
     
        public GetStockPriceForAsync_StateMachine(StockPrices @this, string companyId)
        {
            this.@this = @this;
            _companyId = companyId;
        }
     
        public void Start()
        {
            try
            {
                if (_state == State.Start)
                {
                    // 从方法的开始到第一个“await”的代码
     
                    if (string.IsNullOrEmpty(_companyId))
                        throw new ArgumentNullException();
     
                    _initializeMapIfNeededTask = @this.InitializeMapIfNeeded();
     
                    // 更新状态并注册回调函数
                    _state = State.Step1;
                    _initializeMapIfNeededTask.ContinueWith(_ => Start());
                }
                else if (_state == State.Step1)
                {
                    // 需要先检查错误和是否被取消
                    if (_initializeMapIfNeededTask.Status == TaskStatus.Canceled)
                        _tcs.SetCanceled();
                    else if (_initializeMapIfNeededTask.Status == TaskStatus.Faulted)
                        _tcs.SetException(_initializeMapIfNeededTask.Exception.InnerException);
                    else
                    {
                        // 从第一个await到方法结束的代码
     
                        @this._store.TryGetValue(_companyId, out var result);
                        _tcs.SetResult(result);
                    }
                }
            }
            catch (Exception e)
            {
                _tcs.SetException(e);
            }
        }
     
        public Task<decimal> Task => _tcs.Task;
    }
     
    public Task<decimal> GetStockPriceForAsync(string companyId)
    {
        var stateMachine = new GetStockPriceForAsync_StateMachine(this, companyId);
        stateMachine.Start();
        return stateMachine.Task;
    }
    

    这段代码有些冗长但相对好懂。GetStockPriceForAsync中的所有逻辑都被移到了使用了 "continuation passing style"GetStockPriceForAsync_StateMachine.Start方法。我们的异步转换的主要思想就是按“await边界”来划分原来的方法。划分的第一块代码段就是方法的开始到第一个await。第二个代码段——从第一个await到第二个await。第三个代码段——从第二个await到第三个await或是方法的结尾,以此类推:

    // 生成的状态机的第一步:
     
    if (string.IsNullOrEmpty(_companyId)) throw new ArgumentNullException();
    _initializeMapIfNeededTask = @this.InitializeMapIfNeeded();
    

    每一个被等待的任务现在都变成了状态机的一个字段,Start方法将自己注册为这些任务的continuation:

    _state = State.Step1;
    _initializeMapIfNeededTask.ContinueWith(_ => Start());
    

    然后,当任务完成时,Start方法被回调,_state字段被检查从而知道我们进行到哪一步了。然后的逻辑就是检查任务是否成功,或被取消。如果成功,状态机就继续执行下一段代码段。当一切都完成后,状态机设置TaskCompletionSource<T>实例的结果,让GetStockPricesForAsync返回的任务变成“已完成”的状态。

    // 从第一个await到方法结束的代码
     
    @this._stockPrices.TryGetValue(_companyId, out var result);
    _tcs.SetResult(result); // 让调用者得到结果
    

    这个“实现”有一些缺陷:

    • 有很多堆分配:一次对状态机的分配,一次对TaskCompletionSource<T>的分配,一次对TaskCompletionSource<T>内部的任务实例的分配,一次对continuation委托的分配。
    • 缺少“热路径优化”("hot path optimizations"):如果被等待的任务已经完成了,那么就没有理由再创建一个continuation。
    • 缺少可扩展性:这个实现与基于任务的类紧密耦合,所以不可能用于其他场合,比如等待其他非TaskTask<T>的类型或返回类型。

    现在让我们看一下实际的异步状态机是如何解决上述问题的。

    异步状态机

    编译器对异步方法的转换总得来说和上面我们的手动转换很相似。为了得到正确的行为,编译器依赖于以下类型:

    1. 生成的状态机,包含了所有原始的异步方法的逻辑,就像是一个异步方法的堆栈帧(stack frame)。
    2. 包含着完成的任务的AsyncTaskMethodBuilder(十分类似于 TaskCompletionSource<T>),它管理状态机的状态转换。
    3. 装饰(wrap)着一个任务的TaskAwaiter,它在必要时会给任务添加continuation。
    4. MoveNextRunner,它会在正确的执行上下文(execution context)中调用IAsyncStateMachine.MoveNext

    生成的状态机在debug模式下是class,在release模式下是struct。所有其他的类型(除了MoveNextRunner)都在BCL中被定义为struct。

    编译器为状态机生成一个类似<YourMethodNameAsync>d__1的类型名称,其中包含了用户无法定义或引用的非法标示符,从而避免命名冲突。但是为了简洁,在接下来的例子中我会用合法的标示符(用_代替<>)和稍微容易理解一点的名字。

    原始的方法

    原始的“异步”方法创建状态机实例,用捕获到的状态(包括this指针,如果方法不是静态的话)来初始化它,然后通过调用AsyncTaskMethodBuilder.Start方法(注意状态机实例是以ref关键字被传递的),来启动执行。

    [AsyncStateMachine(typeof(_GetStockPriceForAsync_d__1))]
    public Task<decimal> GetStockPriceFor(string companyId)
    {
        _GetStockPriceForAsync_d__1 _GetStockPriceFor_d__;
        _GetStockPriceFor_d__.__this = this;
        _GetStockPriceFor_d__.companyId = companyId;
        _GetStockPriceFor_d__.__builder = AsyncTaskMethodBuilder<decimal>.Create();
        _GetStockPriceFor_d__.__state = -1;
        var __t__builder = _GetStockPriceFor_d__.__builder;
        __t__builder.Start<_GetStockPriceForAsync_d__1>(ref _GetStockPriceFor_d__);
        return _GetStockPriceFor_d__.__builder.Task;
    }
    

    按引用传递是一个重要的优化,因为状态机往往是相当大的struct(>100字节),按引用传递避免了不必要的拷贝。

    状态机
    struct _GetStockPriceForAsync_d__1 : IAsyncStateMachine
    {
        public StockPrices __this;
        public string companyId;
        public AsyncTaskMethodBuilder<decimal> __builder;
        public int __state;
        private TaskAwaiter __task1Awaiter;
     
        public void MoveNext()
        {
            decimal result;
            try
            {
                TaskAwaiter awaiter;
                if (__state != 0)
                {
                    // 生成的状态机的状态1:
                    if (string.IsNullOrEmpty(companyId))
                        throw new ArgumentNullException();
     
                    awaiter = __this.InitializeLocalStoreIfNeededAsync().GetAwaiter();
     
                    // 热路径优化:如果任务已经完成,那么状态机自动跳到下一步
                    if (!awaiter.IsCompleted)
                    {
                        __state = 0;
                        __task1Awaiter = awaiter;
     
                        // 下面的调用终究会导致状态机的装箱(boxing)
                        __builder.AwaitUnsafeOnCompleted(ref awaiter, ref this);
                        return;
                    }
                }
                else
                {
                    awaiter = __task1Awaiter;
                    __task1Awaiter = default(TaskAwaiter);
                    __state = -1;
                }
     
                // GetResult返回void,但是如果被等待的任务失败了,它就会抛出异常
                // 这个异常之后会被捕捉并改变“结果任务”。
                awaiter.GetResult();
                __this._stocks.TryGetValue(companyId, out result);
            }
            catch (Exception exception)
            {
                // 最终状态:失败
                __state = -2;
                __builder.SetException(exception);
                return;
            }
     
            // 最终状态:成功
            __state = -2;
            __builder.SetResult(result);
        }
     
        void IAsyncStateMachine.SetStateMachine(IAsyncStateMachine stateMachine)
        {
            __builder.SetStateMachine(stateMachine);
        }
    }
    

    生成的状态机看起来很复杂,但是本质上它和我们手动创建的状态机是很类似的。

    尽管生成的状态机与我们手动创建的类似,但它有一些非常重要的区别:

    1. “热路径”("Hot path")优化

    与我们的天真方法不同,生成的状态机知道:一个等待的任务可能已经完成了。

    awaiter = __this.InitializeLocalStoreIfNeededAsync().GetAwaiter();
     
    // 热路径优化:如果任务已经完成,那么状态机自动跳到下一步
    if (!awaiter.IsCompleted)
    {
        // 不相关的代码
     
        // 下面的调用终究会导致状态机的装箱(boxing)
        __builder.AwaitUnsafeOnCompleted(ref awaiter, ref this);
        return;
    }
    

    如果被等待的任务已经完成(无论是否成功),状态机进入下一步:

    // GetResult返回void,但是如果被等待的任务失败了,它就会抛出异常
    // 这个异常之后会被捕捉并改变“结果任务”。
    awaiter.GetResult();
    __this._stocks.TryGetValue(companyId, out result);
    

    这意味着如果所有被等待的任务都已事先是完成的状态,那么整个状态机都会保持在堆栈上。即使在今天,如果所有被等待的任务已经完成,或者会同步地执行完毕,异步方法也会有一个极其小的内存开销。唯一剩下的内存分配就是任务实例本身。

    2. 错误处理

    生成的状态机并没有对错误或被取消状态的“被等待任务”进行特殊的逻辑上的处理。状态机调用awaiter.GetResult(),如果任务是被取消的状态那么这个方法会抛出TaskCancelledException,如果任务错误那么就是另一个异常类型。这是个优雅的解决办法,在这里可以正常地运作,因为GetResult()相较于task.Wait()task.Result在错误处理上有一些不同。

    即使只有唯一一个导致任务失败的异常,task.Wait()task.Result都会抛出一个AggregateException异常。理由很简单:一个任务不仅可以代表通常只有一个错误的IO密集型(IO-bound)操作,还可以代表并行计算的结果。在后者的情况下,操作可能会有一个以上的错误,而AggregateException就是设计为把所有错误集中在一个地方。

    但是async/await是专门为通常最多只有一个错误的异步操作设计的。所以语言设计者们觉得:让awaiter.GetResult()AggregateException中包含的第一个错误抛出,是更合理的。这个设计决策并不是完美的,在接下来的文章中,我们将看到这种抽象何时会有缺陷。

    异步状态机仅仅是整个迷宫中的一小部分。要想看清整个迷宫,我们需要知道状态机实例如何与 TaskAwaiter<T>和 AsyncTaskMethodBuilder<T>进行交互。

    不同的部分是如何被粘合在一起的?

    这个图表看起来十分复杂,但每一部分都是精心设计的,都扮演泽重要的角色。其中最有趣的协作发生在当一个被等待的任务尚未完成时(在图中以棕色矩形标记):

    执行上下文(Execution Context)

    你可能会问:执行上下文是什么?为什么我们需要搞得这么复杂?

    在同步的世界里,每个线程都将上下文信息保存在线程本地(thread-local)的存储中。可以是安全相关的信息,特定文化的数据,或其他东西。当在一个线程中按顺序调用三个方法时,这些信息会自然地在这些方法中传递。但对于异步方法来说,这已经不再适用了。异步方法的每个“部分”都可以在不同的线程中执行,这使得线程本地的信息无法使用。

    执行上下文保存了逻辑上的控制流的信息,即使它跨越多个线程。

    Task.RunThreadPool.QueueUserWorkItem这样的方法会自动捕获上下文。Task.Run方法从调用线程中捕获ExecutionContext,并将其存储在Task实例中。当与此Task实例相关联的TaskScheduler执行一个给定的委托时,它会在存储的上下文中执行ExecutionContext.Run

    我们可以用AsyncLocal来实际演示一下这个概念:

    static Task ExecutionContextInAction()
    {
        var li = new AsyncLocal<int>();
        li.Value = 42;
     
        return Task.Run(() =>
        {
            // Task.Run会恢复执行上下文
            Console.WriteLine("In Task.Run: " + li.Value);
        }).ContinueWith(_ =>
        {
            // 任务的continuation也会恢复执行上下文
            Console.WriteLine("In Task.ContinueWith: " + li.Value);
        });
    }
    

    在这些情况下,执行上下文被传递到Task.Run,然后又被传递到Task.ContinueWith. 所以如果你运行此方法你会看到:

    In Task.Run: 42
    In Task.ContinueWith: 42
    

    但并不是所有BCL中的方法都会自动捕获和恢复执行上下文。有两个例外分别是TaskAwaiter<T>.UnsafeOnCompleteAsyncMethodBuilder<T>.AwaitUnsafeOnComplete。语言的设计者们决定添加一些“不安全的”方法,使用AsyncMethodBuilder<T>MoveNextRunner而不是依靠如AwaitTaskContinuation的内置设施,来手动地传递执行上下文。我怀疑在现有的实现中有一些性能上的原因或是其他限制。

    这里有一个例子说明了区别:

    static async Task ExecutionContextInAsyncMethod()
    {
        var li = new AsyncLocal<int>();
        li.Value = 42;
        await Task.Delay(42);
     
        // 上下文被隐式地捕获。li.Value为42
        Console.WriteLine("After first await: " + li.Value);
     
        var tsk2 = Task.Yield();
        tsk2.GetAwaiter().UnsafeOnCompleted(() =>
        {
            // 上下文没有被捕获:li.Value为0
            Console.WriteLine("Inside UnsafeOnCompleted: " + li.Value);
        });
     
        await tsk2;
     
        // 上下文被捕获。li.Value为42
        Console.WriteLine("After second await: " + li.Value);
    }
    

    输出为:

    After first await: 42
    Inside UnsafeOnCompleted: 0
    After second await: 42
    

    结论

    • 异步方法与同步方法有很大的不同。
    • 编译器为每个异步方法都生成一个状态机,并将原来方法中所有的逻辑移到状态机中。
    • 生成的代码对同步场景进行了高度优化:如果所有被等待的任务都完成了,那么异步方法的额外开销是很小的。
    • 如果被等待的任务还没有完成,则依赖于许多帮助类来完成工作,以保持原方法的逻辑不变。

    参考文献

    如果你想学习更多与执行上下文相关的内容,我强烈推荐以下两篇博文:

    接下来:我们将探索一个C#异步方法的可扩展模型。

  • 相关阅读:
    HihoCoder
    中石油-高精度除法-java版
    01背包问题
    码农谷--将一个英文语句以单词为单位逆序排序/小码农挑选资源的方案数--未提交
    高精度乘法
    中石油—2的幂次方(power)
    中石油-【高精度】简单高精度加法
    中石油-【高精度】被限制的加法
    中石油【递归】分形 已提交
    中石油 【递归】普通递归关系
  • 原文地址:https://www.cnblogs.com/raytheweak/p/8735141.html
Copyright © 2020-2023  润新知