IT 论文精读 · PAPER 66
C. A. R. Hoare · Communications of the ACM · 1978
1978 年,Tony Hoare 提出了 CSP:让一个程序同时做好几件事,不要让这几件事共用同一块内存、再互相拦着;而要让它们各干各的、彼此之间只靠递消息打交道。这不是一篇有实验数字的论文,它给的是一套写法。今天你写 Go 的 channel、用 Erlang / Elixir 撑电信级系统,背后的世界观都是这一篇。
当时的标准答案是:几条同时在跑的活儿共用一块内存,谁都能读、谁都能改;为了不打架,另外再约一套规则——谁先举手谁才能动。
想象一屋子人共用一块白板,上面写着账上还有多少钱。麻烦有两层:一是规则一破白板就被写花——一个人把「100 减 30」算完刚要落笔,另一个已经把数改成了 80,最后落在板上的却是 70。二是更折磨人的:这种错不复现,跑一百次只有第三次出错,因为出不出错取决于两人是否恰好在同一微秒伸手。
Hoare 的做法是:把白板拆掉。
每人一个小房间,自己一张纸,谁也看不见别人的纸;要交换信息,只能从窗口把纸条递过去。于是那类 bug 不是被管得更严,而是无从发生——没有共享的东西,就没有可抢的东西;每张纸只有一个人碰,纸上的账永远自洽。
关键在于手递手:递的人把手伸出窗口就得停下来等对方来接,接的人伸手也得等对方来递;先到的等后到的,碰上了才同时松手,然后各自回去接着干。
这就省掉了旧世界的另一套机制:以前你得先把数据写上白板、再另外通知一声「喂,我写好了」,bug 正出在两者错配;手递手之后,纸条递到了就等于通知到了。
第二件事同样要紧:一个人可以同时守着几个窗口,哪个窗口先伸进纸条就先处理哪个——不预先规定等谁,只等「谁先来」。有了这条,一个人才能当服务窗口轮流招呼好几个人;没有它,你只能写死板的单线流水线。
就这两下,够搭出原先要语言内置的一切:想要个能攒货的缓冲区,就请一个人当传递员,前面来货就收、后面要货就给;想要「同时只准三个人进厨房」,就请个门房数人头。以前的内置功能,这里全变成「多请一个人」。
1980 年代真有人照它造了一门语言(occam),还造了一款把「进程」和「窗口」直接做进硬件的芯片(Transputer)——论文变成 CPU,这很少见。今天 Go 的 channel、Erlang 的消息传递,以至「各自守着自己的数据、只走接口」的微服务直觉,都同出一源。
一句诚实话:这招消灭了「白板被写花」,却没消灭「互相干等」——两个人都伸着手等对方先递,就一起僵在那儿。Hoare 那个五位哲学家吃饭的例子,正是得额外请个门房才不卡住。
别让并行的几件活共用一块内存、再拿锁互相拦;让它们各自私有、只靠「手递手」递消息——递到的那一刻,交数据和对时间一次完成,数据打架于是无从发生。再允许一个人「等几个窗口里谁先来」,缓冲区、许可牌、流水线就都能自己写出来。代价是:它治数据打架,不治互相干等。
想看会合的时序图、守卫命令的写法,以及它怎么长成 Go 的 channel 与 select? → 切到精读版
CSP 主张把并发程序写成一组没有任何共享变量的顺序进程(sequential process),进程之间唯一的交互方式是一对同时执行的输入 / 输出命令——即会合(rendezvous);再借 Dijkstra 的守卫命令(guarded command)允许进程「等多路输入里谁先就绪」。就这两条原语,论文演示了缓冲区、信号量、监视器式模块、协程、子程序调用、流水线全都能写出来而无需语言内置。它是 occam 语言与 Transputer 芯片的蓝本,也是 Go 的 channel 与 CSP 进程代数共同的源头。
条件 → 语句;若干条并列,在成立的那些里任选一条执行,刻意不规定选哪条(即不确定性 nondeterminism)。作者 C. A. R.(Tony)Hoare,论文 1978 年 8 月发表于 CACM;那几年他正从贝尔法斯特女王大学转往牛津。它上承 Dijkstra:守卫命令(1975)被直接借来当骨架,而要取代的靶子——信号量与监视器——恰好一个是 Dijkstra 的、一个是 Hoare 四年前自己的,这篇某种意义上是他为共享内存之外另起的一路。同期 Milner 的 CCS(1980)是并行的另一支源流。下启:occam 语言与 INMOS Transputer、Ada 的任务会合、Hoare 1985 年的同名专著(把 CSP 变成一套有形式语义的进程代数),以及今天的 Go。
1978 年,并发编程的标准答案是「共享内存 + 一套同步件」:数据放在大家都能读写的变量里,再用信号量或监视器规定谁什么时候能碰。这套东西能用,但有三处硌人。第一,它把一件事拆成了两套机制:「把数据交给对方」用共享变量,「告诉对方数据好了」用信号量——绝大多数并发 bug 恰恰长在两者的缝里(忘了加锁、粒度不对、通知早于写入)。第二,错误不可复现:对不对取决于指令恰好怎样交错,同一份代码跑一千次可能只错一次。第三,推理不可分割:想读懂一段代码,你不能只看这段代码——别的线程可能在任意一条指令的间隙改掉你的变量,而交错数随线程数爆炸。
Hoare 的动机就在这里:不是把共享数据管得更严,而是让共享消失,从而让每个进程重新变成可以单独读懂、单独证明的顺序程序。
进程之间不共享任何变量,唯一的交互是一对输入 / 输出命令:感叹号发、问号收。east!x 是「把表达式 x 的值交给名叫 east 的进程」,west?c 是「从名叫 west 的进程收一个值放进变量 c」。论文最小的例子就是一句话的转发器 *[west?c → east!c]——反复从西边收一个字符、往东边送出去。
机制是:两条命令必须配对才生效。哪一边先执行到自己那一半,就停在那里阻塞等待,直到对面执行到匹配的另一半;此时两条命令同时执行,值从发方的表达式拷进收方的变量,然后两边各自继续。这就是会合,它是同步、无缓冲的:没有「发完就走」,也没有系统替你攒消息的信箱。
为什么这样设计?因为它把旧世界那两套机制合成了一个动作:交割完成的那一刻两条时间线恰好对齐,于是不需要额外的「我写好了」通知,也就没有错配的余地。更深一层的收益是推理变回可分割——一个进程的变量只有它自己碰,不变式(invariant)由它独力维护,你读它时不必设想「别人随时可能改我的状态」。数据竞争不是被管住了,而是没有可争的东西。至于缓冲,Hoare 是刻意不提供的——想要,自己写个进程当缓冲区。
光有配对的收发只能搭死板的单线流水线。让 CSP 真正够用的第二个想法,是把 Dijkstra 的守卫命令拿来,并加一条关键许可:守卫里可以放一条输入命令。
写法是 *[ 左?x → 处理 □ 控制?停 → 退出 ]:方框 □ 分隔若干条「守卫 → 语句」,星号表示反复执行。进程停在这里同时候着所有守卫,哪一路的对端先执行到匹配的那一半,就走那一路;几路同时就绪则任选一路——论文刻意不规定选哪条,也不承诺公平轮转。
这条许可为什么关键?它把进程从「按写死的顺序收发」变成「按对方的到达顺序服务」。一个服务型进程要同时招呼多个客户、一个缓冲区要在「有空位就收货」和「有货就发货」之间随机应变,靠的全是它。Go 的 select 就是它的直系后代。
有一处诚实的不对称:这一版只许输入守卫,不许把输出命令放进守卫——写不出「要么给 A 发、要么从 B 收,看谁先」;Hoare 明说这是为实现方便设的限制。
最后一块拼图是并行命令 [P1 || P2 || … ]:并列启动这些进程,全部结束它才结束。注意这一版的进程集合是静态写定的,没有「来一个请求开一个」那种动态创建。
接下来论文的主体是一长串编号例子,它们合起来才是真正的论证:
要提纯出来的是:信号量、监视器、子程序、协程——这些此前被当作语言原语或系统内置特性的东西,在 CSP 里全退化成「再加一个进程」的普通程序。这才是它真正的说服力:不是性能,而是概念的经济性。
必须诚实:这篇论文没有实验、没有基准、没有一个性能数字,1978 年也还没有实现——Hoare 自称给的是一套原语提案,不是完整语言(没有类型、模块、异常,也没给形式语义)。它的「结果」是另外两种。
一是可表达性的演示:那串例子覆盖了当时并发编程的经典题目,每个都只用两条原语写出、且短到能在一页内读完。对语言设计论文来说这就是实验部分——不是「更快多少」,而是「本来要靠语言特性给的东西,现在都不必给了」。二是它预告的可证明性,而这张期票后来真被兑现:进程间没有共享状态,每个进程的不变式只由自己维护,推理不必穷举「别人可能在我哪条指令的间隙动我的变量」。七年后 Brookes、Hoare、Roscoe 等把它做成一套带形式语义(traces 与 failures-divergences 模型)的进程代数,配上 FDR 这样的精化检查器,并发设计真能交给机器穷举验证会不会死锁——这在共享内存模型里难得多。
它罕见地一路落到了硬件。1980 年代 INMOS 把 CSP 做成 occam 语言(PAR / SEQ / ALT 与通道是语言一级构造),并设计了 Transputer——一款把进程与通道当硬件概念的芯片。一篇语言设计论文变成一款 CPU 的设计原则,这不多见;Ada 的任务会合(rendezvous)同样直接受它影响。
它进了今天最主流的语言之一。从贝尔实验室的 Newsqueak、Alef 到 Plan 9 的 Limbo,这条线最后交汇成 Go:goroutine + channel + select,连口号都是它的——「不要通过共享内存来通信,要通过通信来共享内存」。Go 明确在两处偏离原版:通道是头等值(不必写死对方进程名,这一步要到 1985 年版才有)、可带缓冲,且 select 允许发送分支。
它还开出了并发验证一整支:1985 年的同名专著把 CSP 变成进程代数,与 Milner 的 CCS / π 演算并称两大源流,至今是验证并发与通信协议的主流工具之一。
思想层面,「不要共享、改为传消息」成了并发与分布式的默认建议之一——Erlang / Elixir 的进程与消息、Akka 等 actor 框架,以至微服务「各自守着自己的数据、只走接口」的直觉,都与它同源。但要讲准一句:actor 那一支是异步信箱(发完就走,消息在对方信箱里排队),不是 CSP 的同步会合;共同的祖训只是「不共享状态」。
west / east,换个拓扑就得改代码,进程没法当零件复用。1985 年版与 occam 把通道升为头等实体才解决。① 一句话:把并发程序写成一组无共享变量的顺序进程,只靠一对同时执行的输入 / 输出命令(会合)交互。
② 旧世界的痛:共享内存把「传数据」和「对时间」拆成两套机制,bug 长在缝里、且不复现;推理还得穷举所有交错。
③ 想法一:east!x / west?c 必须配对,先到的阻塞等待,配上就同时执行——交割与同步一次完成,无共享故无从争抢;同步、无缓冲是刻意的选择。
④ 想法二:输入可放进守卫,进程于是能等「多路里谁先就绪」、任选一路(不承诺公平)——服务型进程与缓冲区的基础、Go select 的祖先;这一版不许输出守卫。
⑤ 想法三:加上并行命令 [P1 || P2],信号量、监视器、子程序、协程、流水线全成了「再加一个进程」——卖点是概念的经济性。
⑥ 结果:没有实验数字,证据是一串覆盖经典并发题目的短例子,加上「无共享 ⇒ 可分别推理」这张期票,七年后兑现成进程代数与可上模型检查器的验证工具。
⑦ 影响:occam 与 Transputer、Ada 的任务会合、Newsqueak→Limbo→Go 的 channel 与 select(actor / Erlang 同源但走异步信箱)。
⑧ 局限:写死进程名不可组合、只许输入守卫、进程静态、治数据竞争不治死锁、同步耦合、性能常不如一把锁。