本文永久链接 – 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 的 goroutineleak profile 是同一思路的“检测版”,而非“回收版”。
  • 客观评价: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)))

Go 与 CML 的执行模型对比

事件是值,意味着它能做所有值能做的事:赋给变量、当返回值、放进 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,它有三种结局:

nack 事件的三种结局

放到 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 的人,至少有四条可以马上用上的启发:

  1. 延迟执行:封装并发 API 时,尽量避免“函数一调用就偷偷开始干活”,把启动时机留给调用者。
  2. 关注点分离:不要为了在 select 里加个超时,就把转换逻辑也混进去;把“转换”和“等待”分开设计。
  3. 给每个 goroutine 想好退出路径:用 ctx 或 done channel 是惯例,但别忘了要有人负责关闭它们。
  4. 升级到 Go 1.27,跑一遍 goroutineleak:很多线上缓慢增长的内存,可能就是这类泄漏。

小结

CML 的思想可以浓缩为一句话:把通信操作表示为独立于执行的值。

由此,选择、取消、前置准备、后置处理,都统一在“事件”这一个抽象下;再加上“阻塞线程可被 GC”,并发代码才真正做到了“便宜到可以随便用”。

你可能永远不会在生产环境中用 Racket,但这个 30 年前的模型,值得每个 Go 开发者花一个下午读一读——不是为了替代 Go,而是为了看清:你习以为常的 select,其实只是一个更大设计空间里的点。

资料链接:

  1. ConcurrentML: Improving on Go’s concurrency model - https://nikhilism.com/post/2026/golang-racket-concurrency/
  2. Andy Wingo:concurrent ml versus go - https://wingolog.org/archives/2016/09/20/concurrent-ml-versus-go
  3. Andy Wingo:is go an acceptable cml? - https://wingolog.org/archives/2016/09/21/is-go-an-acceptable-cml
  4. Go 1.27 Release Notes:Goroutine leak profile - https://go.dev/doc/go1.27#goroutineleak-profiles
  5. 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技能再上一个新台阶!


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