英文原文链接 发表于2014/09/15


在CloudFlare,我们使用Go语言搭建各种服务和应用。在这篇博文里,我们将对Go语言的技术特点进行深度分析。Go语言里最重要的一个特性就是goroutine。它们的开销比较小,相互协作地调度线程来运行。它们有广泛的用途,比如实现超时控制(timeouts),生成器(generators),以及在多个后台应用之间实现相互竞争(racing)。为了使goroutine能够适应更多的任务,我们必须保证每个goroutine占用很少的内存。同时,人们应该可以很方便地创建goroutine。

为了达到这些目标,Go语言管理的栈的方式看起来和其它很多语言一样,但是它的实现确实非常不同。


线程栈介绍

在我们开始讨论Go语言的栈之前,让我们来看看C语言是怎么管理栈的。

当你在C语言里启动一个线程的时候,标准库(standard library)会负责分配一块内存来用作线程的栈空间。它首先分配一块内存,告诉内核它的地址,然后让内核来控制线程的运行。如果这块分配的内存空间不够大的话,问题就变得复杂起来了。

我们来看看下面这个函数:

这是个递归函数。调用a(4,5)会耗尽所有的栈内存。为了避免这个问题,我们可以调整标准库分配给栈的内存空间的大小。但是增大这个参数会导致所有的线程都占用那么多的栈空间,即使这些函数并不需要递归调用。在这种情况下,虽然你的程序没有用到分配的栈,它还是会耗尽所有的内存。

另外一个解决办法是给每个线程分配不同大小的栈。这样你就需要给每个线程配置栈的大小,从而使得创建线程变得更加麻烦。想要决定一个线程会使用多少内存通常是非常困难的。


Go语言的解决办法

Go语言的运行环境(runtime)尝试在goroutine需要的时候动态地分配栈空间,而不是给每个goroutine分配固定大小的内存空间。这样就避免了需要程序员来决定栈的大小。Go的开发小组正尝试从一种解决方案切换到另外一种解决方案。接下来将会讨论老的解决方案和它的缺点,然后介绍新的方案以及选择它的原因。

分块式的栈(Segmented stacks)

分块式的栈是最初Go语言组织栈的方式。当创建一个goroutine的时候,它会分配一个8KB的内存空间来给goroutine的栈使用。

我们最感兴趣的是当这8KB的栈空间被用完的时候。为了处理这种情况,每个Go函数的开头都有一小段检测代码。这段代码会检查我们是否已经用完了分配的栈空间。如果是的话,它会调用morestack函数。morestack函数分配一块新的内存作为栈空间,并且在这块栈空间的底部填入各种信息(包括之前的那块栈地址)。在分配了这块新的栈空间之后,它会重试刚才造成栈空间不足的函数。这个过程叫做栈分裂(stack split)。当经过栈分裂之后,栈结构如下图所示。

在新分配的栈底部,还插入了一个叫做lessstack的函数指针。这个函数还没有被调用。这样设置是为了从刚才造成栈空间不足的那个函数返回时做准备的。当我们从那个函数返回时,它会跳转到lessstacklessstack函数会查看在栈底部存放的数据结构里的信息,然后调整栈指针(stack pointer)。这样就完成了从新的栈块到老的栈块的跳转。接下来,新分配的这个块栈空间就可以被释放掉了。

分块式的栈的问题

分块式的栈让我们能够按照需求来扩展和收缩栈的大小。程序员不需要花精力去估计goroutine会用到多大的栈。创建一个新的goroutine的开销也不大。当程序员不知道栈会扩展到多少大时,它也能很好的处理这种情况。

这一直是之前Go语言管理栈的的方法。但这个方法有一个问题。缩减栈空间是一个开销相对较大的操作。如果在一个循环里有栈分裂,那么它的开销就变得不可忽略了。一个函数会扩展,然后分裂栈。当它返回的时候又会释放之前分配的内存块。如果这些都发生在一个循环里的话,代价是相当大的。

这就是所谓的热分裂问题(hot split problem)。它是Go语言开发者选择新的栈管理方法的主要原因。新的方法叫做栈复制法(stack copying)

栈复制法(stack copying)

栈复制法一开始和分块式的栈很像。当goroutine运行并用完栈空间的时候,与之前的方法一样,栈溢出检查会被触发。但是,不像之前的方法那样分配一个新的内存块并链接到老的栈内存块,新的方法会分配一个两倍大的内存块并把老的内存块内容复制到新的内存块里。这样做意味着当栈缩减回之前大小时,我们不需要做任何事情。栈的缩减没有任何代价。而且,当栈再次扩展时,运行环境也不需要再做任何事。它可以重用之前分配的空间。

栈是如何被复制的?

栈的复制听起来很容易,但实际操作并非那么简单。存储在栈上的变量的地址可能已经被使用到。也就是说程序使用到了一些指向栈的指针。当移动栈的时候,所有指向栈里内容的指针都会变得无效。幸运的是,指向栈内容的指针自身也必定是保存在栈上的。这是为了保证内存安全的必要条件。否则一个程序就有可能访问一段已经无效的栈空间了。

因为垃圾回收的需要,我们必须知道栈的哪些部分是被用作指针了。当我们移动栈的时候,我们可以更新栈里的指针让它们指向新的地址。所有相关的指针都会被更新。我们使用了垃圾回收的信息来复制栈,但并不是任何使用栈的函数都有这些信息。因为很大一部分运行环境是用C语言写的,很多被调用的运行环境里的函数并没有指针的信息,所以也就不能够被复制了。当遇到这种情况时,我们只能退回到分块式的栈并支付相应的开销。(注:这部分信息有点过时了,但还是值得一读!)

这也是为什么现在运行环境的开发者正在用Go语言重写运行环境的大部分代码。无法用Go语言重写的部分(比如调度器的核心代码和垃圾回收器)会在特殊的栈上运行。这个特殊栈的大小由运行环境的开发者设置。

这些改变除了使栈复制成为可能,它也允许我们在将来实现并行垃圾回收。

再说一下虚拟内存

还有一种处理栈空间的办法是分配很大一块虚拟内存。因为只有在内存地址被访问到的时候才会真正分配物理内存,似乎我们可以简单地分配一块很大的虚拟内存然后让操作系统来完成剩下的工作。但是这个方法有几个问题。

首先,32位的系统只有4GB的虚拟内存,而通常只有其中的3GB可以被应用程序使用。创建上百万的goroutine也不是不常见,这时你很可能会用完所有的虚拟内存(即使我们假设栈只用到8KB的空间)。

其次,即使我们可以在64位的系统里分配大量的虚拟内存,它依赖过量使用(overcommitting)内存。过量使用是指我们分配比实际物理内存空间更多的虚拟内存,并且依赖操作系统来确保能够分配到需要的物理内存。但是过量使用虚拟内存是存在一定风险的。因为一个进程真的使用了比实际物理内存更大的内存空间时,它需要开始为新的需求腾出可用的物理空间。它通常会把一块内存里的内容保存到磁盘上。这样会导致延迟不可预测。因为这个原因,我们通常不在系统里过量使用内存。


结束语