本文永久链接 – https://tonybai.com/2025/11/20/proposal-improve-goroutine-stack-using-page-faults

大家好,我是Tony Bai。

Go 语言的 goroutine 以其轻量和高效著称,而其背后一个关键的“魔法”便是可动态增长的栈 (Resizable Stacks)。然而,支撑这个魔法的机制——在几乎每个函数入口处插入的“栈检查”指令——也并非毫无代价。

近日,在 golang-nuts 邮件组,一位名叫 Arseny Samoylov 的年轻开发者发起了一场引人深思的讨论,提出了一个颇具“革命性”的提案:我们能否借鉴 Linux 内核管理线程栈的方式,用“缺页中断”(Page Faults) 机制来取代 Go 现有的“栈检查”?

这个旨在挑战 Go 运行时基石的大胆设想,引来了 Go 语言联合创始人 Rob Pike 的亲自下场。本文中,我们就来简单看看这个看似优雅的提案,为何会引来社区的质疑,并最终被 Rob Pike 本人以“实现过于复杂”为由,泼上一盆“冷水”。

现状的“痛点”——无处不在的“栈检查”

在深入新提案之前,我们必须先理解 Go 当前的栈增长机制及其代价。

当前,Go 编译器会在几乎每一个非叶子函数的序言 (prologue) 部分,插入几条特殊的指令。这些指令的作用是在函数开始执行前,检查当前 goroutine 的剩余栈空间是否足够。如果不足,运行时 (runtime.morestack) 就会介入:分配一个更大的新栈,将旧栈的内容复制过去,调整所有指向栈上变量的指针,然后才继续执行函数。

提案者指出的当前机制的两大痛点

  1. CPU 开销:频繁的栈检查本身就是一种 CPU 开销,尤其是在调用链很深或存在大量无法内联的间接调用(如接口方法调用)时。
  2. 代码体积膨胀:每个函数都增加了额外的序言指令(提案者估计约 10 条指令),这会增加 L1 指令缓存 (L1i Cache) 的压力,对计算密集型任务的性能产生负面影响。

基于此,提案者估计,消除栈检查可能会为真实的 Go 应用带来 3% – 5% 的性能提升。

“革命”的设想——通过“缺页中断”实现栈增长

Arseny Samoylov 的提案,其灵感源自现代操作系统(如 Linux)管理原生线程栈的方式。

核心思想

  1. 在创建一个 goroutine 时,不再只分配一个很小的物理内存(当前为 2KB),而是为其预留 (reserve) 一大块虚拟地址空间(例如 8MB),但不立即分配物理内存。
  2. 在这块虚拟地址空间的末尾,设置一个“警戒页”(Guard Page),标记为不可访问。
  3. 移除编译器插入的所有“栈检查”指令。
  4. 当 goroutine 的栈增长,触及到未分配的内存页时,会触发一次缺页中断 (Page Fault)。操作系统内核会捕获这个中断,并“懒惰地”为其分配一页新的物理内存。
  5. 当 goroutine 的栈增长到极致,最终触及到那个“警戒页”时,Go 运行时捕获这个特定的信号,此时才执行现有的栈扩容逻辑。

这个设计的精妙之处在于,它将持续的、遍布每个函数的“栈检查”开销,转变成了仅在栈空间真正耗尽时才发生的一次性、代价较高的“异常处理”

社区的讨论——一场关于性能、复杂性与可行性的权衡

这个看似优雅的方案,立刻引发了社区开发者的辩论。经验丰富的工程师们很快指出了这个方案背后隐藏的巨大挑战:

  1. 中断处理的巨大开销:Jason E. Aten 指出,处理一次缺页中断并由信号处理器接管,其过程极其缓慢。它涉及至少 4 次昂贵的上下文切换(用户态 -> 内核态 -> 信号处理器 -> 内核态 -> 用户态)。这个开销,可能远高于 Go 运行时目前高效的内存分配器。
  2. 区分“好”与“坏”的中断:Go 运行时如何能精确地区分出,一次缺页中断是因为“栈需要正常增长”,还是因为一个真正的 Bug(如 nil 指针解引用)?这是一个极其棘手的问题。
  3. 虚拟地址空间的消耗:虽然 64 位系统的虚拟地址空间极其巨大,但为每一个 goroutine 都预留 8MB,依然是一个不小的负担。10 万个 goroutine 将消耗 800GB 的虚拟地址空间。
  4. 最小栈的增加:最小的物理内存分配单位是一个页(通常是 4KB)。这意味着 goroutine 的最小栈大小将从 2KB 翻倍到 4KB,对于那些拥有数百万个小 goroutine 的应用,这可能会导致物理内存消耗翻倍

Rob Pike 的“劝退”——来自创始人的最终裁决

当讨论进入白热化时,Go 语言的联合创始人 Rob Pike 亲自下场,给出了他的最终点评。他的观点,冷静而深刻,几乎为这场辩论画上了句号。

首先,他认为提案者夸大了“栈检查”的成本

“我相信你夸大了(栈检查的)成本。它是可测量的,但并没有你说的那么严重。并且,随着函数内联越来越普遍,函数的体积变大,摊销后的实际成本都在降低。”

更重要的是,他指出了这个提案在工程上的历史困境,这正是“劝退”的核心理由:

“此外,在过去,使用内核traps 来实现栈增长一直都问题重重。我曾见过其他系统尝试这样做,但最终都因为无法预见的复杂性而放弃了。我不是说这做不到,但这绝非易事。而且,由于细节依赖于架构和操作系统,要做到可移植性非常困难。”

最后,他给出了一个简洁而有力的结论:

“这事不归我管,但我不会这么做。”
(It’s not up to me, but I wouldn’t do this.)

小结:永不停歇的探索,Go 演进的生命力

这场关于 goroutine 栈的“革命”提案,最终在创始人的“劝退”中似乎逐渐平息。然而,将此视为一次简单的“失败”,或许会错失其更深远的意义。

Rob Pike 的点评,以其数十年的工程经验和对复杂性的深刻洞察,为这个提案的技术路径亮起了警示的红灯。他指出的“无法预见的复杂性”“难以解决的可移植性”,是任何试图修改语言运行时的工程师都必须敬畏的“冰山”。

然而,无论这位提案者 Arseny Samoylov 最终是选择接受劝告,还是不顾一切地继续探索并拿出概念验证 (PoC),这场讨论本身,对 Go 社区而言,都是一件弥足珍贵的好事,它完美地体现了 Go 社区的生命力所在。

Go 语言的演进,正是在这种“大胆设想”与“审慎权衡”的持续张力中,稳步前行的。

资料链接:https://groups.google.com/g/golang-nuts/c/q3iZk0phN9E


还在为“复制粘贴喂AI”而烦恼?我的新专栏 AI原生开发工作流实战 将带你:

  • 告别低效,重塑开发范式
  • 驾驭AI Agent(Claude Code),实现工作流自动化
  • 从“AI使用者”进化为规范驱动开发的“工作流指挥家”

扫描下方二维码,开启你的AI原生开发之旅。


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

© 2025, bigwhite. 版权所有.

Related posts:

  1. 连 Rob Pike 都感到“担忧”:Go 1.26 SIMD 引入的新复杂性与应对之道
  2. 从 Rob Pike 的提案到社区共识:Go 或将通过 new(v) 彻底解决指针初始化难题
  3. Go 1.25新特性前瞻:GC提速,容器更“懂”Go,json有v2了!
  4. Go 标准库将迎来 Zstandard:性能超越 Gzip,让你的应用更快、更省
  5. 也谈Go的可移植性