并发课程总结与性能调优提醒
前几节课我们讲了如何编写各类并发程序、如何应对并发 bug,并在此过程中引入了一个非常重要的核心概念——计算图。今天我们将看到,之前所学的知识能够帮助我们理解数据中心和网络浏览器中的并发编程是如何实现的。
上一节课讲了并行算法,尤其是 invariantly parallel 的各类问题,以及 Mandelbrot Set 的演示。我们还讨论了并行数据结构:并发 hash table 实现起来很困难,但可以用 sloppy counter,以略微延迟为代价将计算留在本地,从而让程序能够 scale up。
上节课还有一些收尾内容:在讲完 hash table 之后,我们提到了和作业相关的 malloc 与 free。在做任何性能调优之前,请始终牢记这句话——"premature optimization is the root of all evil"。
性能优化需结合实际工作负载
如果总是想当然地做优化,结果很可能不符合预期。大语言模型被强化学习调教得会强行遵循你的指令——你说什么它就做什么,如果你给的 prompt 方向本身就不对,模型也会强行执行,结果自然不会好。
在实际系统中,性能优化必须结合具体的 workload。我们学算法数据结构时总盯着 worst case,但实际系统需要在真实概率分布下表现尽可能好。当然,worst case 也不能太差——若存在指数级的 worst case,攻击者可以通过精心构造请求让系统极度变慢。
举个例子:Apache httpd 曾长期是整个互联网的核心支撑。HTTP 请求可以附带参数,如果精心构造这些参数,使所有 key 都映射到 hash table 的同一个桶里,就能让服务器极度变慢——这曾是一个针对 hash table 的复杂性攻击安全漏洞。
malloc Workload 的特征分析
性能是复杂的问题,在考虑优化之前要先明确目标 workload。以 malloc 为例:分配一个很大的对象(如 1MB、10MB、100MB),通常对应 hash table resize 或大缓冲区,之后必然会对它进行大量读写。如果分配了 100MB 却只读写几个字节就 free 掉,那本身就是 performance bug,应换用更紧凑的数据结构。
从实际 workload 来看,越小的对象,创建和分配越频繁——临时字符串、小容器等生命周期极短的小对象数量庞大;中等对象(如生成的 HTML 响应字符串)生存期稍长;大对象(几 MB 或几百 MB)通常是长期存储的全局数据结构,生命周期最长、最稳定。
因此,若想通过性能测试,几乎只需要管好小对象的分配与回收。小对象的 scalability 是主要瓶颈。
Per-Thread Slab 内存分配设计
这与 sloppy counter 的思路类似:在本地持有一个别人看不到的 list,每次加加时都往里 push 一个元素,隔一段时间再放回全局主 list。类似地,对于大块内存可以用 mmap 直接分配,然后切成若干个 slab,slab 的大小可以随意定,比如 1MB 或 4KB、8KB 都可以。
核心思路是:每个线程都持有若干个 slab,分配时直接从自己的 slab 里取,只需获得该 slab 的锁。由于绝大多数对象都是在同一线程内分配并回收的,这把锁几乎只有本线程会持有,加锁开销极低。
只有当其他线程试图 free 该 slab 中分配的对象时,才会发生跨线程的锁竞争。因此这是一个非常安全且高效的设计:在绝大多数情况下,可以以 O(1) 的时间完成分配或释放。
基于 Segregated Free List 的高效内存分配与释放
如果想让每次分配更快,可以为每种大小的对象单独维护一个空闲链表(Segregated Free List)。例如,将 slab 按 16 字节切分,用链表串起所有空闲块;再有 32 字节一块的 slab,同样用链表串起来。分配时只需找到对应大小的桶,从链表 pop 一个元素;释放时直接 push 回链表,所有操作都是 O(1)。
只有当某个大小的 slab 全部分配完时,才进入 slow path:加全局大锁,向系统申请一块新的 slab,再继续分配。Segregated Free List 这一思路早在 1964 年就已提出,是一个经典且高效的设计。推荐大家找一篇关于 malloc 的 survey paper 阅读——在 AI 的帮助下,阅读效率会高很多。
计算机系统优化与现实复杂性
任何有实际价值的重要问题,现实中都比教科书里描述的要内卷得多。malloc 变快意味着程序变快,节省内存意味着可用空间增多,这直接意味着金钱上的收益。Systems 领域最有意思的地方,就在于性能的提升与钱紧密相连。
10% 性能提升与并发优化
把一个系统的性能提升 10%,已经是相当可观的提升。如果你有 100 万台服务器,节省 10% 的成本就是极大的绝对数额。因此,性能优化的难度和价值都不应被低估。
性能非常重要,这意味着我们依然需要并发、并行,充分利用多处理器的能力。但与此同时,你也要意识到每个线程都是有代价的。
线程的操作系统资源开销
线程不是免费的。线程需要内存:线程栈默认 8MB,虽然按需分配,但仍然占用地址空间。你可以用 pmap 命令查看多线程程序,会清晰地看到一块一块的线程栈。
此外,每个线程在 /proc 下都有一个目录,操作系统为每个线程维护着各种内核数据结构,这些都需要消耗内核内存。我们可以做一个简单实验:在创建线程前后分别查看系统资源状态,用差值除以线程数,即可估算出每个线程的平均资源开销。实验结果表明,创建一个最简单的线程,大约需要约 16.9KB 的系统资源(包括内核中各类数据结构的增长)。
除了内存开销,线程切换时还需要进入操作系统内核,保存并恢复寄存器状态——CPU 只有一组寄存器,必须把当前线程的寄存器保存起来,再加载目标线程的寄存器,这些都是真实的时间开销。所以线程是有代价的,这一点在讲原理时往往会被弱化。
计算图调度的零开销理想
我们真正想要的是:计算图想怎么写就怎么写,写完后几乎没有任何额外开销,系统能够很好地调度每一个计算节点,无论节点上的计算量大小。比如上节课讨论的并行 LCS,需要把计算图切分成若干批次,每个节点包含足够多的计算,性能才好。
线程开销与并行优化
以 Mandelbrot Set 为例,按 x 轴切分成若干区域,分别分配给多个线程——这是 embarrassingly parallel 的任务。但如果需要创建 100 万个线程,线程开销就会变得不可忽视,迫使你手动做任务切分,计算出合适的线程数。
这是否能改变?当你发现启动一个线程的代价远大于一次函数调用时,自然会想:能否让线程的创建代价接近函数调用?这就是今天要讨论的主题。甚至真的有人在 CPU 硬件层面尝试过用一条指令创建一个轻量线程——当你这样想,往往就有人已经做过了。
轻量级协程与同步机制
今天我们讲两条路。第一条路:把线程做得非常轻量,让 spawn 和 join 的代价接近函数调用——这就是协程(coroutine)。编程模型与多线程完全一致,同步机制改用其他方式,但在操作系统课上学到的所有多线程编程知识都可以直接复用。
第二条路:改变程序的执行模型,在代码中直接描述计算图。这就有了 Promise、Future、async/await 等机制——关于它们,我今天会用操作系统课前几讲的知识来清楚解释。
传统编程语言缺乏计算图概念
C 语言在设计时参考了 Fortran、Pascal 等更早的语言,目的是描述顺序程序执行;那时的 CPU 本身也是顺序的,语言不具备描述计算图的能力。通过改变编程语言和程序执行模型,允许更好地描述并行或并发计算图,就有了 Promise、Future、async/await 以及 Goroutine 这些机制。
Async/Await 与 async 函数解析
你可能在 AI 生成的代码里见过定义了某个 async 函数,但不清楚它是做什么的。今天我会用操作系统课前几讲的知识,清楚地解释它到底做了什么。在这种编程模型下,你相当于把一个带有代码的 Job 丢到队列里,然后等待它们完成,本质上是在描述一个计算图。
无 Pthread 实现线程的轻量化方案
我们能否在没有任何 Pthread(无 pthread_create、无 mutex_lock、无任何线程库)的情况下,依然实现 spawn 和 join?答案是可以的。Python 就支持在单个线程中同时维护多个程序执行流。
Generator 与 Yield 实现线程切换
这个语法特性叫做 Generator。我创建了 100 万个"线程",每个 t_walker 本质上是一个无限循环(i = 0; i += 1; ...)——如果顺序执行,一旦调用就永远不会返回。
关键在于 Python 的 yield 关键字:每次执行到 yield 时,当前函数的执行状态会被暂存,然后立即返回给调用者;下次再 send 时,从暂存的状态继续执行。通过主程序用 random.choice 随机选一个"线程"并 send,就实现了 100 万个协程的并发调度,而整个进程中只有一个操作系统线程。
C 语言栈帧与多线程的简单执行模型
用 C 语言的简单执行模型来理解:C 语言中,每个函数调用对应一个栈帧(frame),每个栈帧里有一个程序计数器(PC);简单执行模型每次从最顶层栈帧的 PC 取一条语句执行,这就是顺序程序的运行方式。多线程则是有多个栈同时存在,互相独立推进。
用户态多线程与协程实现原理
Python 的 Generator 机制与此类似。所谓协程,就是一个独立的执行流:在操作系统实现时叫线程,自己在用户空间模拟出来的叫协程。yield 的行为是:保留当前栈,返回给调用者,下次再 send 时从此处继续——这就实现了多个栈之间的主动切换。
你会发现,创建 100 万个这样的协程启动非常快,远比创建 100 万个操作系统线程快得多。而且它们确实是并发的:不同协程推进到了不同的 i 值,就好像多个线程在并发执行一样。不同之处在于,切换是主动的(由 yield 驱动),而不是由操作系统强制触发的。
我们也可以在 C/C++ 中用 setjmp/longjmp 实现栈切换,这曾是操作系统课的一道实验题(后来因为太难而取消)。本质上,用 malloc 分配一段栈空间,用几行汇编把栈顶指针(rsp)移到新栈上,程序就"切换"到了另一个协程。C++20 还专门引入了 co_yield 关键字来实现同样的功能。
理论上,任何能够实现闭包(closure)的编程语言,都有能力实现这样的栈切换——闭包可以将当前作用域的变量封存下来,再记录当前 PC,就保存了函数的运行状态,从而可以实现协程切换。C++20 的 coroutine 和 Python 的 Generator 都是以无独立栈(stackless)的方式实现的;而前面那个 C 的实现则为每个协程真正分配了一段堆栈。
系统调用与并发执行
单线程模拟的协程还有两个未解决的问题。
第一个问题:如果某个协程调用 read,read 是一个系统调用,进入操作系统内核后,若管道里没有数据,该线程就会等待。由于底层只有一个操作系统线程,其他所有协程也只能一起等待,即使它们本可以继续计算。这不是我们想要的。
第二个问题:不能使用互斥锁进行同步。协程 T1 将锁拿走后 yield 切换到协程 T3;T3 也试图 lock,发现锁已被持有,于是进入操作系统等待。但底层只有一个操作系统线程,T1 根本无法被调度来执行 unlock,从而造成死锁(AA 型死锁)。互斥锁是操作系统为真正的线程设计的,无法在单线程的协程模型中直接使用。
协程的并发模拟与互斥限制
你可以通过足够频繁的 yield 来模拟线程轮转,让每个协程均匀推进;但不能在协程中使用互斥锁,因为它是为操作系统线程设计的。
为了解决阻塞问题,操作系统提供了非阻塞 I/O 机制:用 O_NONBLOCK 标志打开文件描述符,当没有数据时 read 立即返回 EAGAIN,而不是阻塞等待。协程检测到 EAGAIN 后主动 yield,切换到其他可运行的协程,等下次调度再重试 read。非阻塞 I/O 与用户态协程结合,就可以在单个线程内获得任意多个线程的并发效果。
非阻塞 I/O 与文件描述符
你们考试只知道 open 返回一个文件描述符,但操作系统其实演化了很长时间。一旦用 O_NONBLOCK 打开,read 在没有数据时立即返回负值 EAGAIN,有数据时返回读到的字节数——read 的行为与普通阻塞版本完全不同。能从"我要实现用户态线程"这条线走下来,你会发现这些设计都是非常自然的。
文件描述符是操作系统中实实在在的对象,不像互斥锁那样受限于线程,甚至跨进程都可以访问,因此可以用来实现协程之间的同步——既可以同步,又可以传递数据。
I/O 多路复用机制
操作系统还提供了 I/O 多路复用机制,即 epoll。epoll 可以同时监控大量文件描述符,当任意一个就绪(可读或可写)时就返回,甚至可以同时监听 100 万个文件描述符。
操作系统还提供了 eventfd、timerfd 等基于文件描述符的机制,可以统一纳入 epoll 监听。有了 epoll,我们就可以让协程在等待 I/O 时不浪费 CPU,一旦数据就绪立即被唤醒——所有问题都迎刃而解了。
API 设计理念与降低心智负担
引入 epoll 和非阻塞 I/O 增加了不少心智负担,但所有编程机制的出发点都是让人用起来更方便。如果允许自己设计编程语言,我们可以让用户写起来和多线程代码一模一样:该 sleep 就 sleep,该 read 就 read,看起来是阻塞的,但由编译器在背后将其改写为非阻塞的异步形式。sleep 被改写为把自己放入等待队列并标记一段时间内不再调度;read 被改写为循环尝试非阻塞读,若得到 EAGAIN 就主动 yield。
Goroutine 与 Go 调度器原理
如果编译器在我们的控制之下,就可以把所有同步写法改写为异步写法——恭喜你,你发明了 Goroutine。
Go 的实现方式是:在每个操作系统线程上运行一个调度循环,不断从全局队列中取可运行的 goroutine 来执行。每个 goroutine 在 sleep、read 等操作时,编译器已将其改写为非阻塞形式:若资源尚未就绪,将 goroutine 移出调度队列,待条件满足再放回;调度器借助 epoll 监听所有文件描述符,一旦有数据,对应 goroutine 就被唤醒放回队列。
这样,goroutine 的创建和切换开销极低(接近函数调用),多个操作系统线程又可以真正并行地执行不同的 goroutine,充分利用多核 CPU。所有 goroutine 共享内存,相当于在进程内部实现了一个小型操作系统调度器。
Go 还有一个令人欣赏的设计理念,来自 Effective Go:
Do not communicate by sharing memory; instead, share memory by communicating.
管道机制与并发计算图
在 Go 中,任意 goroutine 之间都可以建立 channel(管道),就像 UNIX 进程之间的管道一样。UNIX 的管道本身就描述了一个并发计算图:cat a.txt | cat b.cpp | wc 这条命令,每个进程生产数据,下一个进程消费数据,自带生产者-消费者同步语义,无需手动维护 buffer 和互斥锁。
Go 将这个思想搬入语言内部:goroutine 是轻量线程,channel 是类型安全的管道,自带同步语义。通过 channel,我们既可以同步,又可以传递数据,而不像传统并发中同步与通信是分开的。
UNIX 哲学与 Go 的并发设计
Golang 真正继承了 UNIX philosophy。Go 的设计者之一 Rob Pike 和 Ken Thompson 正是 UNIX 的核心成员,也是 Bell Labs 的重要人物。Go 在共享内存的多核时代,把 UNIX 里那些好的设计重新实现了一遍,是一个非常出色的语言设计。
轻量化线程方案:并行图像切分处理
以 Mandelbrot Set 的 Go 实现为例:每个 Worker goroutine 负责计算图像的一段竖条(由 low 和 high 参数划定),计算完成后通过 channel 发送信号。主 goroutine 监听 channel,收到所有 Worker 的完成信号后,写入图像文件。由于 goroutine 创建开销极低,几乎不需要手动管理线程数量——用 go 关键字(相当于 spawn)在 for 循环里启动若干 goroutine,就这么简单。
(AI 生成的代码存在同步方面的 bug,anyway,这是一个说明性示例。)这就是第一条路:把线程做得极其轻量,解决了线程创建代价过高的问题。
JavaScript 的诞生与设计缺陷
现在进入另一条世界线。1995 年,Brendan Eich 加入 Netscape,被委以重任,要为网页设计一门嵌入式脚本语言——这就是 JavaScript。他花了 10 天时间,融合了 C、Java、Scheme、Self 等语言的特性,主要目的是设计一门看起来像 Java 的语言以吸引开发者。
有意思的是,他个人兴趣在函数式编程,对这件事并不太上心,做出了一些糟糕的设计;但也正因如此,"函数作为一等公民"(Function is first class citizen)被放入了 JavaScript,这个决定影响深远。
JavaScript 有无数坑:this 的动态绑定语义与 C++/Python/Java 完全不同,早期 React 开发者经常因此出错,需要手动 bind(this);类型系统极其宽松,0 == []、0 == "0"、0 == " " 均为 true,但 [] != " "——这些奇怪行为均是 ECMAScript 标准规定的,在 F12 控制台里可以直接复现。这就是设计不佳带来的后果。
HTTP 协议的演变
最早的 HTTP 协议极为简单:客户端发送一行文本 GET /mypage.html,服务端找到文件后直接返回 HTML,就是这样。有兴趣可以查阅 "Evolution of HTTP",了解这个协议的演变历程。
我们甚至可以用 nc(netcat)直接向课程网站发一个 HTTP 请求,收到的就是一段文本——HTTP/1.1 200 OK、nginx 服务器信息,以及 HTML 正文。HTTP 比大家想象的要简单。
早期 HTTP 与初代网页形态
那时的网页非常简陋,雅虎主页就是那个样子。早期中国互联网的三巨头——新浪、搜狐、网易——都诞生于那个时代。
那时还没有 CSS,网页布局全靠 <table> 标签,用二维网格划分页面区域。想实现圆角矩形这样的效果,必须用 PS 先设计好页面,精准计算每个部分的像素值,再切成一张张小图片拼到表格里——这就是"切图工程师"的工作。
像素级网页设计与 XMLHttpRequest 的诞生
1999 年,一个划时代的 API 诞生了:XMLHttpRequest。它允许 JavaScript 代码在任意时刻向任意 URL 发起 HTTP 请求,打开了一扇全新的大门。这项技术后来被称为 Ajax(Asynchronous JavaScript and XML)——之所以叫 XML,是因为那时 Java 后端广泛使用 XML 格式,而今天我们传输的数据几乎全是 JSON 了。
Ajax 使网页实现了后台刷新。HTTP 1.0 时代的网页完全静态,点超链接才会重新加载整页;有了 XMLHttpRequest,网页可以在不刷新页面的情况下从后端拉取数据并更新界面。
从静态网页到 Ajax 与云端应用的演进
JavaScript 的 DOM API 可以任意修改文档中的元素——改变位置、样式、文本,甚至插入新的 HTML,浏览器会自动完成渲染更新。再加上 jQuery 的 $(Dollar)选择器语法(本质上是 document.querySelector),开发者可以像查询字符串一样选中 DOM 树中的任意节点并修改它,用一行脚本就能让整个页面改头换面。
这带来了 Web 应用的爆发。Google 尝试把 Microsoft Office(Excel、Word、PowerPoint)全部搬到浏览器里,相当成功;Google 还发布了 Chrome OS——整个操作系统底层是 Linux,但只暴露一个浏览器界面,所有内容都通过 JavaScript DOM 渲染。后来微软放弃 IE,基于 Chromium 内核推出了 Edge,重新夺回了大量市场份额。
JavaScript 基于事件的并发模型与回调机制
JavaScript 的并发是其核心组成部分。网页请求天生耗时,鼠标点击、窗口移动等事件随时可能到来,JavaScript 天生需要对并发有良好的支持。
JavaScript 选择了一种基于事件的并发模型,且不使用线程。设计出发点是:JavaScript 的目标用户是零门槛入门的网页开发者,让他们处理 data race、atomicity violation 等并发 bug 显然不现实。
JavaScript 里没有同步阻塞 I/O,所有 I/O 都是异步的——你只能给 $.ajax 提供一个回调函数,等请求完成后浏览器创建新事件来调用它。JavaScript 的执行模型是:每个事件一旦开始执行就必须运行到结束(run-to-completion),中途不能被切换,永远不存在 data race。代价是:如果某段代码执行时间过长,整个网页都会失去响应。
回调地狱与 Promise 的诞生
但这种回调机制带来了著名的回调地狱(Callback Hell)。如果要依次完成三件有依赖关系的事(登录 → 获取好友列表 → 获取好友动态),每件事的回调都要嵌套在上一件事的成功回调里,代码缩进层层加深,很快就难以维护。
一个顺序的 A → B → C 逻辑,被硬生生拆成三个互相嵌套的函数,无法用编程语言的顺序结构来描述。真的很佩服人类,竟然在这么多年的时间里,忍受着这样难用的 API,却创造出了整个 JavaScript 生态里如此丰富的内容。
Promise 的出现解决了这个问题,提供了直接在代码里描述计算图的能力:fetch(...).then(...).catch(...) 描述了一个前后依赖关系;Promise.all([p1, p2]).then(...) 则相当于线程的 join——只有 p1 和 p2 都完成后,then 节点才会执行。
fetch 与 Promise 的状态变化
fetch 是立即返回的,返回一个 Promise,初始状态为 pending;等网络请求完成后,Promise 状态变为 fulfilled,才能获取 response。你可以在浏览器控制台里验证:fetch(someURL) 立即返回一个 pending 的 Promise,过一会儿它变成 fulfilled。
Promise.all 同样立即返回一个 Promise,只有当所有传入的 Promise 都 fulfilled 后,它才会 resolve。
Promise 与 async/await 的计算图模型
Promise 还不够好用——它立即返回,但程序员希望用顺序结构(顺序、选择、循环)来写代码。这就有了 async/await。
async 函数实质上是由编译器包装的:函数内部的每个 await 会被编译器展开为链式的 Promise .then 回调,生成嵌套的代码——但这是编译器做的,程序员写的时候保持了自然的顺序逻辑。
例如,await Promise.all([fetchData(), fetchData()]) 等价于创建两个并行执行的计算图节点,然后等待它们都完成。与直接写 Promise.all([...]) 立即返回 pending 相比,加了 await 会真正等待,从控制台里可以观察到明显的延迟。
Promise.all 与 await 的执行机制对比
直接写 Promise.all([...]) 会立即返回一个 pending 的 Promise;而 await Promise.all([...]) 会等到所有 Promise fulfilled,需要消耗实际的等待时间。这是两者在执行机制上的关键区别。
编译器翻译 a = await f(); b = await g(); 的方式是:先执行 f(),fulfilled 后在 .then 里执行 g(),再在下一个 .then 里处理 b,如此递归展开。这就是编译器自动生成的"结构化回调地狱"——程序员写 await 时代码是顺序的,编译器负责把它变成链式的 Promise 调用。
JavaScript 的灵活特性与生态乱象
JavaScript 的灵活性是把双刃剑。"函数作为一等公民"使其编程模型极为灵活,但也催生了各种库和框架互相竞争的局面。甚至出现了用 JavaScript 写的 transpiler,把新版本 JavaScript(含 async/await)编译成老版本 JavaScript,以兼容所有浏览器。
网页生态与 Electron 桌面应用
在这个生态上,先后出现了 AngularJS、Bootstrap,再到现在的 Express、Next.js 等框架。大家发现网页开发如此方便后,想到可以用 JavaScript 构建桌面应用,于是有了 Electron 框架——VS Code 就是基于 Electron 构建的。
后来又有人想:用写 React 页面的方式来写命令行程序,于是有了 Ink 这个库,[听不清] 的终端界面就是用 Ink 实现的。
浏览器中运行汇编与 C/C++ 的探索
还有更疯狂的探索:既然浏览器性能那么好,能否在浏览器里解释执行汇编代码?如果可以,就能在浏览器里运行 C/C++ 程序,实现 JavaScript 与 C/C++ 的互相调用。越来越多的人在这个生态上向更底层延伸,就像操作系统的分层一样不断往下走。
编程语言的抽象层级与应用生态
这与操作系统的分层非常类似:从 Linux 系统调用 API,到 libc,到 C++,再到 JavaScript 和浏览器——每一层抽象之上都生长出一个巨大的应用生态。JavaScript 生态中不仅可以与 C/C++ 互联,还有 Three.js(3D 图形)、TensorFlow.js(机器学习)等丰富的库,是一个非常有趣的发展历程。
JavaScript 初学者常见难点解析
如果你刚开始学 JavaScript,遇到 async/await、Promise 等概念感到困惑,不妨重走一遍这条演进路径:从并发被发明,到 data race、互斥与同步,再到如何用计算图描述并发,最后看设计者和使用者如何在语言层面找到双方都舒适的表达方式。理解了这条路,你就能真正理解这些语言特性背后的设计动机。
轻量化方案与计算图描述方式对比
总结:描述并发与并行计算的核心是计算图。这节课讲了两条路:
- 第一条路(Go/Goroutine):把线程做得极其轻量,让创建和切换的代价接近函数调用,编程模型与多线程保持一致,借助编译器将同步写法透明地转换为异步执行。
- 第二条路(JavaScript/async-await):改变语言的执行模型,让程序员直接在代码中描述计算图,以事件驱动的方式规避了线程的复杂性。
两条路各有优劣,但都是为了解决同一个问题:如何以更自然、更低开销的方式表达并发计算。