Go 语言设计与实现 中关于栈空间的描述
多数架构上默认栈大小都在 2 ~ 4 MB 左右,极少数架构会使用 32 MB 作为默认大小。用户程序可以在分配的栈上存储函数参数和局部变量。
Go 语言的逃逸分析遵循以下两个不变性
1.指向栈对象的指针不能存在于堆中;
2.指向栈对象的指针不能在栈对象回收后存活;
栈内存空间
Go 语言使用用户态线程 Goroutine 作为执行上下文,它的额外开销和默认栈大小都比线程小很多,然而 Goroutine 的栈内存空间和栈结构也在早期几个版本中发生过一些变化:
v1.0 ~ v1.1 — 最小栈内存空间为 4KB;
v1.2 — 将最小栈内存提升到了 8KB;
v1.3 — 使用连续栈替换之前版本的分段栈;
v1.4 — 将最小栈内存降低到了 2KB;
Goroutine 的初始栈内存在最初的几个版本中多次修改,从 4KB 提升到 8KB 是临时的解决方案,其目的是为了减轻分段栈的栈分裂问题对程序造成的性能影响;在 v1.3 版本引入连续栈之后,Goroutine 的初始栈大小降低到了 2KB,进一步减少了 Goroutine 占用的内存空间。
分段栈
当 Goroutine 调用的函数层级或者局部变量需要的越来越多时,运行时会调用 runtime.morestack#go1.2 和 runtime.newstack#go1.2 创建一个新的栈空间,这些栈空间虽然不连续,但是当前 Goroutine 的多个栈空间会以链表的形式串联起来,运行时会通过指针找到连续的栈片段:
分段栈机制虽然能够按需为当前 Goroutine 分配内存并且及时减少内存的占用,但是它也存在两个比较大的问题:
1.如果当前 Goroutine 的栈几乎充满,那么任意的函数调用都会触发栈的扩容,当函数返回后又会触发栈的收缩,如果在一个循环中调用函数,栈的分配和释放就会造成巨大的额外开销,这被称为热分裂问题(Hot split);
2.一旦 Goroutine 使用的内存越过了分段栈的扩缩容阈值,运行时就会触发栈的扩容和缩容,带来额外的工作量;
连续栈
连续栈可以解决分段栈中存在的两个问题,其核心原理就是每当程序的栈空间不足时,初始化一片更大的栈空间并将原栈中的所有值都迁移到新的栈中,新的局部变量或者函数调用就有了充足的内存空间。使用连续栈机制时,栈空间不足导致的扩容会经历以下几个步骤:
1.在内存空间中分配更大的栈内存空间;
2.将旧栈中的所有内容复制到新的栈中;
3.将指向旧栈对应变量的指针重新指向新栈;
4.销毁并回收旧栈的内存空间;
因为需要拷贝变量和调整指针,连续栈增加了栈扩容时的额外开销,但是通过合理栈缩容机制就能避免热分裂带来的性能问题,在 GC 期间如果 Goroutine 使用了栈内存的四分之一,那就将其内存减少一半,这样在栈内存几乎充满时也只会扩容一次,不会因为函数调用频繁扩缩容。
云风博客关于栈空间的描述:
go 支持海量 goroutine 如何解决 stack 空间占用问题的。lua 的 coroutine 没有这个问题是因为 lua 是在虚拟机内运行,自己在 heap 上开辟空间保存 VM 中的 stack ,lua 5.2 中的 coroutine 的基本内存开销仅有 208 字节(64 位系统下)。
但 go 是要编译成本地代码的,并且需要和传统的 C 代码做交互。它需要使用传统的 stack 模型。这样就必须让 stack 大小可以动态增长且不必连续。
事实上 go 的确是这样的,如果你写一个无限递归的 go 函数,它不会像 C 函数那样很快就 stackoverflow ,而仅仅是一点点吃掉内存而已。
解决这个问题的方法是采用一种叫作 Split Stacks 或是 segmented stacks 的技术。我们只需要在函数调用的时候检查当前堆栈的容量,如果快用净就新申请一块内存,并把栈指针指过去。当函数返回后,再把栈指针修改回来即可。
透明的作到这点需要增强编译器,更准确的说,我们增强链接器即可。因为与其在编译时给函数调用前后插入代码,不如在链接过程给链入的函数加一个壳。并且可以通过 #pragma 给链接器提供建议。
具体分析可以见这篇文章。我顺着读到 gcc 对 splitstack 的扩展支持 ,如果能让 gcc 对这个支持良好,那么就可以实现一个不错的 coroutine 库了。
不过我对里面提到的向前兼容的方案有点意见。当调用原来没有经过 splitstack 编译链接过的 C 库时,文章里提到分配一块足够大(64K)的栈空间供其使用。10 年前,我为梦幻西游的客户端实现过一个简单的 coroutine 库,由于需要开辟上千条 coroutine (当时物理内存只保证有 128M ),我只给每条 coroutine 预留了 4k 空间。那么在 coroutine 里像调用传统函数,只需要把 stack 指针切回到当前系统线程的 stack 上即可。
这样做可行是因为,之所以我们需要为 coroutine 保留独立 stack ,是因为 coroutine 中可以通过 yield 保存延续点,以后需要跳回。但传统函数里绝对不可能调用 yield ,我们就可以断言这些函数运行时,当前线程不会有任何其它 coroutine 的执行序混于其间。一个线程的所有 coroutine 都可以共享一个栈空间来执行这些传统函数。
hive,skynet以及go语言
这里的hive和skynet都是云风大神的开源项目。skynet是一个基于actor模型的开源并发框架。hive是skynet简化并去掉了一些“历史包袱”之后重新设计的框架。go是google开源的一门编程语言。
hive和go的比较
hive和go的代码我都看过,发现有些想法惊人地相似。为了充分地利于多核的并发优势,它们都选择了协程,go中是goroutine,hive框架中是借助lua的coroutine,非常轻量。协程之间不会有加锁之类方式的处理数据依赖,不会通过共享内存来通信,而是通过通信来共享内存。go中是channel,hive中每个actor都附带一个消息队列。如果遇到协和执行不下去了,则会暂时地将它yield,直接条件满足时继续。go中是通过分段栈实现保存一个goroutine的低开销,而hive更省,直接利用lua虚拟机。
在底层实现上,它们都是开了几条物理线程,不停地取一个协程执行,如果要yield就将协和放到队列中等待时机重新拿出来执行。调度方面go要做得完善一些,毕竟hive代码量小。
不过在保存上下文上,hive更牛一些。据云风说保存一个coroutine只要200到300B,每个lua_State不到10K,而go的每个goroutine则至少需要4k以上,即使使用分段栈技术,所以还是没有lua轻量。只要是按栈去实现的保存上下文都不可能更轻量的,没办法。而且分段栈带来的很大一个负作用就是与C的兼容性,其实cgo并不那么好用的。lua使用虚拟机的,与C的兼容性堪称完美。不过也不是完全没有代价,C的数据与lua栈数据的传递也是一笔额外的开销。
在网络处理方面,从skynet改版后的hive与go的做法是相同的,底层是epoll/kqueue机制的异步io,上层提供给用户的阻塞的io接口。我觉得这才是人性化的方案,异步加回调那种绝对是反人类的。
底层实现上也是相同,调用上层的网络api后导致阻塞,则会把当前的协程yield掉。有一个后台线程不停地做poll,如果收到数据则会唤醒相应端口的协程。
不同的地方是通道方面。Go提供了first class的channel,这个通用性更强一些。而hive则受了更多erlang的影响,每个actor绑定一条消息队列。
虽然我是Go语言粉丝,毕竟不是低端脑残粉。看了hive的代码后,有时候我甚至觉得hive做得更好一些。完美兼容C是个很大的优势,比如说内存管理可以自己选择让lua垃圾回收或者自己手动管理。甚至,使用完之后,直接释放一个lua_State都不用进行垃圾回收。虽然Go也可以自己申请一块大内存后手动管理,但总不是像直接使用C那么爽。随便进行比较一下,代码量短小易控方面,hive胜。coroutine的开销上,hive胜。DSL设计上,hive胜。内存管理的灵活性方面,hive胜。
引用: