10

Go 语言如何实现垃圾回收中的 Stop the World (STW)

 3 years ago
source link: https://studygolang.com/articles/28450
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.

JbQNNna.png!web

本篇文章讨论实现原理基于 Go 1.13.

在垃圾回收机制 (GC) 中,"Stop the World" (STW) 是一个重要阶段。 顾名思义, 在 "Stop the World" 阶段, 当前运行的所有程序将被暂停, 扫描内存的 root 节点和添加写屏障 (write barrier) 。 本篇文章讨论的是, "Stop the World" 内部工作原理及我们可能会遇到的潜在风险。

Stop The World(STW)

这里面的"停止", 指的是停止正在运行的 goroutines。 下面这段程序就执行了 "Stop the World":

func main() {
   runtime.GC()
}

这段代码中, 调用 runtime.GC() 执行垃圾回收, 会触发 "Stop the World"的三个步骤。

(关于关于垃圾回收机制, 可以参考我的另外一篇文章 "Go: 内存标记在垃圾回收中的实现" ):

这个阶段的第一步, 是抢占所有正在运行的 goroutine(即图中 G ):

j2MjU3a.png!web

被抢占之后, 这些 goroutine 会被悬停在一个相对安全的状态。 同时,承载 Goroutine 的处理器 P (无论是正在运行代码的处理器还是已在 idle 列表中的处理器), 都会被被标记成停止状态 (stopped), 不再运行任何代码:

RjMNvef.png!web

接下来, Go 调度器 (Scheduler) 开始调度, 把每个处理器的 Marking Worker (即图中 M ) 从各自对应的处理器 P 分离出来, 放到 idle 列表中去, 如下图:

BFvQBfB.png!web

在停止了处理器和 Marking Worker 之后, 对于 Goroutine 本身, 他们会被放到一个全局队列中等待:

niumyyr.png!web

到目前为止, 整个"世界"被停止. 至此, 仅存的 "Stop The World" (STW)goroutine 可以开始接下来的回收工作, 在一些列的操作结束之后, 再启动整个"世界"。

我们也可以在 Tracing 工具中看到一次 STW 的运行状态:

ZvyuemB.png!web

系统调用

下面我们来讨论一下 STW 是如何处理系统调用的。

我们知道, 系统调用是需要返回的, 那么当整个"世界"被停止的时候, 已经存在的系统调用如何被处理呢?

我们通过一个实际例子来理解:

func main() {
   var wg sync.WaitGroup
   wg.Add(10)
   for i := 0; i < 10; i++ {
      Go func() {
         http.Get(`https://httpstat.us/200`)
         wg.Done()
      }()
   }
   wg.Wait()
}

这是一段简单的系统调用的程序, 我们通过 Tracing 工具看一下它是如何被处理的:

nAVN7bE.png!web

我们可以看到, 这个系统调用 goroutine (即图中 G30 ) 在"世界"被停止的时候, 就已经存在了。

但是, 我们之前提到, STW 把所有的处理器 P 都标为停止状态 (stopped) , 所以, 这个系统调用的 Goroutine 也会被放到全局队列中, 等待 golang 世界恢复之后, 被重新启用。

延迟

前文提到 STW 的第三步是将 Marking Worker( M ) 从处理器( P )上分离, 然后放入 idle 列表中。

而实际上, Go 会等待他们自发停止, 也就是说当调度器(scheduler)运行的时候, 系统调用在运行的时候, STW 会等待。

理论上, 等待一个 Goroutine 被抢占是很快的, 但是在有些情况下, 还是会出现相应的延迟。

我们通过一个例子来模拟类似情况:

func main() {
   var t int
   for i := 0;i < 20 ;i++  {
      Go func() {
         for i := 0;i < 1000000000 ;i++ {
            t++
         }
      }()
   }

   runtime.GC()
}

我们还是来看一下这段代码运行的 Tracing 情况, 从下图我们可以看到 STW 阶段总共耗时 2.6 秒:

z22yyae.png!web

我们来简单分析为什么会出现这么长的 STW: 正如例子中的 main 函数, 一个没有函数调用的 Goroutine 一般不会被抢占, 那么这个 Goroutine 对应的处理器 P 在任务结束之前不会被释放。

而 STW 的机制是等待它自发停止, 因此就出现了 2.6 秒的 STW。

为了提高整体程序的效率, 我们一般需要避免或者改进这种情况。

关于这部分, 大家可以参考我的另一篇文章 "Go: Goroutine 和抢占"


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK