异步一切

原文:Asynchronous Everything

Midori 是由很多超轻量级、细粒度进程构成的,它们通过强类型的消息传递接口连接彼此。通常见到的经典程序是那种单个的、庞大的进程 —— 也许内部还有多个线程。但我们取替之以一大堆的小进程,形成了自然的、安全的和大量的自动并行。同步阻塞直接就是不允许的,这意味着,不夸张地说所有的东西都是异步的:所有的文件和网络 IO,所有的消息传递,和所有的像跟其他异步工作项会合的“同步”活动项。因此造就系统超高并发,用户输入响应良好,并且非常容易扩展。但就如同你想象的那样,这当然也带来了令人着迷的挑战。

异步编程模型

Midori 的异步编程模型表面上很像 C# 的 async/await

这当然不是巧合。我是 .NET 任务库的架构师和主程序员。作为 Midori 并发架构师,我不得不承认我对这个眼前这个 .NET Release 的成功有些偏见。然而即使是我也清楚我们之前的工作不能照样在 Midori 上奏效,所以开始着手这段经年的旅程。但在我们前进的同时,我们也与 C# 团队紧密合作,以将一些 Midori 的做法带回到这个发布中的语言。并且早在 C# 着眼之前的一年多,就已经开始使用一种 async/await 的变体。我们并没有将所有 Midori 的优点都带给 .NET,有些已经显露出来了,大多数都是关于性能的方面。一想到我再也回不到过去及时将 .NET 的 Task 给弄成 struct,我就想死。

但我得超越自己。要进行一个漫长的旅程才能接触到现在这点,让我们从最开始的地方启程吧。

Promises

在我们异步模型的核心是一个叫 promises 的技术。现在,这个概念已经相当普遍了。但就像等会你会看到的那样,我们使用 promises 的方式更有趣些。我们受 E 系统)的影响很深。也许跟现在流行的大多数异步框架最大的不同是我们完全不玩虚的,在我们系统里面连一个同步的 API 都没有。

异步模型的第一版用的是显式的回调(callbacks)。这东西用过 Node.js 的都知道。主要想法就是你得到的任何一个操作的 Promise<T>,最终会给你一个结果 T (或出错)。这个 T 可能由内部的异步处理甚至在远程的某个地方跑出来的,不过使用它的就不需要关心了。它们只是把 Promise<T> 当作普通的类型处理,去索取的时候,必须将 T 交出来。

基本的 callback 模型像下面一样启动一些东西:

  1. Promise<T> p = ... some operation ...;
  2. ... 可选地跟 p 里面的操作并发地做一些事情 ...;
  3. Promise<U> u = Promise.When(
  4. p,
  5. (T t) => { ... the T is available ... },
  6. (Exception e) => { ... a failure occurred ... }
  7. );

后来我们将静态方法改成了实例方法:

  1. Promise<U> u = p.WhenResolved(
  2. (T t) => { ... the T is available ... },
  3. (Exception e) => { ... a failure occurred ... }
  4. );

注意这个 promise 链。操作的 callback 要不返回一个类型为 U 的值,要不根据情况扔出一个异常。然后 u promise 的使用者再干同样的活,一而再,再而三。

这就是并发数据流编程。它很好用,因为一系列操作的真值依赖(true dependencies)组织起系统活动的调度。传统的系统不是因为真值依赖,而是因为假值依赖经常发生故障,像程序突然在深层的异步 IO 调用中出了问题,但调用者却不知情。

实际上,你 Windows 的屏幕经常卡住变白可能就是因为这个引起的。我永远不会忘记几年前那份报告,它找到了导致 Outlook 挂起的一个主要原因。一个经常使用的 API 偶尔会尝试访问网络上的打印机以枚举 Postscript 字体。它将这些字体缓存下来,这样隔一段时间才需访问打印机一次,隔一段无法预测的时间。结果,这种“好”的表现让开发人员觉得从 UI 线程调用它是没问题的。测试的时候也没出什么 问题(嗯,可能开发人员用很值钱的电脑,在接近完美的网络下工作)。悲催的是,当网络卡爆时,结果就出现一个 10 秒的挂起白屏跟一个旋转圆圈鼠标。一直到现在,在我用过的每一个操作系统中,还是会有这种问题。

上面例子的问题在于开发者调用 API 时,高延迟存在没显现出来的可能。当调用被深深地埋在调用栈深处,标记为虚函数调用等,那就更难显现出来了。在 Midori 里,所有的异步性是在类型系统表现出来的,这种问题不可能会发生,因为类似的 API 必然会返回一个 promise 。当然,开发者仍然可以干些荒唐事(像在 UI 线程里面搞个无限循环),但要搞砸已经难得多了。特别是跟 IO 相关的活。

你不想数据流链继续下去了?没问题。

  1. p.WhenResolved(
  2. ... as above ...
  3. ).Ignore();

这被证明有一点反模式,它通常是你正对共享状态作修改的一个信号。

这个 Ignore 得稍微解释一下。我们的语言不许你忽略返回值,除非你显式表明要这样做。若你意外地忽略一些或很多重要的东西(例如:一个异常),这个特定的 Ignore 方法还会加入一些诊断信息来帮助调试。

最后我们为常用的模式加入了一堆帮助方法重载和 API:

  1. // Just respond to success, and propagate the error automatically:
  2. Promise<U> u = p.WhenResolved((T t) => { ... the T is available ... });
  3. // Use a finally-like construct:
  4. Promise<U> u = p.WhenResolved(
  5. (T t) => { ... the T is available ... },
  6. (Exception e) => { ... a failure occurred ... },
  7. () => { ... unconditionally executes ... }
  8. );
  9. // Perform various kinds of loops:
  10. Promise<U> u = Async.For(0, 10, (int i) => { ... the loop body ... });
  11. Promise<U> u = Async.While(() => ... predicate, () => { ... the loop body ... });
  12. // And so on.

这当然远不是什么新想法了。Joule) 和 Alice) 语言甚至有内置的语法让类似上面的笨重的回调传递变得让人更好受点。

但它还是让人没法忍。这编程模型扔掉了一大堆熟悉的编程语言构造,例如:循环。

这非常非常差劲!它会导致回调困境(callback soup)、非常多深层次的嵌套,而且经常在需要保证正确性的真正重要的代码里出现。想象一下你在一个磁盘驱动程序里面,看到这样的的代码:

  1. Promise<void> DoSomething(Promise<string> cmd) {
  2. return cmd.WhenResolved(
  3. s => {
  4. if (s == "...") {
  5. return DoSomethingElse(...).WhenResolved(
  6. v => {
  7. return ...;
  8. },
  9. e => {
  10. Log(e);
  11. throw e;
  12. }
  13. );
  14. }
  15. else {
  16. return ...;
  17. }
  18. },
  19. e => {
  20. Log(e);
  21. throw e;
  22. }
  23. );
  24. }

根本不可能搞清楚会发生什么。很难知道这各种各样的 return 到底 return 到哪里,有什么异常没有被处理,而且非常容易导致重复代码(例如上面的 error 分支),因为经典的代码块范围已经不适用了。上帝保佑,你需要的只是一个循环。而这正是一个磁盘驱动 —— 最需要可靠性的东西!

进入 Async 和 Await

几乎每一个重要的语言现在都具备类似 async 和/或 await 特性。而我们是从 2009 年开始大范围使用的。我说的大范围,那是真的大范围。

async/await 让我们保持系统的非阻塞性质并且消除了上述的一些可用性混乱。事后看来,这是很显然的,但请记住当时使用 await 上规模的最主流的的语言也不过是 F#,用它的异步工作流(也看看这篇论文)。尽管在可用性和生产力上很有好处,但在队伍里也有很大的争议,下面会提及更多。

我们搞的这套跟 C# 和 .NET 里的有点不同。让我们跟随从上面说的 promises 到新的基于 async/await 模型的脚步。我们一路走,一路我会指出它们的不同之处。

我们首先将 Promise<T> 重命名为 AsyncResult<T>,将它设计成 struct。(这跟 .NET 的 Task<T> 很相似,但比起“计算”,更关注“数据”。)这样一个相关类型的家族就诞生了:

  • T: 不会失败的同步计算即刻返回的结果。
  • Async<T>: 不会失败的异步计算的结果。
  • Result<T>: 可能会失败的同步计算即刻返回的结果。
  • AsyncResult<T>: 可能会失败的异步计算的结果。

最后一个其实只是 Async<Result<T>> 的简写。

会失败的和不会失败的区别是以后会提到的另一个课题。总而言之,不管怎样,我们的类型为我们保证了这些属性。

顺着下去,我们加入了 awaitasync 关键字。一个方法能被标记为 async

  1. async int Foo() { ... }

这意味着在它里面允许 await:

  1. async int Bar() {
  2. int x = await Foo();
  3. ...
  4. return x * x;
  5. }

开始这只不过是解决回调麻烦的语法糖,像 C# 里一样。但在最终,我们更进一步,为了性能,加入了轻量级的协程(coroutine)和链接栈(linked stack)。下面会提及更多。

要调用 async 方法的调用者必须做出选择:是用 await 并等待方法的结果呢?还是用 async 并执行异步的操作?所有的异步性在系统因此显式为:

  1. int x = await Bar(); // Invoke Bar, but wait for its result.
  2. Async<int> y = async Bar(); // Invoke Bar asynchronously; I'll wait later.
  3. int z = await y; // ...like now. This waits for Bar to finish.

这也给我们带来了一个非常重要但微妙的特性,这是我们很后面才认识到的。因为在 Midori 里面“等待”某样东西的唯一的办法是使用异步模型,而且没有隐藏的阻塞。我们的类型系统能告诉我们所有的能“等待”的东西。更重要的是,它能告诉我们所有不能等待的东西,这就是纯粹的同步计算!这就能用来保证没有代码能够阻塞 UI 绘画,以及其他的,我们下面能看到,非常多很强的能力。

因为系统中异步代码的绝对量太大,我们在语言里优化了很多 C# 还没有支持的模式。例如迭代器,for 循环和 LINQ 查询:

  1. IAsyncEnumerable<Movie> GetMovies(string url) {
  2. foreach (await var movie in http.Get(url)) {
  3. yield return movie;
  4. }
  5. }

或者,用 LINQ 的风格:

  1. IAsyncEnumerable<Movie> GetMovies(string url) {
  2. return
  3. from await movie in http.Get(url)
  4. ... filters ...
  5. select ... movie ...;
  6. }

整个 LINQ 体系是流式处理的,包括资源管理和背压

我们将数百万行的代码从旧的 callback style 转换成新的 async/await 方式。这个过程中发现了大量 bug,由原先显式回调模型的复杂的控制流造成的。特别是在循环和错误处理逻辑中,相比原来的愚蠢的 API 版本,现在可以使用熟悉的编程语言结构了。

我之前提到这是有争议的。队伍里大多数人喜欢这可用性的改善,但并不是全部都如此。

可能最大的问题在于它鼓励了一种拉模式(pull-style)的并发。拉,指的是调用者在处理它自己操作之前需要等待被调用者。在新的模型里,你需要花点力气才能不这样做。当然,由于 async 关键字的存在,不这样做是一直允许的,但就是比旧的模型多了那么一点摩擦。只需将 await 关键字拿掉,就变成了旧的、熟悉的、阻塞的等待模型。

我们在拉和推之间提供了桥梁,用一种响应式IObservable<T>/IObserver<T> 适配器的形式。我不会宣称他们非常成功,然而对于不采用数据流的有边际影响的操作,他们是很有用的。实际上,我们的整个 UI 框架是基于函数式响应式编程这个概念的,这需要跟 Reactive Framework 有点不同,为了性能。我们就不继续展开来说了。

一个有趣的结果是,在先 await 再返回一个 T 的方法,和直接返回一个 Async<T> 的方法之间,带来了一种新的差异。以前类型系统并不存在这种区别。坦白地说,这让我们很郁闷,现在也还是这样。例如:

  1. async int Bar() { return await Foo(); }
  2. Async<int> Bar() { return async Foo(); }

我们很想说这两种方式性能上是一样的,但很可惜,并不是。前一种阻塞并保持一个栈帧(stack frame)存活,后一种不会。有些编译器的小聪明能帮助处理常见的模式 —— 事实上跟异步尾调用是等价的 —— 但并不什么时候都那么凑效。

在它本身,并不是一个大问题。虽然它在一些重要的地方例如流中导致了一些反模式。开发人员倾向于在以前直接返回 Async<T> 的地方 await,导致一堆没必要的暂停的栈帧堆积。我们对大部分模式有很好的解决方案,但一直到项目的结束我们仍然为它挣扎,特别在需要跑满 10GB 网卡线速的网络栈。下面我们会讨论我们应用的一些技术。

但在旅程的结束往回看,这个改变还是值得的,在于模型的简单性和可用性,也在于它给我们打开了一些优化的大门。

执行模型

这就引出了执行模型。我们经历了也许五个不同的模型,但最后着陆在一个不错的地方。

完成异步一切的一个关键是超轻量级进程。这是可能做到的,多亏在上一篇提到的安全基础上构建的软件隔离进程(SIPs)

去除共享的、静态(static)的可变状态帮助我们保持进程纤小。在传统程序中,拥有一大堆可变静态变量,能烧掉多少地址空间是让人震惊的。我之前提到,我们冻结了绝大部分静态变量成常量,将它们在很多进程之间共享。执行模型还导致了更低成本的栈(详见下文),也是一个关键因素。最终的这些受益于不仅仅是技术,还有文化。每晚在实验室,我们测量进程的启动时间和占用内存,并有一个“棘轮”过程保证我们每个 sprint 都比上一个好。我们中的一个组每周找个地方,仔细看这些数字并尝试回答为什么升了、降了或者为什么还是一样。我们普遍地有这种性能文化,但正因如此,它保持了系统轻巧快速的基准。

进程中运行的代码不能阻塞。在内核里,阻塞在特定的地方是允许的,但要记得没有用户代码能够在内核里跑,所以这只是一个实现细节。当我说“非阻塞”,我是说真的:Midori 没有请求页面调度,这在传统的操作系统中,意味着接触一块内存有可能会物理阻塞以进行 IO 操作。我得说,消除了页面颠簸是非常受欢迎的,一直以来,我对新的 Windows 系统做的第一件事就是关掉虚拟内存。我宁愿操作系统因为内存不足去杀掉程序,然后稳定地运行下去,也不愿意它像疯了一样处理虚拟内存。

C# 的 async/await 实现完全是一个前端编译器的把戏。如果你反编译过生成的程序集(assembly),你就知道:它提升捕获到的变量到一个对象的的字段里,将方法体重写为一个状态机,然后用 Task 的继续传递机制来保持类似迭代器的对象在不同的状态中推进。

我们也是这样开始的,还向 C# 和 .NET 组分享了一些我们关键的优化。不幸的是,在 Midori 这种规模上,结果就是根本没法跑。

首先,要记得,Midori 一整个操作系统都是用垃圾回收内存写的。我们学到了一些重要的经验教训,这样做有充分的必要性。但我得说主要的启示是避免像灾难一样的过多的分配,包括那些短命的对象。早期在 .NET 有一种这样的流言:Gen0 回收是免费的。不幸的是,这带坏了很多的 .NET 类库代码,但这句话完全是胡扯。Gen0 回收带来暂停、弄脏缓存、还给高并发的系统带来频率节拍问题(beat frequency issues))。然而我会指出,在 Midori 这样的规模上运行垃圾回收的一个窍门恰恰是细粒度的进程模型,每一个进程都有单独的可以独立回收的堆。我以后会写一整篇文章来说我们是怎样让我们的垃圾回收器获得好的表现的,但这是最重要的架构特性。

因此,第一个关键的优化,如果一个 async 方法不进行 await ,就不应该为它做任何的分配。

我们给 .NET 分享了我们的经验,在实现 C# 的 await 时。可惜的是,那时候, .NET 的 Task 已经实现成类( class )了。因为 .NET 需要 async 方法返回 Task 类型,所以做不到零分配,除非你自己山寨一种模式,如缓存一个 singletonTask 对象。

第二个关键的优化是确保为进行了 awaitasync 的方法只做尽可能少的分配。

在 Midori 中,很常见一个 async 方法调用另外一个,这个又调用另外一个,一直下去。你设想一下在状态机模型下会发生什么,一个阻塞的叶子方法会触发了一连串的 O(K) 内存分配,K 是 await 时栈的深度。这是非常不划算的。

我们最终达成的是一个只会在 await 发生时才会分配的模型,而且在整个调用链中只会分配一次。我们管这调用链叫“活动(activity)”。最上层的 async 区分了活动的分界。结果,async 可能还是有点花销,但 await 是免费的。

是的,这需要一个额外的步骤,而且这一步很重要。

最后的关键优化是确保 async 方法被强加的消耗尽可能少。这意味着要消除状态机重写模型的一些不够好的方面。实际上,我们最终放弃了状态机重写:

  1. 它完全摧毁了代码质量。它阻碍了内联(inlining)这样的简单优化,因为很少内联器会认为一个有多个状态变量的切换语句,加上堆分配的显示帧,有大量的局部变量复制,会是一个“简单的方法”。我们在和用本机(native)代码开发的操作系统竞争,所以这很重要。

  2. 它要求改变调用约定。就是说,返回值必须是 Async*<T> 对象,跟 .NET 的 Task<T> 类似。不是最初的那个。虽然我们的是 struct —— 消除了分配的方面 —— 它们仍然是多余的,并且要求代码进行状态和类型测试来获取值。如果我的异步方法返回一个 int,我希望生成的机器码就是一个他妈的返回 int 的方法!

  3. 最后,经常有太多的堆状态需要捕获了。我们希望一个正在 await 中的活动使用的总空间尽可能的小。有些进程有上百上千个这样的活动是很常见的,加上有些进程不断在它们间切换。由于内存占用和缓存理由,让它们尽可能地保持跟最精心地手工制作的状态机一样小是非常重要的。

我们创建的模型是一个异步活动跑在链接栈(linked stack)上的模型。这些链接开始只有 128 字节,并按需要增长。多次试验之后,我们形成了个链接每次大小翻倍的模型。即一开始 128b,然后 256b,最多到 8k 的分块。实现这个需要很深入的编译器支持。编译器知道提升链接检查,特别是在循环中,并在它能预测栈帧的大小的时候(为了内联)探测更大数量的链接检查。用链接存在一个常见的问题是会频繁结束并重新链接,特别是在循环里进行函数调用的边界,然而上述的绝大部分优化避免了这种情况发生。而且即使发生了,我们的链接代码是手写的汇编 —— 如果我没记错的话,链接只用三个指令 —— 而且我们保持了一个可重用热连接片段的缓存。

还有另一个关键的创新。还记得吧,我之前提过的,我们从类型系统中知道一个方法是异步与否,就简单地看是不是有 async 关键字。这让我们在编译器中可以将所有非异步的代码执行在经典的栈上。结果就是所有的同步代码仍然是无需探测的!另一个效果是操作系统内核能在一堆池化的栈上调度所有的同步代码。它们总是热的,更类似一个经典的线程池,而不是普通的 OS 调度器。因为这些代码从不阻塞,你不需要 O(T) 个栈,这里 T 是整个系统中活动线程的数量。作为替代,你最后只需要 O(P) 个栈,这里 P 是机器的处理器数目。记住,去掉了虚拟内存也是实现这个成果的关键。所以这是真的一堆大赌注加起来最终强烈地改变了游戏的规则。

消息传递

系统中一个基础的部分在之前的讲述中被忽略了:消息传递。

进程不仅是超轻量级的,他们还是单线程的。每个上面跑一个事件循环而且事件循环不能被阻塞。由于系统的非阻塞特性,它的工作是执行一块非阻塞的工作指导它完成或者进行等待,然后获取下一块工作,如此下去。之前在等待中并满足了的 await 被简单地调度到曲柄的下一个转弯处。

每一个这样的曲柄轮转被贴切地称为:一个“轮换”。

这意味着轮换会在异步活动之间发生,不在其他地方,只在 await 点上。因此,并发只会在定义好的点发生交错。这是推测并发状态的一个巨大的福利,然而也随之有些坑,我们后面会探索这些。

最好的地方是,进程再也不会遭受共享内存竞争了。

我们当然有一个任务和数据并行框架。它放大了我之前提到的语言上的并发安全特性 —— 不可变性、隔离性和只读声明 —— 来确保数据竞争是完全被避免的。这用在细粒度的能使用额外的计算能力的计算上。然而,通过使用消息传递解耦成进程联系,系统中的大部分能够获得并行的执行。

每个进程能够导出一个异步的接口,类似:

  1. async interface ICalculator {
  2. async int Add(int x, int y);
  3. async int Multiply(int x, int y);
  4. // Etc...
  5. }

跟大多数异步 RPC 系统一样,从这个接口生成了一个服务器端桩(stub)和客户端代理。在服务器上,我们会实现这个接口:

  1. class MyCalculator : ICalculator {
  2. async int Add(int x, int y) { return x + y; }
  3. async int Multiply(int x, int y) { return x * y; }
  4. // Etc...
  5. }

每个服务器端的对象还可以通过公开一个构造函数来请求权能,非常类似程序的 main 入口点,就像我在前一篇文章里描述的那样。我们的应用程序模型负责激活和连接服务器的程序和服务。

服务端还能返回其他对象的引用,不管是它自身进程的还是别的远端进程的。系统使用垃圾回收器来协调和管理对象的生命周期。例如,树:

  1. class MyTree : ITree {
  2. async ITree Left() { ... }
  3. async ITree Right() { ... }
  4. }

正如你猜的那样,客户端会通过代理对象,连接到服务器端的运行在进程中的对象,有可能服务端跟客户端在同一个进程里,但多数情况下对象是远程的,因为这是进程之间彼此通讯的方式:

  1. class MyProgram {
  2. async void Main(IConsole console, ICalculator calc) {
  3. var result = await calc.Add(2, 2);
  4. await console.WriteLine(result);
  5. }
  6. }

设想一下计算器是一个系统服务,这个程序将与系统服务通信,将两个数字加在一起,然后将结果打印到控制台(控制台本身也可能是一个不同的服务)。

系统中一些关键的方面是消息传递非常有效率。首先,所有有必要在进程间交付的数据结构都是处于用户模式的,所以无需进行内核模式的转换。实际上,他们绝大多数都是无锁的。第二,系统采用一种称为“流水线(pipelining)”的技术来消除往返和同步乒乓(synchronization ping-ponging)。大批大批的消息能填充到管道中直到管道被填满。它们一次次以大块的方式投递。最后,一种崭新称为“三方切换”的技术被用来缩短消息传递中各方之间的通讯路径。这消除了中间方,通常它的工作只是简单地转发消息,没有带来什么价值,除了延迟和浪费。

消息传递

只有以下类型能够封送跨越消息传递边界:

  • 基本类型(例如:intstring 等)。
  • 自定义的不包含指针的数据结构(显式标记封送)。
  • 指向 stream 的引用(下面会说)。
  • 对其他异步对象的引用(例如:我们上面的 ICalculator)。
  • 一种特别的 SharedData 对象,这个下面会进一步解释。

大部分是很明显的。不过 SharedData 有点特别。Midori 中有一个“零拷贝”的基本哲学。这是一个将来会有一篇文章讨论的主题。这是我们在一些关键基准上性能超过许多经典系统的秘密武器。基本的想法是,如果能够避免的话,没有一个字节应该被复制。例如,所以在进程间进行消息传递时,我们不想通过复制来封送字节数组(byte[])。SharedData 是一种指向进程间共享的堆中一些不可变数据的自动引用计数的指针。操作系统内核管理这个堆内存,并且在所有引用减为零时进行回收。因为引用计数是自动的,程序不会出问题。这利用了我们语言的一些新特性,如析构函数。

我们还有“邻近对象(near objects)”的概念,需要一个额外的步骤,能让你在同一个进程对中封送不可变数据的引用。这让你可以通过引用来封送胖(rich objects)对象。例如:

  1. // An asynchronous object in my heap:
  2. ISpellChecker checker = ...;
  3. // A complex immutable Document in my heap,
  4. // perhaps using piece tables:
  5. immutable Document doc = ...;
  6. // Check the document by sending messages within
  7. // my own process; no copies are necessary:
  8. var results = await checker.Check(doc);

正如你猜测的那样,所有的这些都是建立在一个更基础的 “通道(channel)” 概念之上的。这跟你在 Occam),Go) 和相关的 通信顺序进程(CSP) 语言中看到的类似。我个人觉得围绕消息如何在系统上流转的结构和相关的检查,比直接面对通道编程更令人舒服,但你可能会有所异议。结果跟用 actor 编程类似,但在进程和对象标识的关系方面有些关键的差别。

流(Streams)

我们的框架有两种基础的流类型:包含字节流的 Stream 和包含 T 序列的 Sequence<T>。它们都是前向的(forward-only)(我们有另外分开的可随机访问的类)并且 100% 的异步。

你知道为什么会有两种类型吗?他们开始是完全独立的东西,最终变成了兄弟关系,共享很多策略和实现。然而,他们仍然保持不同的关键原因在于,如果你知道你正在处理的是原始的字节流时,你可以在实现中进行大量有趣的性能改进,相对于一个完全的泛型版本而言。

为了我们现在讨论的目的,暂且让我们设想 StreamSequence<T> 是同构的。

如之前提到的,我们还有 IAsyncEnumerable<T>IAsyncEnumerator<T> 类型,当你想消费某些东西时,它们是你编码中最通用的接口。当然,开发人员能够实现他们自己的流类型,特别是因为我们在语言中有异步的迭代器。一组完整的异步 LINQ 操作在这些接口上工作,因此 LINQ 非常适合消费和组合流和序列。

除了上面的基于枚举的消费技术之外,所有标准的 peeking 和基于批量的 API 也是可用的。然而,必须指出,整个系统的框架是在内核的零拷贝能力的基础上构建的,为了避免复制。每次我在 .NET 中看到使用 byte[] 来处理流,都会让我留下热泪。结果就是我们的流在系统中非常基础的地方使用,像网络栈自己,文件系统,Web 服务器等等。

之前提过,我们在流 API 中支持推和拉两种风格的并发。例如,我们支持两种风格的生成器(generator):

  1. // Push:
  2. var s = new Stream(g => {
  3. var item = ... do some work ...;
  4. g.Push(item);
  5. });
  6. // Pull:
  7. var s = new Stream(g => {
  8. var item = await ... do some work ...;
  9. yield return item;
  10. });

流的实现处理了大量和通用的细节,确保流是尽可能高效的。一个关键的技术是流控,从 TCP 那边借鉴过来的。一个流的生产者和消费者完全在抽象的覆盖下合作,以确保管道不会变得非常不平衡。这很像 TCP 流控的工作方式,维护一个叫做窗口(window)的东西,并根据情况打开或关闭它。总的来说,这非常有效。例如,我们的实时多媒体栈有两个异步的管道,一个处理音频,另一个处理视频,并将它们合并在一起,以实现音频和视频的同步。通常,内置的流控机制就能让它们不掉帧。

“巨大”的挑战

以上是一段旋风式的旅程,其中我覆盖了一些关键的细节,希望在你脑中能形成一个较为完整的画面。

在旅程中我们还有若干“巨大的挑战”没有覆盖到。我永远也不会忘记它们,因为它们形成我整整 3 年的绩效考核的大纲。我决心去征服它们。我不能说我们交出的答卷是完美的,但我们得到了很深入的进展。

取消

对可取消操作的需求不是什么新东西。我提出了 .NET 中的 CancellationToken 的抽象,很大程度是为了应对之前使用“隐式作用域”尝试的环境权限(ambient authority)的一些挑战。

在 Midori 中的不同之处在于规模。异步的操作到处都是。它分散在不同的进程中,有时候甚至在不同的机器中。要追踪跑飞的操作是非常困难的。我有个简单的用例就是如何可靠地实现浏览器的取消按钮。简单地渲染一个网页就涉及几个浏览器自身的进程,加上几个网络进程 —— 包括网卡的驱动程序 —— 以及 UI 栈,等等。有能力立即和可靠地取消所有这些操作不仅是吸引人的,它是必需的。

解决方案最终建立在 CancellationToken 的基础上。

关键的创新在于首先在我们整个消息传递模型之上重建 CancellationToken 的想法,然后将它在所有适合的地方编织起来。例如:

  • CancellationToken 能跨进程扩展它们的覆盖范围。
  • 整个的异步对象可以封装在 CancellationToken 中,并用于触发撤销。
  • 可以用 CancellationToken 调用整个异步函数,这样可以将取消向下传播。
  • 一些如同存储的方面,需要手动检查以确保状态保持一致性。

总体而言,我们采取了“整个系统”的方法来让取消布满整个系统,包括扩展的跨进程的取消覆盖。我们搞定了这个让我非常愉快。

状态管理

有争议的“状态管理”问题可以通过简单的例子来说明:

  1. async void M(State s) {
  2. int x = s.x;
  3. await ... something ...;
  4. assert(x == s.x);
  5. }

这里的问题是,这个断言会触发吗?

答案当然是会。即使没有并发,重入也是个问题。依赖我在“…something…”里做了什么,s 指向的 State 对象有可能在返回给我们之前就被改变了。

但有一些更微妙的地方,即使在“…something…”里没有改变对象,我们还是可能会发现断言被触发。设想一下这个调用:

  1. State s = ...;
  2. Async<void> a = async M(s);
  3. s.x++;
  4. await a;

这个调用者有同一个对象的别名。如果 M 的异步等待操作需要等待,控制就会交回给调用者。然后这里的调用者在 M 中的正在等待的操作完成之前就将 x 增加了。很不幸,当 M 恢复时,它会发觉 x 的值不再跟 s.x 一样了。

这个问题表现在其他更加曲折的方面。例如,设想早期服务对象中的一个:

  1. class StatefulActor : ISomething {
  2. int state;
  3. async void A() {
  4. // Use state
  5. }
  6. async void B() {
  7. // Use state
  8. }
  9. }

设想 AB 内都含有 await,它们现在可以相互交错,再加上和它们自身活动的交织。如果你认为这有种条件竞争的味道,那你就猜对了。实际上,说消息传递系统没有条件竞争是一个彻头彻尾的谎言。甚至有论文讨论在 Erlang 上下文中的这个。更正确的说法是,我们的系统没有数据条件竞争。

不管怎么说,这里有个拦路虎。

解决方法是从经典的同步里偷一页过来,并加上下面多种技术中的一个:

  • 隔离。
  • 标准同步技术(避免写写或读写冒险)。
  • 事务。

目前,我们选择了隔离。结果表明,web 框架提供了可供学习的良好经验。大多数时候,一个服务端对象是一个“会话(session)”的一部分,不应该跨多个并发的客户端进行访问。这样容易将状态划分为子对象,并使用这些对象进行对话。我们语言在围绕可变性的声明能帮助指导处理这个过程。

一个较次要的技术是应用同步。谢天谢地谢我们的语言,我们知道哪些操作是读和写的,所以我们能够因此用它来智能地阻止分发消息,通过使用标准的读写锁技术。这让人舒适温暖得没话说,但如果做得不对会引起死锁(我们做了尽可能的检测)。正如你见到的,当你一旦踏进这条道路,世界就不再那么优雅了,所以我们不鼓励这样做。

最后的是事务。我们没有这样做。分布式事务是邪恶的

总的来说,我们尝试从 web 中学习,并应用那些让大规模分布式系统工作良好的架构。无状态是最容易的模式,隔离是很接近的第二名,其他的或多或少都有点脏。

PS:我保证将会发一整个帖子来专门阐述我们语言的注解(annotations)。

顺序

在分布式系统中,事情都是无序的,除非你走出去维护秩序。并且保证顺序会削弱系统的并发性,增加了簿记(book-keeping),并增加了一大堆的复杂性。我从这里学到的最重要的一课是:分布式系统就是无序的。这很糟,但不要试图去打败它,你肯定会后悔的。

Leslie Lamport 在这个主题有一篇经典的必读论文:时间、时钟以及分布式系统中的顺序

但无序的事件让开发者吃惊。下面就是一个好例子:

  1. // Three asynchronous objects:
  2. IA a = ...;
  3. IB b = ...;
  4. IC c = ...;
  5. // Tell b to talk to a:
  6. var req1 = async b.TalkTo(a);
  7. // Tell c to talk to b:
  8. var req2 = async c.TalkTo(a);
  9. await Async.Join(req1, req2);

如果你期望 ba 谈话保证在 ca 谈话之前,你一定会有崩溃的一天。

我们提供了控制顺序的措施。例如,你可以在通道中 flush 所有的消息,并等待它们投递。你当然也可以一直等待个别的操作,但这会带来一些不必要的延迟,因为往返。我们还有一个“流”抽象,让你可以保持一个序列的异步消息是按顺序投递的,但是以尽可能最高效的方式实现的。

跟状态管理一样,我们发现大量的排序问题通常表示存在设计问题。

调试

有那么多东西在系统中飞来飞去,调试在早期是个大挑战。

与许多这样的挑战一样,解决的办法是工具。我们教会我们的工具,活动跟线程一样,是头等的。我们引入了与跨进程消息相关的因果 ID,所以如果你断点进了分发给一个进程的一个消息,你能追溯回在其他别的远程进程的原点。崩溃的默认行为是收集跨线程的堆栈跟踪(stack trace),以帮助指出你是如何走到那里的。

我们改进的执行模型的另一个绝大的优点是栈又回来了!是的,你实际上得到了等待中的多层次深度的异步活动的栈跟踪,无需额外的开销。许多诸如 .NET 的系统不得不用它们的方式从一大堆散乱的类栈的对象中翻出一片片收集拼凑出一个栈帧。我们遇到了跨进程的挑战,但在一个单一的进程中,所有的活动都有正常的栈跟踪和处于良好状态的变量。

资源管理

某些时候,我有一个深刻的领悟:经典系统的阻塞相当于一个自然的阀门,限制了可供给系统的工作的量。默认情况下,你们的平均程序不会表达出它们所有潜在的并发和并行,但我们的会!虽然这听起来像一件好事 —— 实际上它是 —— 但伴随着暗面。面对这么多的工作,你到底要如何明智地管理资源和调度所有的它们呢?

这是一条非常非常漫长曲折的道路。我不敢说我们已经解决了。我甚至不敢说我们已经走得足够接近了。我只敢说我们已经解决得够足够了,这个问题已经不会再对系统稳定性产生灾难性的影响,而它本来会的。

我过去遇到的一个类似的问题是在 Windows 和 .NET 框架中实现线程池。由于线程池中的工作项可能会被阻塞,你要怎样决定同时保持活动的线程的数量呢?启发式算法总是不完美的,而我会说我们没有做得更糟。如果算是有的话,我们可以算是错误地使用了更多的潜在的并行度来让可以用的资源变得饱和。Midori 系统跑出 100% 的 CPU 使用率非常常见,因为它是正在做着有用的事情,这在 PC 和传统的应用中非常罕见。

但我们问题的规模比我见过的任何东西都严重。一切都是异步的。想象一下一个应用程序遍历整个文件系统,并为磁盘上的每个文件都执行一系列的异步操作。在 Midori 中,应用程序、文件系统、磁盘驱动等等,都是不同的异步进程。很容易想象结果会得到一个类似 fork 炸弹的问题。

解决方法这里可以分开为一个两方面的防护:

  1. 自控:异步代码知道它能用工作淹没系统,因此显式的尝试不要这样做。
  2. 自动资源管理:不管用户写的代码怎样做,系统都能够自动调节限流。

因为显而易见的原因,我们选择了自动资源管理。

通过这样的一种形式,操作调度决定要访问什么进程,哪个进程能运行,在某些情况下,用上像我们上面在流中见到的流控技术。这是我们最“开放式结局”和“未解决”的研究。我们尝试了很多非常酷的想法。这包括了对异步活动的预期资源使用进行建模的尝试(类似凸优化的这篇论文)。这最终证实是非常困难的,但如果你能用适当的技术匹配它,它当然能展现出一些非常有趣的长远的前景。也许让人很惊奇,我们最有希望的结果是将广告投标算法适配到资源分配上。加入博弈的元素,这方法变得非常有意思。如果系统对所有的系统资源收取市场价值,并且系统中所有的代理都有有限数量的“购买力”,我们可以预期它们会根据可用的市场价格购买那些从中受益最大的资源。

但自动管理不是总是完美的。这就是自控参与进来的地方。程序员也可以限制未完成活动的最大数量,通过像“宽循环(wide-loop)”简单的技术来实现。宽循环是一个异步循环,开发人员可以在其中指定最大的未完成迭代数目。系统确保不会同时运行超过这个数目。感觉是有点不够高大上,但搭配资源管理,很有效。

我还是得说我们没有被这东西弄死。开始我们真的以为会被它弄死。我想说在这一大堆东西里面,这个是解决得最不满意的一个,但它毕竟还是创新系统研究的沃土。

尾声

这个帖子里适合放很多东西。如你所见,我们将“到处异步”做得非常极致。

以此同时,世界也已经走过了很长的路,比我们开始时更接近这个模型。在 Windows 8 中,一个很大的重点在于引入异步的 API,并且像在 C# 中加入 await 一样,我们给了他们我们当时学习到的经验教训。我们正在做的事情的一些点滴,但当然没有达到上面说的那个水平。

最终的系统是自动并行的,但跟标准意义的并行非常不同。天量的微型进程和许多的异步消息确保系统推动进程不断前进,甚至在网络这种可变延迟的操作面前也是如此。我最喜欢的展示给 Steve Ballmer 的 demo 是我们多媒体栈上的一个 skype 模拟实现,不管你怎么压迫它,它就是不会挂。

虽然我迫不及待要讨论架构和编程模型相关主题了,但我想我需要先后退一步。我们的编译器一直在进步,在许多方面,它是我们的秘密武器。我们在那里使用的技术让我们可以实现所有的这些更大的目标。如果没有这样的基础,我们永远也不能拥有安全性或跟本机代码(native code)同场竞技。下次见,我们会剧透一点编译器方面的信息。