内容提要
文章介绍Concurrent ML(CML)并发模型:将通信操作抽象为可组合的“事件”值,由sync触发执行,并通过handle-evt、choice-evt、guard-evt分别实现提交后动作、选择与提交前准备。与Go的select将描述与执行绑定不同,CML更易封装、组合和取消,且不可达的阻塞线程可被GC回收。Go 1.27的goroutineleak profile可检测泄漏但不能回收。作者认为CML无法照搬,但其延迟执行、关注点分离和泄漏检测值得借鉴。
延伸解读
描述与执行分离:CML 与 Go 的关键差异
文章指出,Go 的 select 把通信操作的“描述”和“执行”绑死在语法里:ch1 <- 5 写在 select 中只是候选动作,一旦移出就立即执行。CML 则把通信操作抽象为事件值,只有 sync 才触发执行。这意味着事件可以赋给变量、作为返回值、放进结构体,再被组合子加工。这种分离让封装和组合成为语言层面的能力,而不是靠约定硬撑。
组合子如何改变封装方式
CML 用三个组合子支撑整个体系:handle-evt 在事件被选中后执行动作,choice-evt 把“选择”本身变成可传递的事件,guard-evt 在 sync 开始时才执行准备函数。文章以“从最快 URL 获取”为例说明,库作者只负责二选一,调用者再叠加超时,双方互不迁就。相比之下,Go 里启动两个 goroutine 各自返回 channel 并非同一回事,因为 goroutine 一启动就开始干活。
取消与泄漏:运行时语义的差距
文章对比了取消机制:Go 依赖 context.Context 逐层传递,CML 则把取消做成 nack 事件内建于运行时,调用者无需额外参数。在 goroutine 泄漏上,CML 允许只阻塞在不可达事件上的线程被 GC 回收;Go 1.27 的 goroutineleak profile 能检测泄漏但不能回收,且经由全局变量或可运行 goroutine 局部变量可达的原语无法识别。文章强调这是诊断工具与语言语义的区别。
对 Go 开发者的实际启发
作者明确表示 CML 无法原样搬进 Go,但给出四条可立即借鉴的做法:封装并发 API 时避免函数一调用就偷偷启动,把启动时机留给调用者;不要把转换逻辑和超时混在同一个 select 里;为每个 goroutine 想好退出路径并有人负责关闭;升级到 Go 1.27 后跑一遍 goroutineleak,排查缓慢增长的内存。这些建议都围绕延迟执行、关注点分离和泄漏可检测展开。
Q&A
Go 的 select 和 CML 的事件模型主要区别是什么?
Go 的 select 是语法结构,通信的描述(如 ch1 <- 5)与执行绑定,无法作为值独立存在、封装或组合。CML 将通信操作抽象为事件值(event),只有通过 sync 才触发执行,因此事件可以赋值、传递、组合,实现更灵活的封装和组合。
CML 中的 handle-evt、choice-evt 和 guard-evt 分别有什么作用?
handle-evt 用于在事件被选中后执行动作(提交后动作);choice-evt 将多个事件组合成选择,本身也是事件,可被封装和再组合;guard-evt 在 sync 开始时执行准备函数,用其返回的事件顶替自己(提交前准备)。
为什么在 Go 中封装转换逻辑容易导致消息丢失和 goroutine 泄漏?
在 Go 中,用 goroutine 和 channel 封装转换逻辑时,如果外层 select 因超时先触发,内部 goroutine 可能仍在等待输入,之后取走一个值却卡在发送上,导致消息丢失和 goroutine 泄漏。CML 通过 handle-evt 将转换挂在事件上,只有分支被选中时才执行,避免了此问题。
CML 如何实现取消操作?与 Go 的 context 有何不同?
CML 将取消内建为 nack 事件,通过 nack-guard-evt 在提交前感知取消,调用者无需额外参数。Go 依赖 context.Context 约定,需要显式传递 ctx 并监听 Done(),取消逻辑需穿透调用链。CML 的取消对调用者透明,而 Go 的取消依赖约定和层层传递。
CML 如何解决 goroutine 泄漏问题?Go 1.27 有什么相关改进?
CML 运行时允许垃圾回收只阻塞在事件上且不再被引用的线程,从而自动回收泄漏的线程。Go 1.27 引入了 goroutineleak profile(实验于 1.26,1.27 转正),可检测无法被可达的阻塞 goroutine,但仅报告泄漏,不回收。
从 CML 模型中可以借鉴哪些实践来改进 Go 并发代码?
可以借鉴:延迟执行(封装 API 时避免立即启动 goroutine,将启动时机留给调用者);关注点分离(将转换逻辑与超时等待分开设计);为每个 goroutine 规划退出路径(使用 ctx 或 done channel 并确保关闭);升级到 Go 1.27 并使用 goroutineleak profile 检测泄漏。