当前位置: 首页 > 科技观察

使用eBPF深入研究GoGC

时间:2023-03-14 12:18:40 科技观察

大家好,我是ProgrammerSpectre。对于程序员来说,内存管理是非常重要的。编程语言按照内存管理方式一般可以分为手动内存管理和自动内存管理。手动内存管理的典型代表是C和C++;自动内存管理的代表有Java、C#等。通常自动内存管理自带垃圾收集器,也就是GC(当然Rust有不同的方式,既没有GC也没有手动内存管理,你可以找有兴趣可以出来)。Go语言也使用GC来管理内存。虽然Gopher不需要手动管理内存,但是了解Go如何分配和释放内存可以让我们编写出更好、更高效的应用程序。垃圾收集器是这个难题的关键部分。本文讨论Go中的GC。为了更好地理解垃圾收集器的工作原理,我决定在一个实时应用程序中跟踪它的低级行为。本文将使用eBPFuprobes检测Go垃圾收集器。本文的源代码在这里[1]。1.先决条件在深入研究之前,让我们快速了解一下uprobe、垃圾收集器的设计以及我们将使用的演示应用程序。为什么要检测?Uprobes[2]很酷,因为它们允许我们在不更改代码的情况下动态收集新信息。当您不能或不想重新部署您的应用程序时,这很有用。函数参数、返回值、延迟和时间戳都可以通过uprobes收集。在这篇文章中,我将把uprobes部署到Go垃圾收集器的关键函数。这使我们能够看到它在正在运行的应用程序中的实际行为。uprobes可以跟踪函数的延迟、时间戳、参数和返回值切片注意:本文使用的Go版本是1.16。我将跟踪Go运行时中的私有函数,因此这些函数可能会在Go的后续版本中发生变化。垃圾收集阶段Go使用并发标记清除垃圾收集器。对于那些不熟悉这些术语的人,请阅读以下内容以便您理解本文的其余部分。https://agrim123.github.io/posts/go-garbage-collector.htmlhttps://en.wikipedia.org/wiki/Tracing_garbage_collectionhttps://go.dev/blog/ismmkeynotehttps://www.iecc.com/gclist/GC-algorithms.htmlGo的垃圾收集器之所以说是并发的,是因为它可以安全地与主程序并行运行。换句话说,它不需要停止程序的执行来完成它的工作(稍后会详细介绍)。垃圾收集有两个主要阶段:标记阶段:识别并标记程序不再需要的对象。清除阶段:对于每个被标记阶段标记为“不可达”的对象,释放内存以供其他地方使用。节点着色算法。黑色表示仍在使用。白色表示它已准备好清洁。灰色意味着它仍然需要分类为黑色或白色一个简单的演示应用程序这是一个简单的端点,我将使用它来触发垃圾收集器。它创建一个可变大小的字符串数组,然后通过调用runtime.GC()启动垃圾收集器。在实际代码中,您不需要手动调用垃圾收集器,因为Go会自动为您处理。http.HandleFunc("/allocate-memory-and-run-gc",func(whttp.ResponseWriter,r*http.Request){arrayLength,bytesPerElement:=parseArrayArgs(r)arr:=generateRandomStringArray(arrayLength,bytesPerElement)fmt.Fprintf(w,fmt.Sprintf("Generatedstringarraywith%dbytesofdata\n",len(arr)*len(arr[0])))runtime.GC()fmt.Fprintf(w,"Rangarbagecollector\n")})2.追踪垃圾收集的主要阶段现在我们了解了uprobes和Go垃圾收集器的基础知识,让我们更深入地了解它的行为。跟踪runtime.GC()首先,我们计划在Go的运行时库中的以下函数中添加uprobes:函数说明GC[3]调用GCgcWaitOnMark[4]等待标记阶段完成gcSweep[5]执行扫描阶段(如果您有兴趣查看如何生成uprobe,请查看代码[6]。)部署uprobe后,命中端点会生成一个包含10个字符串的数组,每个字符串20个字节。$curl'127.0.0.1:8080/allocate-memory-and-run-gc?arrayLength=10&bytesPerElement=20'Generatedstringarraywith200bytesofdataRangarbagecollector此时uprobes观察到以下事件:运行垃圾收集器后,为GC、gcWaitOnMark和gcSweep收集事件这从源代码[7]中是有意义的-gcWaitOnMark被调用两次,一次是在开始下一个循环之前验证前一个循环。标记阶段触发清理阶段。接下来,使用对/allocate-memory-and-run-gc端点的各种输入请求,对runtime.GC后的延迟进行了一些测量。arrayLengthbytesPerElementApproximatesize(B)GClatency(ms)GCthroughput(MB/s)1001,000100,0003.2311,0001,0001,000,0008.511810,0001,00010,000,00053.718610010,0001,000,0130,000.000.231,00012.480710,00010,000100,000,00096.21,039跟踪标记和清除阶段虽然这是一个很好的高级视图,但我们可以使用更多细节。接下来探索一些用于内存分配、标记和清除的辅助函数,以获得下一级信息。这些辅助函数有参数或返回值,可以帮助我们更好地可视化正在发生的事情(例如分配的内存页)。函数描述捕获的信息allocSpan[8]分配新内存分配内存页gcDrainN[9]执行N个标记工作单元完成标记工作单元sweepone[10]清除跨度中的内存清除内存页$curl'127.0.0.1:8080/allocate-memory-and-run-gc?arrayLength=20000&bytesPerElement=4096'Generatedstringarraywith81920000bytesofdataRangarbagecollectorAfterhitthegarbagecollectorwithalargerload,herearetherawresults:调用垃圾收集器An过滤器后由allocSpan、gcDrainN和sweepone收集的事件示例被绘制为时间序列以便于解释:What:Go分配了几千个内存页,这是正常的,因为我们直接向堆中添加了大约80MB的字符串。标记工作开始了(注意它的单位不是页面,而是标记工作单元)。标记的内存页被清理器清除。(这应该是所有内存页,因为调用完成后我们不会重用字符串数组)。跟踪StopTheWorld事件“Stoppingtheworld”是指垃圾收集器为了安全地修改状态而临时停止除自身以外的所有内容。我们通常更喜欢最小化STW阶段,因为STW会减慢我们的程序(通常是在最不方便的时候......)。一些垃圾收集器在垃圾收集运行的整个过程中停止世界。这些是“非并发”垃圾收集器。虽然Go的垃圾收集器在很大程度上是并发的,但我们可以从代码中看出它在两个地方进行了技术上的STW。我们跟踪以下函数:函数描述stopTheWorldWithSema[11]停止其他goroutines直到调用startTheWorldWithSemastartTheWorldWithSema[12]启动暂停的goroutines再次触发GC:$curl'127.0.0.1:8080/allocate-memory-and-run-gc?arrayLength=10&bytesPerElement=20'Generatedstringarraywith200bytesofdataRan垃圾收集器这次产生了以下事件:生成开始和停止STW事件我们从GC事件中可以看出,垃圾收集需要3.1毫秒才能完成。在我检查了确切的时间戳后,发现STW第一次停止了300微秒,第二次停止了365微秒。换句话说,大约80%的垃圾回收是并发执行的。我们希望当垃圾收集器在实际内存压力下自动调用时,这个比率会变得更好。为什么Go垃圾收集器需要STW?1stStopTheWorld(标记阶段之前):设置状态并打开写屏障。写屏障确保在GC运行时正确跟踪新写入(因此它们不会被意外释放或保留)。2ndStopTheWorld(afterthemarkphase):清理标记状态,关闭写屏障。3.垃圾收集器如何调整自己的速度?知道何时运行垃圾收集是Go等并发垃圾收集器的重要考虑因素。早期的垃圾收集器被设计为在达到一定的内存消耗水平时启动。如果垃圾收集器是非并发的,这就可以正常工作。但是对于并发垃圾收集器,主程序在垃圾收集期间仍在运行——因此内存分配可能仍在进行。这意味着如果垃圾收集器运行得太晚,可能会超出内存目标。(Go也不能一直运行垃圾收集——GC会占用主应用程序的资源和性能。)Go的垃圾收集器使用pacer[13]来估计垃圾收集的最佳时间。这有助于Go在不牺牲不必要的应用程序性能的情况下满足其内存和CPU目标。pacer,可以理解为speeddevicetriggerrateGo的并发垃圾收集器依赖一个pacer来决定何时进行垃圾收集。但是它是如何做出这个决定的呢?每次调用垃圾收集器时,起搏器都会更新其下次运行GC的内部目标。这个目标被称为触发率。0.6的触发率意味着一旦堆大小增加60%,系统就应该运行垃圾回收。触发率是一个由CPU、内存和其他因素决定的数字。让我们看看当我们一次分配大量内存时,垃圾收集器的触发率是如何变化的。我们可以通过跟踪函数得到触发率gcSetTriggerRatio。$curl'127.0.0.1:8080/allocate-memory-and-run-gc?arrayLength=20000&bytesPerElement=4096'生成的字符串数组有81920000字节的dataRan垃圾收集器触发率随时间变化从图中可以看出,最初,触发率相当高。运行时已确定在程序使用450%或更多内存之前不需要垃圾回收。这是有道理的,因为应用程序没有做太多事情(并且没有使用大量堆)。然而,一旦我们请求端点进行约81MB的堆分配,触发率就会迅速下降到约1。如果我们将内存增加100%(因为我们的内存消耗增加),现在可以进行垃圾收集。MarkandSweepHelpers当分配了内存但没有调用垃圾收集器时会发生什么?接下来,请求/allocate-memory端点,它类似于/allocate-memory-and-gc但不调用runtime.GC()。$curl'127.0.0.1/allocate-memory?arrayLength=10000&bytesPerElement=10000'生成了包含100000000字节数据的字符串数组根据最近的触发率,垃圾收集器应该还没有启动。然而,我们看到标记和清除仍然发生:gcDrainmarksworkdoneovertimesweeponememorypagesclearedovertime事实证明,垃圾收集器还有另一个技巧来防止失控的内存增长。如果堆内存开始增长得太快,垃圾收集器将“征税”任何分配新内存的请求。请求新堆分配的Goroutine必须先协助垃圾收集,然后才能得到他们请求的东西。这种“辅助”系统增加了分配的延迟,从而帮助系统承受背压。这非常重要,因为它解决了并发垃圾收集器可能导致的问题。在并发垃圾收集器中,内存分配仍然在垃圾收集运行时进行。如果程序分配内存的速度快于垃圾收集器释放它的速度,那么内存增长将是无限的。通过减慢(背压)新内存的净分配来帮助解决这个问题。我们可以跟踪gcAssistAlloc1[14]以查看此过程的运行情况。gcAssistAlloc1接受一个名为scanWork的参数,它是请求的辅助工作量。可以看到gcAllocAssist1在一段时间内执行的辅助工作量,gcAssistAlloc1是markandsweep工作的来源。它收到了完成大约300,000个工作单元的请求。在前面的标记阶段图中,gcDrainN在同一时间段内完成了大约300,000个标记单元的工作(只是稍微展开一下)。4.总结关于Go中的内存分配和垃圾收集,还有很多东西需要学习!这里有一些其他的资源可以查看:Go的小对象特殊清理[15]查看对象是否在堆上分配或通过逃逸分析[16]Stacksync.Pool[17],一种并发数据结构,减少分配池共享对象[18]正如我们在本文的示例中所做的那样,创建uprobe通常最好在更高级别的BPF框架中完成。对于这篇文章,我使用了Pixie的DynamicGologging[19]功能(仍处于alpha阶段)。bpftrace[20]是另一个创建uprobe的好工具。检查Go垃圾收集器行为的另一个好选择是gc跟踪器。启动程序时只需传递GODEBUG=gctrace=1即可。这会输出有关垃圾收集器正在做什么的各种有用信息。原文链接:https://blog.px.dev/go-garbage-collector/。参考文献[1]此处:https://github.com/pixie-io/pixie-demos/tree/main/go-garbage-collector[2]uprobes:https://jvns.ca/blog/2017/07/05/linux-tracing-systems/#uprobes[3]GC:https://github.com/golang/go/blob/go1.16/src/runtime/mgc.go#L1126[4]gcWaitOnMark:https://github.com/golang/go/blob/go1.16/src/runtime/mgc.go#L1201[5]gcSweep:https://github.com/golang/go/blob/go1.16/src/runtime/mgc.go#L2170[6]代码:https://github.com/pixie-io/pixie-demos/tree/main/go-garbage-collector[7]来自源代码:https://github。com/golang/go/blob/go1.16/src/runtime/mgc.go#L1126[8]allocSpan:https://github.com/golang/go/blob/go1.16/src/runtime/mheap。go#L1124[9]gcDrainN:https://github.com/golang/go/blob/go1.16/src/runtime/mgcmark.go#L1095[10]sweepone:https://github.com/golang/go/blob/go1.16/src/runtime/mgcsweep.go#L188[11]stopTheWorldWithSema:https://github.com/golang/go/blob/go1.16/src/runtime/proc.go#L1073[12]startTheWorldWithSema:https://github.com/golang/go/blob/go1.16/src/runtime/proc.go#L1151[13]pacer:https://go.googlesource.com/proposal/+/a216b56e743c5b6b300b3ef1673ee62684b5b63b/design/44167-gc-pacer-redesign.md[14]gcAssistAlloc1:https://github.com/golang/go/blob/go1.16/src/runtime/mgcmark.go#L504[15]特别移除:https://github.com/golang/go/blob/master/src/runtime/mgc.go#L93[16]逃逸分析:https://medium.com/a-journey-with-go/go-introduction-to-the-escape-analysis-f7610174e890[17]sync.Pool:https://pkg.go。dev/sync#Pool[18]减少分配:https://medium.com/swlh/go-the-idea-behind-sync-pool-32da5089df72[19]动态Go日志记录:https://docs.px。dev/tutorials/custom-data/dynamic-go-logging/[20]bpftrace:https://github.com/iovisor/bpftrace本文转载自微信公众号“幽灵”,转载文章可通过以下二维码请联系Spectre公众号。