本文永久链接 – https://tonybai.com/2026/10/22/go-concurrency-concurrent-ml-events
大家好,我是Tony Bai。
【导读】
Go 把 CSP 带进了千家万户,channel 和 select 几乎成了并发的代名词。但有开发者最近指出:1990 年代的 Concurrent ML(CML)在 CSP 之上又往前走了一步,只做了一件事——把“通信操作”从一条语句变成一个值。就是这个小小的变化,让“组合、封装、取消”这些在 Go 里要靠 context 和约定硬撑的事,变得顺理成章,还顺手解决了 goroutine 泄漏。这个老模型,Go 能学到什么?
【文章要点】
- Go 的
select是语法,不是值:通信的“描述”和“执行”被绑死,无法封装、无法组合。 - CML 的核心:事件(event)是值,
sync才触发执行;select、超时、取消都是同一个抽象。 - 三个组合子:
handle-evt(提交后动作)、choice-evt(选择)、guard-evt(提交前准备)。 - Andy Wingo 2016 年的“认错”:用 channel + goroutine 封装转换逻辑,会丢消息、泄漏 goroutine。
- 取消机制:CML 用 nack 事件内建于运行时,而 Go 靠
context约定层层传递。 - 线程可被 GC 回收:Go 1.27 的
goroutineleakprofile 是同一思路的“检测版”,而非“回收版”。 - 客观评价:CML 无法原样搬进 Go,但“延迟执行”、“组合优先”和“泄漏可检测”值得借鉴。

你真的“拥有”了 Go 的 select 吗?
写过 Go 的人,大概都对这段代码不陌生:
select {
case ch1 <- 5:
case y := <-ch2:
default:
}
select 很强:多个通信操作里,只有一个会被选中,其余原封不动。但原文《ConcurrentML: Improving on Go’s concurrency model》的作者 Nikhil 指出了一个容易被忽略的事实:
在
select里,ch1 <- 5是一段“动作描述”;一旦把它拿出来,它立刻变成“执行”。
也就是说,在 Go 里:
- 你不能写一个函数,返回“向某个 channel 发送”这个动作,留给调用者以后再选择;
- 你不能把它塞进结构体里带着走;
- 你不能先把两个操作封装成“二选一”,再交给上层叠加超时。
通信的“描述”和“执行”,被 Go 语法绑死在了一起。这正是 CML 要拆开的地方。
CML 的核心:通信操作就是值
CML(Concurrent ML)诞生于 1990 年代中期,出自 John Reppy 等人之手。它的关键思想只有一句话:把通信操作提升为一个“值”(称为事件,event),执行则交给 sync。
今天对这套思想支持最完整的语言是 Racket。对照一下:
| 目的 | Go | Racket(CML) |
|---|---|---|
| 立即发送 | ch <- 5 |
(channel-put ch 5) |
| 描述“发送” | 无法脱离 select | (channel-put-evt ch 5) |
| 描述“接收” | 无法脱离 select | channel 本身就是事件 |
| 执行 | 语句到达即执行 | (sync evt) |
注意,channel-put-evt 不会发送任何东西,它只返回一个值。只有被 sync 时,才真正尝试通信。事实上,阻塞式的 channel-put 本身就能用事件定义:
(define (channel-put ch val)
(sync (channel-put-evt ch val)))

事件是值,意味着它能做所有值能做的事:赋给变量、当返回值、放进 struct、被各种“组合子”继续加工。
三个组合子,撑起整个体系
1)handle-evt:提交后的动作(post-commitment)
你可以把 select 的每个 case 理解成一个“提交点”:选中它,其余作废。case 后面的语句,就是提交之后的动作。CML 把这部分挂在事件本身上:
(sync
(handle-evt (channel-put-evt out-ch x) (λ (_) (loop y (+ x y))))
(handle-evt quit-ch (λ (_) (displayln "quit"))))
2)choice-evt:“选择”本身也是个事件
Go 里“选择”只能写在 select 中;CML 里“选择”也是值,可以被封装、返回、再组合。Nikhil 给的例子是:
(define (fetch-from-fastest-url url1 url2)
(choice-evt (http-get-evt url1)
(http-get-evt url2)))
; 谁先返回用谁,并叠加 5 毫秒的超时
(sync (fetch-from-fastest-url url1 url2)
(timeout-evt 5))
注:上面代码中的
http-get-evt在原文中是示意 API,并非标准库函数。
关键在于:在 sync 之前,真正的请求根本没有发出去。库作者只负责“二选一”,调用者再决定要不要加超时,双方互不迁就。而 Go 里,“起两个 goroutine 各自返回 channel”和这件事不是一回事:goroutine 一被启动就开始干活了。
3)guard-evt:提交前的准备(setup)
与 handle-evt 对偶,guard-evt 接受一个“制造函数”,在 sync 真正开始时才执行,并用它返回的事件顶替自己。比如打印服务里“每次请求新建一个响应 channel”这类准备动作,就可以收进事件内部,调用者完全无感。
一场 2016 年的“认错”
这个话题其实并不新。2016 年,Guile 的维护者 Andy Wingo 在博客上发过一篇《concurrent ml versus go》,当时他的结论是:Go 的 channel + select 和 CML 表达力基本相当。他还特意邀请读者“来纠错”。
纠错很快就来了。第二天,他在《is go an acceptable cml?》里承认:自己错了,并给出了一个经典例子。
假设有一个“把数字加 1”的转换器,在 Go 中通常这么写:
func onePlus(in <-chan int) <-chan int {
out := make(chan int)
go func() { out <- 1 + <-in }()
return out
}
然后给它套一个超时:
select {
case v := <-onePlus(numbers):
fmt.Println(v)
case <-time.After(0):
fmt.Println("timeout")
}
如果超时先触发会怎样?onePlus 里的 goroutine 依然在等 numbers,将来某个时刻它可能取走一个数字,然后卡在 out <- ... 上,永远没人接收。结果是:一个数字凭空消失,还白白泄漏了一个 goroutine。
而在 CML 里,转换是用 handle-evt 挂在事件上的:
(sync (handle-evt numbers add1)
(timeout-evt 0))
只有当 numbers 这个分支真的被选中时,消息才会被取走并加 1,否则什么都不会发生。
Go 里当然也能改写成 case msg := <-ch: return msg + 1,但 Wingo 指出,这把“加 1”和“超时”两个关注点搅在了一起。表达力的差距,就在于能不能把转换封装在事件内部。
故事还有后续。Wingo 也发现,CML 其实可以建在“类 Go”的运行时之上——读者 Vesa Karvonen 就给出了基于 Clojure core.async 的 CML 概念验证实现。但 Michael Sperber 提醒他:把非 channel 事件包装成 goroutine + channel,性能开销可能高达 10 倍(Wingo 在文中转述了这一数字)。所以更合理的方向或许是:让 channel 的收发建在事件之上,而不是反过来。
公允起见,当年评论区也有人指出,Go 其实有
reflect.Select可以在运行时动态构造 select。但它解决的是“分支数量动态”,依然没有解决“描述可以脱离 select 独立存在”的问题。
取消:CML 把它做进了运行时
Go 的取消(cancel)靠 context.Context:约定每个函数接收 ctx,监听 Done(),调用者用 WithTimeout / WithCancel 控制。它有效,但全靠约定,且 ctx 必须穿透整条调用链。
CML 的做法是:取消也是一种事件,叫 nack(negative acknowledgement,否定确认)。通过 nack-guard-evt,制造函数会拿到一个特殊的 nack-evt,它有三种结局:

放到 Nikhil 的“打印服务”例子里,效果是这样的:
(define (print-evt service msg)
(nack-guard-evt
(λ (nack-evt)
(define resp-ch (make-channel))
(thread (λ ()
(sync nack-evt
(channel-put-evt (print-service-print-ch service)
(print-req msg resp-ch nack-evt)))))
resp-ch)))
- 如果调用者在请求提交到服务之前就放弃了(比如超时),请求根本不会发出;
- 如果服务处理完后才发现客户已不再等待,它能通过
nack-evt知道,不会卡在回复上。

最值得注意的一点是:print-evt 的 API 完全没变。调用者不需要知道有取消逻辑,更不需要额外的 Context 参数。Nikhil 也坦言,这部分是 CML 与 Go 最接近的地方——本质上都是“监听一个事件来感知取消”,区别只是:Go 把它放在顶层并逐层传递,CML 则让事件自己决定是否参与取消。
goroutine 泄漏:CML 的答案是“交给 GC”
Go 最让人头疼的陷阱之一:goroutine 一旦阻塞在永远不会就绪的操作上,就会连同它引用的内存一起泄漏。
CML 的运行时语义很干脆:
如果一个线程只阻塞在某些事件上,而线程和这些事件都不再被其他存活的对象引用,那么它可以被垃圾回收。
所以 Racket 代码里,随手 thread 一个“可能永远等不到的发送”完全没问题——没人关心它,GC 自然会收走。原书《Concurrent Programming in ML》把这种“先投机地派生线程去发送,哪怕不会被接受”的写法,称为一项重要的 CML 编程技巧。
Go 这边也在朝这个方向走。查阅 Go 1.27 发布说明:
- Go 1.26 以实验形式引入了 goroutine 泄漏 profile;
- Go 1.27(2026 年 8 月发布)将其转正,名为
goroutineleak,位于runtime/pprof,也可通过net/http/pprof的/debug/pprof/goroutineleak端点访问; - 原理同样基于可达性:如果 goroutine G 阻塞在某个并发原语 P 上,而 P 无法从任何可运行的 goroutine(及它们可能唤醒的 goroutine)到达,那么 G 永远不会醒来;
- 局限也写得很清楚:经由全局变量,或可运行 goroutine 的局部变量可达的原语,无法被识别。
一个最简单的例子:
func leak() {
ch := make(chan int)
go func() { ch <- 1 }() // 没人接收,ch 也无处可达
}
你可以在程序运行一段时间后,用下面的方式导出报告:
pprof.Lookup("goroutineleak").WriteTo(os.Stdout, 1)
这里要特别分清:Go 1.27 是“发现并报告”泄漏,而 CML 是“直接回收”不可达的阻塞线程。前者是诊断工具,后者是语言语义。Nikhil 也提到,相关提案和论文都没有提及“这个能力在 CML 生态里早已存在”,说明知道这段历史的人并不多。
冷静看:Go 能学什么,不能学什么
先泼点冷水:
- Nikhil 本人明确表示,把 CML 的事件系统改造进 Go 并不现实。
- 他的文章不是提出新东西,而是把一个长期停留在学术圈的模型介绍给工程师。
- 本文中的 Racket 示例,并不意味着你应该立刻把项目迁到 Racket。
但对日常写 Go 的人,至少有四条可以马上用上的启发:
- 延迟执行:封装并发 API 时,尽量避免“函数一调用就偷偷开始干活”,把启动时机留给调用者。
- 关注点分离:不要为了在
select里加个超时,就把转换逻辑也混进去;把“转换”和“等待”分开设计。 - 给每个 goroutine 想好退出路径:用
ctx或donechannel 是惯例,但别忘了要有人负责关闭它们。 - 升级到 Go 1.27,跑一遍
goroutineleak:很多线上缓慢增长的内存,可能就是这类泄漏。
小结
CML 的思想可以浓缩为一句话:把通信操作表示为独立于执行的值。
由此,选择、取消、前置准备、后置处理,都统一在“事件”这一个抽象下;再加上“阻塞线程可被 GC”,并发代码才真正做到了“便宜到可以随便用”。
你可能永远不会在生产环境中用 Racket,但这个 30 年前的模型,值得每个 Go 开发者花一个下午读一读——不是为了替代 Go,而是为了看清:你习以为常的 select,其实只是一个更大设计空间里的点。
资料链接:
- ConcurrentML: Improving on Go’s concurrency model - https://nikhilism.com/post/2026/golang-racket-concurrency/
- Andy Wingo:concurrent ml versus go - https://wingolog.org/archives/2016/09/20/concurrent-ml-versus-go
- Andy Wingo:is go an acceptable cml? - https://wingolog.org/archives/2016/09/21/is-go-an-acceptable-cml
- Go 1.27 Release Notes:Goroutine leak profile - https://go.dev/doc/go1.27#goroutineleak-profiles
- John H. Reppy:《Concurrent Programming in ML》
声明:本文案例代码以原文及相关资料为基础,部分 Go 代码为示意,实际使用前建议本地运行验证。
还在为写 Agent 框架频频死循环、上下文爆炸而束手无策?我的新专栏 《从0 开始构建 Agent Harness》 将带你:
- 抛弃臃肿框架,回归“驾驭工程 (Harness Engineering)”的第一性原理
- 用 Go 语言手写 ReAct 循环、并发拦截与上下文压缩引擎等,复刻极简OpenClaw
- 构建坚不可摧的 Safety Middleware 与飞书人工审批防线
- 在底层实现 Token 成本审计、链路追踪与自动化跑分评估
- 从“调包侠”进化为掌控大模型边界的“AI 操作系统架构师”
扫描下方二维码,开启从 0 开始构建Agent Harness 的实战之旅。

你的Go技能,是否也卡在了“熟练”到“精通”的瓶颈期?
- 想写出更地道、更健壮的Go代码,却总在细节上踩坑?
- 渴望提升软件设计能力,驾驭复杂Go项目却缺乏章法?
- 想打造生产级的Go服务,却在工程化实践中屡屡受挫?
继《Go语言第一课》后,我的《Go语言进阶课》终于在极客时间与大家见面了!
我的全新极客时间专栏 《Tony Bai·Go语言进阶课》就是为这样的你量身打造!30+讲硬核内容,带你夯实语法认知,提升设计思维,锻造工程实践能力,更有实战项目串讲。
目标只有一个:助你完成从“Go熟练工”到“Go专家”的蜕变! 现在就加入,让你的Go技能再上一个新台阶!

商务合作方式:撰稿、出书、培训、在线课程、合伙创业、咨询、广告合作。如有需求,请扫描下方公众号二维码,与我私信联系。
