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

时间:2022-08-31 15:21:07

翻译自一篇博文,原文: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>进行交互。

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

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

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

执行上下文(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#异步方法的可扩展模型。