大数跨境

Go 并发的天花板,被一个 30 年前的老模型捅破了?CML 把 channel 玩出了新花样

Go 并发的天花板,被一个 30 年前的老模型捅破了?CML 把 channel 玩出了新花样 TonyBai
2026-10-22
1


大家好,我是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 代码为示意,实际使用前建议本地运行验证。


如果本文对你有所帮助,请帮忙点赞、推荐和转发!

点击下面标题,阅读更多干货!

-  Goroutine泄漏防不胜防?Go GC或将可以检测“部分死锁”,已在Uber生产环境验证

- 测试超时卡死,却不知道是哪个 goroutine 在作妖?Go 官方提案:给每个测试自动打标签

- context:Go 语言的“天问”,你真的懂了吗?

- 别再无脑 go func 了!Go 资深布道师 Dave Cheney 的 Goroutine 管理哲学

- 并发测试神器 synctest 的“成人礼”:从goroutine泄漏到微妙的竞态,Go团队如何修复三大“首日bug”?

- 从“锁”到“channel”:开启你的Go并发心智模型转变之旅

- 解构Go并发之核,与Dmitry Vyukov共探Go调度艺术




🔥 还在为写 Agent 框架频频死循环、上下文爆炸而束手无策?我的新专栏 《从0 开始构建 Agent Harness》 将带你:

  • 抛弃臃肿框架,回归“驾驭工程 (Harness Engineering)”的第一性原理
  • 用 Go 语言手写 ReAct 循环、并发拦截与上下文压缩引擎等,复刻极简OpenClaw
  • 构建坚不可摧的 Safety Middleware 与飞书人工审批防线
  • 在底层实现 Token 成本审计、链路追踪与自动化跑分评估
  • 从“调包侠”进化为掌控大模型边界的“AI 操作系统架构师”

扫描下方二维码👇,开启从 0 开始构建Agent Harness 的实战之旅。

【声明】内容源于网络
0
0
TonyBai
Tony Bai的技术世界 (tonybai.com)。 不满足于“会用”,我们追求“精通”。 专注Go语言底层原理、高质量工程实践与云原生架构,探索Go与AI等前沿结合。 欢迎对技术有追求的Gopher同行,关注我,与Go一同进化。
内容 1235
粉丝 0
TonyBai Tony Bai的技术世界 (tonybai.com)。 不满足于“会用”,我们追求“精通”。 专注Go语言底层原理、高质量工程实践与云原生架构,探索Go与AI等前沿结合。 欢迎对技术有追求的Gopher同行,关注我,与Go一同进化。
总阅读11.0k
粉丝0
内容1.2k