简单聊聊内存逃逸?

问题

:::info 知道golang的内存逃逸吗?什么情况下会发⽣内存逃逸?
:::

回答

Golang程序变量会携带有⼀组校验数据,用来证明它的整个⽣命周期是否在运⾏时完全 可知。如果变量通过了这些校验,它就可以在栈上分配。否则就说它 逃逸 了,必须在 堆上分配。 能引起变量逃逸到堆上的典型情况:

  • 在方法内把局部变量指针返回 局部变量原本应该在栈中分配,在栈中回收。但是由 于返回时被外部引用,因此其生命周期大于栈,则溢出。
  • 发送指针或带有指针的值到 channel 中。 在编译时,是没有办法知道哪 个 goroutine 会在 channel上接收数据。所以编译器没法知道变量什么时候才会被释放。
  • 在⼀个切⽚上存储指针或带指针的值。 ⼀个典型的例⼦就是 []*string 。这会导致 切⽚的内容逃逸。尽管其后⾯的数组可能是在栈上分配的,但其引用的值⼀定是在 堆上。
  • slice 的背后数组被重新分配了,因为 append 时可能会超出其容量( cap )。 slice 初始化的地方在编译时是可以知道的,它最开始会在栈上分配。如果切⽚背后的存 储要基于运⾏时的数据进⾏扩充,就会在堆上分配。
  • 在 interface 类型上调用方法。 在 interface 类型上调用方法都是动态调度的 —— 方法的真正实现只能在运⾏时知道。想像⼀个 io.Reader 类型的变量 r , 调用 r.Read(b) 会使得 r 的值和切⽚b 的背后存储都逃逸掉,所以会在堆上分配。

举例

通过⼀个例⼦加深理解,接下来尝试下怎么通过 go build -gcflags=-m 查看逃逸的情 况。

  1. package main
  2. import "fmt"
  3. type A struct {
  4. s string
  5. }
  6. // 这是上面提到的 "在方法内把局部变量指针返回" 的情况
  7. func foo(s string) *A {
  8. a := new(A)
  9. a.s = s
  10. return a //返回局部变量a,在C语言中妥妥野指针,但在go则ok,但a会逃逸到堆
  11. }
  12. func main() {
  13. a := foo("hello")
  14. b := a.s + " world"
  15. c := b + "!"
  16. fmt.Println(c)
  17. }

执⾏go build -gcflags=-m main.go

  1. # command-line-arguments
  2. .\main.go:10:6: can inline foo
  3. .\main.go:16:10: inlining call to foo
  4. .\main.go:19:13: inlining call to fmt.Println
  5. .\main.go:10:10: leaking param: s
  6. .\main.go:11:10: new(A) escapes to heap
  7. .\main.go:16:10: new(A) does not escape
  8. .\main.go:17:11: a.s + " world" does not escape
  9. .\main.go:18:9: b + "!" escapes to heap
  10. .\main.go:19:13: ... argument does not escape
  11. .\main.go:19:13: c escapes to heap
  • .\main.go:11:10: new(A) escapes to heap 说明 new(A) 逃逸了,符合上述提到的常⻅ 情况中的第⼀种。
  • .\main.go:17:11: a.s + " world" does not escape 说明 b 变量没有逃逸,因为它 只在⽅法内存在,会在⽅法结束时被回收。
  • .\main.go:18:9: b + "!" escapes to heap 说明 c 变量逃逸,通过 fmt.Println(a …interface{}) 打印的变量,都会发⽣逃逸,感兴趣的朋友可以去查查为什么。

以上操作其实就叫逃逸分析。下篇⽂章,跟⼤家聊聊怎么⽤⼀个⽐较trick的⽅法使变量 不逃逸。⽅便⼤家在⾯试官⾯前秀⼀波。
链接:

字符串转成byte数组,会发⽣内存拷⻉吗?

问题

字符串转成byte数组,会发⽣内存拷⻉吗?

回答

字符串转成切⽚,会产⽣拷⻉。严格来说,只要是发⽣类型强转都会发⽣内存拷⻉。那 么问题来了。 频繁的内存拷⻉操作听起来对性能不⼤友好。有没有什么办法可以在字符串转成切⽚的 时候不⽤发⽣拷⻉呢?

解释

  1. package main
  2. import (
  3. "fmt"
  4. "reflect"
  5. "unsafe"
  6. )
  7. func main() {
  8. a := "aaa"
  9. ssh := *(*reflect.StringHeader)(unsafe.Pointer(&a))
  10. b := *(*[]byte)(unsafe.Pointer(&ssh))
  11. fmt.Printf("%v", b)
  12. }

StringHeader 是字符串在go的底层结构。

  1. type StringHeader struct {
  2. Data uintptr
  3. Len int
  4. }

SliceHeader 是切⽚在go的底层结构。

  1. type SliceHeader struct {
  2. Data uintptr
  3. Len int
  4. Cap int
  5. }

那么如果想要在底层转换⼆者,只需要把 StringHeader 的地址强转成 SliceHeader 就
⾏。那么go有个很强的包叫 unsafe 。

  1. unsafe.Pointer(&a) ⽅法可以得到变量a的地址。
  2. (*reflect.StringHeader)(unsafe.Pointer(&a)) 可以把字符串a转成底层结构的形式。
  3. (*[]byte)(unsafe.Pointer(&ssh)) 可以把ssh底层结构体转成byte的切⽚的指针。
  4. 再通过 * 转为指针指向的实际内容。

CAS操作与ABA问题

我们知道使⽤cas操作需要特别注意ABA的问题,那么runqget函数这两个使⽤cas的地 ⽅会不会有问题呢?答案是这两个地⽅都不会有ABA的问题。原因分析如下: ⾸先来看对runnext的cas操作。只有跟p绑定的当前⼯作线程才会去修改runnext为⼀ 个⾮0值,其它线程只会把runnext的值从⼀个⾮0值修改为0值,然⽽跟p绑定的当前 ⼯作线程正在此处执⾏代码,所以在当前⼯作线程读取到值A之后,不可能有线程修改 其值为B(0)之后再修改回A。 再来看对runq的cas操作。当前⼯作线程操作的是p的本地队列,只有跟p绑定在⼀ 起的当前⼯作线程才会因为往该队列⾥⾯添加goroutine⽽去修改runqtail,⽽其它⼯作 线程不会往该队列⾥⾯添加goroutine,也就不会去修改runqtail,它们只会修改 runqhead,所以,当我们这个⼯作线程从runqhead读取到值A之后,其它⼯作线程也就 不可能修改runqhead的值为B之后再第⼆次把它修改为值A(因为runqtail在这段时间之 内不可能被修改,runqhead的值也就⽆法越过runqtail再回绕到A值),也就是说,代码 从逻辑上已经杜绝了引发ABA的条件。 到此,我们已经分析完⼯作线程从全局运⾏队列和本地运⾏队列获取goroutine的代 码,由于篇幅的限制,我们下⼀节再来分析从其它⼯作线程的运⾏队列偷取goroutine 的流程。

goroutine简介

goroutine是Go语⾔实现的⽤户态线程,主要⽤来解决操作系统线程太“重”的问题,所 谓的太重,主要表现在以下两个⽅⾯:

  • 创建和切换太重:操作系统线程的创建和切换都需要进⼊内核,⽽进⼊内核所消耗 的性能代价⽐较⾼,开销较⼤;
  • 内存使⽤太重:⼀⽅⾯,为了尽量避免极端情况下操作系统线程栈的溢出,内核在 创建操作系统线程时默认会为其分配⼀个较⼤的栈内存(虚拟地址空间,内核并不 会⼀开始就分配这么多的物理内存),然⽽在绝⼤多数情况下,系统线程远远⽤不 了这么多内存,这导致了浪费;另⼀⽅⾯,栈内存空间⼀旦创建和初始化完成之后 其⼤⼩就不能再有变化,这决定了在某些特殊场景下系统线程栈还是有溢出的⻛ 险。

    ⽽相对的,⽤户态的goroutine则轻量得多:

  • goroutine是⽤户态线程,其创建和切换都在⽤户代码中完成⽽⽆需进⼊操作系统内 核,所以其开销要远远⼩于系统线程的创建和切换;
  • goroutine启动时默认栈⼤⼩只有2k,这在多数情况下已经够⽤了,即使不够⽤, goroutine的栈也会⾃动扩⼤,同时,如果栈太⼤了过于浪费它还能⾃动收缩,这样 既没有栈溢出的⻛险,也不会造成栈内存空间的⼤量浪费。

正是因为Go语⾔中实现了如此轻量级的线程,才使得我们在Go程序中,可以轻易的创 建成千上万甚⾄上百万的goroutine出来并发的执⾏任务⽽不⽤太担⼼性能和内存等问 题。 注意: 为了避免混淆,从现在开始,后⾯出现的所有的线程⼀词均是指操作系统线程, ⽽goroutine我们不再称之为什么什么线程⽽是直接使⽤goroutine这个词。

线程模型与调度器

第⼀章讨论操作系统线程调度的时候我们曾经提到过,goroutine建⽴在操作系统线程 基础之上,它与操作系统线程之间实现了⼀个多对多(M:N)的两级线程模型。 这⾥的 M:N 是指M个goroutine运⾏在N个操作系统线程之上,内核负责对这N个操作系 统线程进⾏调度,⽽这N个系统线程⼜负责对这M个goroutine进⾏调度和运⾏。 所谓的对goroutine的调度,是指程序代码按照⼀定的算法在适当的时候挑选出合适的 goroutine并放到CPU上去运⾏的过程,这些负责对goroutine进⾏调度的程序代码我们 称之为goroutine调度器。⽤极度简化了的伪代码来描述goroutine调度器的⼯作流程⼤ 概是下⾯这个样⼦:

  1. // 程序启动时的初始化代码
  2. 2 ......
  3. 3 for i := 0; i < N; i++ { // 创建N个操作系统线程执行schedule函数
  4. 4 create_os_thread(schedule) // 创建一个操作系统线程执行schedule函数
  5. 5 }
  6. 67 //schedule函数实现调度逻辑
  7. 8 func schedule() {
  8. 9 for { //调度循环
  9. 10 // 根据某种算法从M个goroutine中找出一个需要运行的goroutine
  10. 11 g := find_a_runnable_goroutine_from_M_goroutines()
  11. 12 run_g(g) // CPU运行该goroutine,直到需要调度其它goroutine才返回
  12. 13 save_status_of_g(g) // 保存goroutine的状态,主要是寄存器的值
  13. 14 }
  14. 15 }

:::success 这段伪代码表达的意思是,程序运⾏起来之后创建了N个由内核调度的操作系统线程 (为了⽅便描述,我们称这些系统线程为⼯作线程)去执⾏shedule函数,⽽schedule 函数在⼀个调度循环中反复从M个goroutine中挑选出⼀个需要运⾏的goroutine并跳转 到该goroutine去运⾏,直到需要调度其它goroutine时才返回到schedule函数中通过 save_status_of _g保存刚刚正在运⾏的goroutine的状态然后再次去寻找下⼀个 goroutine。 需要强调的是,这段伪代码对goroutine的调度代码做了⾼度的抽象、修改和简化处 理,放在这⾥只是为了帮助我们从宏观上了解goroutine的两级调度模型,具体的实现 原理和细节将从本章开始进⾏全⾯介绍。
:::

重要的结构体

:::tips 下⾯介绍的这些结构体中的字段⾮常多,牵涉到的细节也很庞杂,光是看这些结构体的 定义我们没有必要也⽆法真正理解它们的⽤途,所以在这⾥我们只需要⼤概了解⼀下就 ⾏了,看不懂记不住都没有关系,随着后⾯对代码逐步深⼊的分析,我们也必将会对这 些结构体有越来越清晰的认识。为了节省篇幅,下⾯各结构体的定义略去了跟调度器⽆ 关的成员。另外,这些结构体的定义全部位于Go语⾔的源代码路径下的runtime/runtim e2.go⽂件之中。
:::

stack结构体

stack结构体主要⽤来记录goroutine所使⽤的栈的信息,包括栈顶和栈底位置:

  1. // Stack describes a Go execution stack.
  2. // The bounds of the stack are exactly [lo, hi),
  3. // with no implicit data structures on either side.
  4. // 用于记录goroutine使用的栈的起始和结束位置
  5. type stack struct {
  6. lo uintptr // 栈顶,指向内存低地址
  7. hi uintptr // 栈底,指向内存高地址
  8. }

gobuf结构体

gobuf结构体⽤于保存goroutine的调度信息,主要包括CPU的⼏个寄存器的值:

  1. type gobuf struct {
  2. // The offsets of sp, pc, and g are known to (hard-coded in) libmach.
  3. //
  4. // ctxt is unusual with respect to GC: it may be a
  5. // heap-allocated funcval, so GC needs to track it, but it
  6. // needs to be set and cleared from assembly, where it's
  7. // difficult to have write barriers. However, ctxt is really a
  8. // saved, live register, and we only ever exchange it between
  9. // the real register and the gobuf. Hence, we treat it as a
  10. // root during stack scanning, which means assembly that saves
  11. // and restores it doesn't need write barriers. It's still
  12. // typed as a pointer so that any other writes from Go get
  13. // write barriers.
  14. sp uintptr // 保存CPU的rsp寄存器的值
  15. pc uintptr // 保存CPU的rip寄存器的值
  16. g guintptr // 记录当前这个gobuf对象属于哪个goroutine
  17. ctxt unsafe.Pointer
  18. // 保存系统调用的返回值,因为从系统调用返回之后如果p被其它工作线程抢占,
  19. // 则这个goroutine会被放入全局运行队列被其它工作线程调度,其它线程需要知道系统
  20. //调用的返回值。
  21. ret sys.Uintreg
  22. lr uintptr
  23. // 保存CPU的rip寄存器的值
  24. bp uintptr // for GOEXPERIMENT=framepointer
  25. }

g结构体

g结构体⽤于代表⼀个goroutine,该结构体保存了goroutine的所有信息,包括栈, gobuf结构体和其它的⼀些状态信息:

  1. // 前文所说的g结构体,它代表了一个goroutine
  2. type g struct {
  3. // Stack parameters.
  4. // stack describes the actual stack memory: [stack.lo, stack.hi).
  5. // stackguard0 is the stack pointer compared in the Go stack growth
  6. //prologue.
  7. // It is stack.lo+StackGuard normally, but can be StackPreempt to
  8. //trigger a preemption.
  9. // stackguard1 is the stack pointer compared in the C stack growth
  10. //prologue.
  11. // It is stack.lo+StackGuard on g0 and gsignal stacks.
  12. // It is ~0 on other goroutine stacks, to trigger a call to morestackc
  13. //(and crash).
  14. // 记录该goroutine使用的栈
  15. stack stack // offset known to runtime/cgo
  16. // 下面两个成员用于栈溢出检查,实现栈的自动伸缩,抢占调度也会用到stackguard0
  17. stackguard0 uintptr // offset known to liblink
  18. stackguard1 uintptr // offset known to liblink
  19. ......
  20. // 此goroutine正在被哪个工作线程执行
  21. m *m // current m; offset known to arm liblink
  22. // 保存调度信息,主要是几个寄存器的值
  23. sched gobuf
  24. ......
  25. // schedlink字段指向全局运行队列中的下一个g,
  26. //所有位于全局运行队列中的g形成一个链表
  27. schedlink guintptr
  28. ......
  29. // 抢占调度标志,如果需要抢占调度,设置preempt为true
  30. preempt bool // preemption signal, duplicates stackguard0
  31. = stackpreempt
  32. ......
  33. }