Go GC:三色标记,并发还要正确
动机:自动回收,又不能长时间停全世界。 Go 程序不停分配、从不手动 free,runtime 必须自己找出垃圾。笨办法是全停下来,走一遍所有可达对象,剩下的全 free,但长停顿就是长跑服务的延迟毛刺。所以 Go 一边让程序跑、一边并发标记,整个设计都是为了让并发标记不出错。
三色:没看、半看、全看。 所有对象起手都是白色,意思是未访问、候选回收。根(全局变量、栈上的指针、寄存器)先染成灰色。灰色是已访问、子对象还没扫完;黑色是已访问、子对象全扫完了。标记就是不断取一个灰对象,把它的子对象染灰,再把它自己变黑,直到没有灰对象为止。最后还白着的,就是不可达的垃圾。白灰黑正好对应没看、半看、全看。
三色不变式,以及并发怎么破坏它。 强不变式是:黑对象不能直接指向白对象。如果标记时这条一直成立,就不会漏标活对象。但标记时程序还在改指针,两种改动凑在一起就破了它:一个黑对象新指了一个白对象,同时这个白对象唯一的另一条路被删了。白对象还活着,但已经没有灰对象能通向它,标记结束都没访问过它,就会被当成垃圾 free 掉。这就是整个设计要防的致命错误。弱不变式也值得知道:黑对象指白对象可以,但这个白对象必须同时经由某个灰对象可达,也就是有灰色保护路径通向它。屏障的活,就是让弱不变式在任何时候都成立。
写屏障:写指针时介入。 标记期间每次写指针,屏障都跑一遍。插入屏障是:黑对象新指白对象时,把白对象染灰。删除屏障是:指针被覆盖或删除时,把旧目标染灰,防那是通向它的最后一条路。Go 用的是两者混合:堆上的写走屏障,栈上的写不走屏障,靠终止停顿重扫栈来兜底。屏障的活就一句话:还活着的白对象,不能在标记结束时还是没看过的。
STW 只发生在两端。 标记开始先短停一次:开写屏障、把根染灰。然后并发标记,程序照跑。标记结束再停一次:重扫根和栈、关写屏障、算下次触发点。两次停顿之外全是并发跑。这两次停顿的长度,就是长跑服务延迟毛刺的来源。
标记完了:清扫。 白对象被回收,内存拿回去复用。清扫本身也可以跟程序并发跑,不用再停一次。
GOGC:调的是触发点,不是算法。 GOGC 定的是上次回收后堆能长多少才触发下次:下次触发点等于上次存活堆乘以一加 GOGC 除以一百。默认 100 就是堆可以翻倍才触发下次。GOGC 大,回收次数少、内存多;GOGC 小,回收勤、内存少。它调的是回收频率和内存的 tradeoff,别的东西都不管。旁边还可以再设一个软上限 GOMEMLIMIT。
标记分摊在 P 上。 标记任务由 P 上的 worker 分担,分配太快、标记跟不上时,分配的 goroutine 会被拉去辅助标记:先干一部分标记活,再继续分配。分配者替标记买单,标记就不会被分配甩在后面。
生产注记。 长跑后端里 GC 相关的延迟毛刺,根子就是那两次停顿和标记跟不上分配。常规手段按顺序是:调 GOGC 和 GOMEMLIMIT、减少分配、别把堆养得太大。