一些 Go 语言的新学习者总是会对 goroutine 栈内存占用大小感到非常好奇。这一般是由于程序员进行无限的函数循环调用导致的。为了说明这个问题,请思考以下代码示例(为使问题更加清晰而使用相对刻意的写法):
package main
import "fmt"
type S struct {
a, b int
}
// String 实现了接口 fmt.Stringer
func (s *S) String() string {
return fmt.Sprintf("%s", s) // 调用 Sprintf 时会默认调用 s.String()
}
func main() {
s := &S{a: 1, b: 2}
fmt.Println(s)
}
尽管我不建议你这样做,但当你尝试运行这段代码的时候,你会发现你的机器正在进行大量的运算,甚至变得无响应而使你不得不使用 ctrl + c 来中断执行,以免程序最终达到无药可救的地步;因为我知道你会这样做,所以我为你做好了这一步,你可以直接在 playground 执行这段代码。
许多程序员都曾经写过类似的代码而导致函数的无限循环调用,并使得他们的程序崩溃,但一般情况下并不足以对他们的机器造成毁灭性破坏。问题是,为什么 Go 的程序就特殊一点的呢?
goroutine 的一个主要特性就是它们的消耗;创建它们的初始内存成本很低廉(与需要 1 至 8MB 内存的传统 POSIX 线程形成鲜明对比)以及根据需要动态增长和缩减占用的资源。这使得 goroutine 会从 4096 字节的初始栈内存占用开始按需增长或缩减内存占用,而无需担心资源的耗尽。
为了实现这个目标,链接器(5l、6l 和 8l)会在每个函数前插入一个序文,这个序文会在函数被调用之前检查判断当前的资源是否满足调用该函数的需求(备注 1)。如果不满足,则调用 runtime.morestack 来分配新的栈页面(备注 2),从函数的调用者那里拷贝函数的参数,然后将控制权返回给调用者。此时,已经可以安全地调用该函数了。当函数执行完毕,事情并没有就此结束,函数的返回参数又被拷贝至调用者的栈结构中,然后释放无用的栈空间。
通过这个过程,有效地实现了栈内存的无限使用。假设你并不是不断地在两个栈之间往返,通俗地讲叫栈分割,则代价是十分低廉的。
但是我一直注意到一个问题,当你的程序存在函数的无限循环调用而即将导致你的操作系统内存枯竭,而此时又恰好需要分配新的栈页面,则会从堆中分配内存。
当你的函数无止尽地调用着自己,新的栈页面会不断地从堆中分配,继而使得函数又能够继续调用自己。我相信这很快就会使程序用光你机器所有空余的物理内存,交换存储器也会被大量使用,最终导致你的系统变得非常不稳定。
可以被 Go 使用的堆内存取决于许多方面,包括你的 CPU 架构以及操作系统,但一般依赖于你机器可用的物理内存,因此你的机器会在即将使用完堆内存之前进行大量交换存储器的操作。
对于 Go 1.1,许多人都希望可以提升 32 位以及 64 位平台上堆内存使用的最大限制,这个问题会在某些情况下变得更加严重。比如说,你的机器不太可能拥有 128GB 的物理内存(备注 3)。
最后要说的是,这里有一些 issue 已经涉及到这个问题(issue1、issue2),但仍未找到在不损失性能的情况下能够处理该问题的一个好的解决方案。
备注:
1. 同样适用于方法,但方法的接收者本质上就是函数的第一个参数,当讨论有关 Go 的分段栈的问题时,没有必要将它们区别对待。
2. 使用页面这个词不代表每次分配的内存额度是固定的 4096 字节,必要时会调用 runtime.morestack 来进行新的分配,但我猜测会与页面值的倍数相接近。
3. 由于 Go 1.1 的改动,64 位 Windows 平台的堆内存被限制在 32GB 之内。
原文地址:http://dave.cheney.net/2013/06/02/why-is-a-goroutines-stack-infinite