翻译自一篇博文,原文: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#异步方法的可扩展模型。

[翻译]剖析C#中的异步方法的更多相关文章

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

    翻译自一篇博文,原文:Extending the async methods in C# 异步系列 剖析C#中的异步方法 扩展C#中的异步方法 C#中异步方法的性能特点. 用一个用户场景来掌握它们 在 ...

  2. 【翻译】Anatomy of a Program in Memory—剖析内存中的一个程序(进程的虚拟存储器映像布局详解)

    [翻译]Anatomy of a Program in Memory—剖析内存中的一个程序(进程的虚拟存储器映像布局详解) . . .

  3. 又踩.NET Core的坑:在同步方法中调用异步方法Wait时发生死锁(deadlock)

    之前在将 Memcached 客户端 EnyimMemcached 迁移 .NET Core 时被这个“坑”坑的刻骨铭心(详见以下链接),当时以为只是在构造函数中调用异步方法(注:这里的异步方法都是指 ...

  4. 深入剖析Java中的装箱和拆箱

    深入剖析Java中的装箱和拆箱 自动装箱和拆箱问题是Java中一个老生常谈的问题了,今天我们就来一些看一下装箱和拆箱中的若干问题.本文先讲述装箱和拆箱最基本的东西,再来看一下面试笔试中经常遇到的与装箱 ...

  5. 从别人那淘的知识 深入剖析Java中的装箱和拆箱

    (转载的海子的博文   海子:http://www.cnblogs.com/dolphin0520/) 深入剖析Java中的装箱和拆箱 自动装箱和拆箱问题是Java中一个老生常谈的问题了,今天我们就来 ...

  6. 深入剖析Java中的自动装箱和拆箱过程

    深入剖析Java中的装箱和拆箱 自动装箱和拆箱问题是Java中一个老生常谈的问题了,今天我们就来一些看一下装箱和拆箱中的若干问题.本文先讲述装箱和拆箱最基本的东西,再来看一下面试笔试中经常遇到的与装箱 ...

  7. (原创)拨开迷雾见月明-剖析asio中的proactor模式(一)

    使用asio之前要先对它的设计思想有所了解,了解设计思想将有助于我们理解和应用asio.asio是基于proactor模式的,asio的proactor模式隐藏于大量的细节当中,要找到它的踪迹,往往有 ...

  8. [ 转载 ]学习笔记-深入剖析Java中的装箱和拆箱

    深入剖析Java中的装箱和拆箱 自动装箱和拆箱问题是Java中一个老生常谈的问题了,今天我们就来一些看一下装箱和拆箱中的若干问题.本文先讲述装箱和拆箱最基本的东西,再来看一下面试笔试中经常遇到的与装箱 ...

  9. 【转】深入剖析Java中的装箱和拆箱

    深入剖析Java中的装箱和拆箱 自动装箱和拆箱问题是Java中一个老生常谈的问题了,今天我们就来一些看一下装箱和拆箱中的若干问题.本文先讲述装箱和拆箱最基本的东西,再来看一下面试笔试中经常遇到的与装箱 ...

随机推荐

  1. 磁盘(disk)结构

  2. AGC009E Eternal Average

    atc 神题orz 那个擦掉\(k\)个数然后写上一个平均值可以看成是\(k\)叉Huffman树的构造过程,每次选\(k\)个点合成一个新点,然后权值设为平均值.这些0和1都会在叶子的位置,同时每个 ...

  3. 4.(基础)tornado应用安全与认证

    这一节我们介绍应用安全与认证,其实中间省略了一个数据库.对于tornado来说,读取数据库的数据,性能的瓶颈还是在数据库上面.关于数据库,我在<>中介绍了sqlalchemy,这是一个工业 ...

  4. 了解并安装Nginx

    公司使用nginx作为请求分发服务器,发现本人在查看nginx配置上存在些许困难,故仔细阅读了陶辉的<深入理解nginx模块开发与框架>第一部分,并作此记录. 了解 我根据书上的思路来了解 ...

  5. deepin下挂载的的Windows系统(NTFC)目录怎么是只读的???

    关键命令: df-h sudo ntfsfix /dev/sda4 重启 参考博客:深度官网问题之大神回复

  6. zabbix 性能优化

    Zabbix 安装好就放在那不管了,以为不需要调优.直到最近出现了如下一堆告警. 描述下我们的环境 硬件:8核 32G 软件:Centos7.6 Zabbix4.0.Httpd2.4.PHP7.3.M ...

  7. dfs序 线段树 dfs序列 主席树

    并查集 #include<stdio.h> ]; void sset(int x) { ;i<=x;i++) stt[i]=i; } int ffind(int x) { if(x= ...

  8. SSH中直接运行php文件

    cd /home/afish/domains/afish.cnblogs.com/public_htmlphp locoy_im_folder.php php locoy_im.php

  9. windows开启ftp服务

    1.启动或关闭windows-->internet information services-->ftp服务器   选中 2.此电脑右键-->管理-->服务和应用程序--> ...

  10. 安装tidb数据库

    1.下载压缩包 安装tar包路径 命令:wget http://download.pingcap.org/tidb-latest-linux-amd64.tar.gz 命令:wget http://d ...