前言
学如逆水行舟,不进则退。共勉!!
async/await 所引入的异步函数的简单写法,可以在暂停点时放弃线程,这是构建高并发系统所不可或缺的。但是异步函数本身,其实并没有解决并发编程的问题。结构化并发 (structured concurrency) 将用一个高效可预测的模型,来实现优雅的异步代码的并发。
iOS资料|地址
什么是结构化
“结构化” (structured) 这个词天生充满了美好的寓意:一切有条不紊、充满合理的逻辑和准则。但是结构化并不是天然的:在计算机编程的发展早期,所使用的汇编语言,甚至到 Fortran 和 Cobol 中,为了更加契合计算机运行的实际方式,只有“顺序执行”和“跳转”这两种基本控制流。使用无条件的跳转 (goto 语句) 可能会让代码运行杂乱无状。在戴克斯特拉的《GOTO 语句有害论》之后,关于是否应该使用结构化编程的争论持续了一段时间。在今天这个时间点上,我们已经可以看到,结构化编程取得了全面胜利:大部分的现代编程语言已经不再支持 goto 语句,或者是将它限制在了极其严苛的条件之下。而基于条件判断 (if),循环 (for/while) 和方法调用的结构化编程控制流已经是绝对的主流。
不过当话题来到并发编程时,我们似乎看到了当年非结构化编程的影子。也许我们正处在与当年 goto 语句式微的同样的历史时期,也许我们马上会见证一种更为先进的编程范式成为主流。在深入到具体的 Swift 结构化并发模型之前,我们先来看看更一般的结构化编程和结构化并发之间的关系。
goto 语句
goto 语句是非结构化的,它允许控制流无条件地跳转到某个标签。虽然现在看来 goto 语句已经彻底失败,完全不得人心,但是受限于编程语言的发展,goto 语句在当时是有其生存土壤的。在还没有发明代码块的概念 (也就是 { … }) 之前,基于顺序执行和跳转的控制流,不仅是最简单的天然选择,也完美契合 CPU 执行指令的方式。顺序执行的语句非常简单,它总可以找到明确的执行入口和出口,但是跳转语句就不一定了:
程序开发的初期,控制流的设计更多地选择了贴近实际执行的方式,这也是 goto 语句被大量使用的主要原因。不过 goto 的缺点也是相当明显的:不加限制的跳转,会导致代码的可读性急剧下降。如果程序中存在 goto,那么就可能在任何时候跳转到任何部分,这样一来,程序就并不是黑匣子了:程序的抽象被破坏,你所调用的方法并不一定会把控制权还给你。另外,多次来回跳转,往往最后会变成面条代码,在调试程序时,这会是每个程序员的噩梦。
结构化编程
在代码块的概念出现后,一些基本的封装带来了新的控制流方式,包括我们今天最常使用的条件语句、循环语句以及函数调用。由它们所构成的编程范式,即是我们所熟悉的结构化编程:
实际上,这些控制流也可以使用 goto 语句来实现,而且一开始人们也认为这些新控制流仅只是 goto 的语法糖。不过相比于 goto,新控制流们拥有一个非常显著的特点:控制流从顶部入口开始,然后某些事情发生,最后控制流都在底部结束。除非死循环,否则从入口进入的代码最终一定会执行达到出口。
这不仅让代码的思维模型变得更简单,也为编译器在低层级进行优化提供了可能。如果代码作用域里没有 goto,那么在出口处,我们就可以确定在代码块中申请的本地资源肯定不会再被需要。这一点对于回收资源 (比如在 defer 中关闭文件、切断网络,甚至是自动释放内存等) 是至关重要的。
完全禁止使用 goto 语句已经成为了大部分现代编程语言的选择。即使有少部分语言还支持 goto,它们也大都遵循高德纳 (Donald Ervin Knuth) 所提出的前进分支和后退分支不得交叉的理论。像是 break,continue 和提前 return 这样的控制流,依然遵循着结构化的基本原则:代码拥有单一的入口和出口。事实上我们今天用现代编程语言所写的程序,绝大部分都是结构化的了。当今,结构化编程的习惯已经深入人心,对程序员们来说,使用结构化编程来组织代码,早已如同呼吸一般自然。
非结构化的并发
不过,程序的结构化并不意味着并发也是结构化的。相反,Swift 现存的并发模型面临的问题,恰恰和当年 goto 的情况类似。Swift 当前的并发手段,最常见的要属使用 Dispatch 库将任务派发,并通过回调函数获取结果:
func foo() -> Bool {
bar(completion: { print($0) })
baz(completion: { print($0) })
return true
}
func bar(completion: @escaping (Int) -> Void) {
DispatchQueue.global().async {
// ...
completion(1)
}
}
func baz(completion: @escaping (Int) -> Void) {
DispatchQueue.global().async {
// ...
completion(2)
}
}
bar 和 baz 通过派发,以非阻塞的方式运行任务,并通过 completion 汇报结果。对于调用者的 foo 来说,它作为一段程序,本身是结构化的:在调用 bar 和 baz 后,程序的控制权,至少是当前线程的控制权,会回到 foo 中。最终控制流将到达 foo 的函数块的出口位置。但是,如果我们将视野扩展一些,就会发现在并发角度来看,这个控制流存在很大隐患:在 bar 和 baz 中的派发和回调,事实就是一种函数间无条件的“跳转”行为。bar 和 baz 虽然会立即将控制流交还给 foo,但是并发执行的行为会同时发生。这些被派发的并发操作在运行时中,并不知道自己是从哪里来的,这些调用不存在于,也不能存在于当前的调用栈上。它们在自己的线程中拥有调用栈,生命周期也和 foo 函数的作用域无关:
在 foo 到达出口时,由 foo 初始化的派发任务可能并没有完成。在派发后,实际上从入口开始的单个控制流将被一分为二:其中一个正常地到达程序出口,而另一个则通过派发跳转,最终“不知所踪”。即使在一段时间后,派发出去的操作通过回调函数回到闭包中,但是它并没有关于原来调用者的信息 (比如调用栈等),这只不过是一次孤独的跳转。
除了使代码的控制流变得非常复杂以外,这样的非结构化并发还带来了另一个致命的后果:由于和调用者拥有不同的调用栈,因此它们并不知道调用者是谁,所以无法以抛出的方式向上传递错误。在基于回调的 API 中,一般将 Error 作为回调函数的参数传递。慵懒的开发者们总会有意无意忽视掉这种错误,Swift 5.0 中加入的 Result 缓解了这一现象。但是在未来某个未知的上下文中处理“突如其来”的错误,即便对于顶级开发者来说,也不是一件轻而易举的事情。
结构化并发理论认为,这种通过派发所进行的并行,藉由时间或者线程上的错位,实际上实现了任意的跳转。它只是 goto 语句的“高级”一些的形式,在本质上并没有不同,回调和闭包语法只是让它丑陋的面貌得到了一定程度遮掩。
除了回调和闭包,我们也有另外的一些传统并发手段,比如协议和代理模式或者 Future 和 Promise 等,但是它们实际上和回调并没有什么区别,在并发模型上带来的“随意跳转”是等价的。
结构化并发
并发程序是很难写好的,想正确地设计一个复杂并发更是难上加难。不过,你有没有怀疑过,这可能并不是我们智商上有什么问题,而是我们所使用的工具并不那么趁手如意?并发难写的原因,也许只是和当年 goto 一样,是我们没有发明合适的理论。
goto 最大的问题,在于它破坏了抽象层:当我们封装一个方法并进行调用时,我们所做的事情是相信这个方法会为我们完成它所声称的事情,把它看作一个黑盒。但是如果存在 goto,这个抽象假设就不再有效。你必须仔细深入到黑盒里面,去研究它的跳转方式:因为黑盒并不一定会乖乖把控制权还给你,而是会把调用控制流引到其他任意地方去。
非结构化的并发面临类似的问题:一旦我们的并发框架中允许使用派发回调模式,那么我们在调用任意一个函数时,我们都会存在这样的担忧:
这个函数会不会产生一个后台任务?
这个函数虽然返回了,但是它所产生的后台任务可能还在运行,它什么时候会结束,它结束后会产生怎么样的行为?
作为调用者,我应该在哪里、以怎样的方式处理回调?
我需要保持这个函数用到的资源吗?后台任务会自动去持有这些资源吗?我需要自己去释放它们吗?
后台任务是否可以被管理,比如想要取消的话应该怎么做?
派发出去的任务会不会再去派发别的任务?别的这些任务会被正确管理吗?如果取消了这个派发出去的任务,那些被二次派发的任务也会被正确取消吗?
这些答案并没有通用的约定,也没有编译器或运行时的保证。你很可能需要深入到每个函数的实现去寻找答案,或者只能依赖于那些脆弱且容易过时的文档 (前提还得有人写文档!) 然后不断自行猜测。和 goto 一样,派发回调破坏了并发的黑盒。它让我们所希冀和依赖的抽象大厦轰然坍塌,让我们原本可以用来在并发程序的天空中自由翱翔的双翼霎时折断。
结构化并发并没有很长的历史,它的基本概念由 Martin Sústrik 在 2016 年首次提出,之后 Nathaniel Smith 用一篇《Go 语句有害论》笔记“致敬”了当年对 goto 的批评,并从更高层阐明了结构化并发的做法,同时给出了一个 Python 库来证明和实践这些概念。我相信 Swift 团队在设计并发模型时,或多或少也参考了这些讨论,并吸收了相关经验。就算不是唯一,Swift 现在也是少数几个在原生层面上将结构化并发加入到标准库的语言之一。
那么,到底什么是结构化并发?
如果要用一句话概括,那就是即使进行并发操作,也要保证控制流路径的单一入口和单一出口。程序可以产生多个控制流来实现并发,但是所有的并发路径在出口时都应该处于完成 (或取消) 状态,并合并到一起。