简单聊聊内存逃逸?
问题
:::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
查看逃逸的情 况。
package main
import "fmt"
type A struct {
s string
}
// 这是上面提到的 "在方法内把局部变量指针返回" 的情况
func foo(s string) *A {
a := new(A)
a.s = s
return a //返回局部变量a,在C语言中妥妥野指针,但在go则ok,但a会逃逸到堆
}
func main() {
a := foo("hello")
b := a.s + " world"
c := b + "!"
fmt.Println(c)
}
执⾏go build -gcflags=-m main.go
# command-line-arguments
.\main.go:10:6: can inline foo
.\main.go:16:10: inlining call to foo
.\main.go:19:13: inlining call to fmt.Println
.\main.go:10:10: leaking param: s
.\main.go:11:10: new(A) escapes to heap
.\main.go:16:10: new(A) does not escape
.\main.go:17:11: a.s + " world" does not escape
.\main.go:18:9: b + "!" escapes to heap
.\main.go:19:13: ... argument does not escape
.\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数组,会发⽣内存拷⻉吗?
问题
回答
字符串转成切⽚,会产⽣拷⻉。严格来说,只要是发⽣类型强转都会发⽣内存拷⻉。那 么问题来了。 频繁的内存拷⻉操作听起来对性能不⼤友好。有没有什么办法可以在字符串转成切⽚的 时候不⽤发⽣拷⻉呢?
解释
package main
import (
"fmt"
"reflect"
"unsafe"
)
func main() {
a := "aaa"
ssh := *(*reflect.StringHeader)(unsafe.Pointer(&a))
b := *(*[]byte)(unsafe.Pointer(&ssh))
fmt.Printf("%v", b)
}
StringHeader
是字符串在go的底层结构。
type StringHeader struct {
Data uintptr
Len int
}
SliceHeader
是切⽚在go的底层结构。
type SliceHeader struct {
Data uintptr
Len int
Cap int
}
那么如果想要在底层转换⼆者,只需要把 StringHeader 的地址强转成 SliceHeader 就
⾏。那么go有个很强的包叫 unsafe 。
- unsafe.Pointer(&a) ⽅法可以得到变量a的地址。
- (*reflect.StringHeader)(unsafe.Pointer(&a)) 可以把字符串a转成底层结构的形式。
- (*[]byte)(unsafe.Pointer(&ssh)) 可以把ssh底层结构体转成byte的切⽚的指针。
- 再通过 * 转为指针指向的实际内容。
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调度器的⼯作流程⼤ 概是下⾯这个样⼦:
// 程序启动时的初始化代码
2 ......
3 for i := 0; i < N; i++ { // 创建N个操作系统线程执行schedule函数
4 create_os_thread(schedule) // 创建一个操作系统线程执行schedule函数
5 }
67 //schedule函数实现调度逻辑
8 func schedule() {
9 for { //调度循环
10 // 根据某种算法从M个goroutine中找出一个需要运行的goroutine
11 g := find_a_runnable_goroutine_from_M_goroutines()
12 run_g(g) // CPU运行该goroutine,直到需要调度其它goroutine才返回
13 save_status_of_g(g) // 保存goroutine的状态,主要是寄存器的值
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所使⽤的栈的信息,包括栈顶和栈底位置:
// Stack describes a Go execution stack.
// The bounds of the stack are exactly [lo, hi),
// with no implicit data structures on either side.
// 用于记录goroutine使用的栈的起始和结束位置
type stack struct {
lo uintptr // 栈顶,指向内存低地址
hi uintptr // 栈底,指向内存高地址
}
gobuf结构体
gobuf结构体⽤于保存goroutine的调度信息,主要包括CPU的⼏个寄存器的值:
type gobuf struct {
// The offsets of sp, pc, and g are known to (hard-coded in) libmach.
//
// ctxt is unusual with respect to GC: it may be a
// heap-allocated funcval, so GC needs to track it, but it
// needs to be set and cleared from assembly, where it's
// difficult to have write barriers. However, ctxt is really a
// saved, live register, and we only ever exchange it between
// the real register and the gobuf. Hence, we treat it as a
// root during stack scanning, which means assembly that saves
// and restores it doesn't need write barriers. It's still
// typed as a pointer so that any other writes from Go get
// write barriers.
sp uintptr // 保存CPU的rsp寄存器的值
pc uintptr // 保存CPU的rip寄存器的值
g guintptr // 记录当前这个gobuf对象属于哪个goroutine
ctxt unsafe.Pointer
// 保存系统调用的返回值,因为从系统调用返回之后如果p被其它工作线程抢占,
// 则这个goroutine会被放入全局运行队列被其它工作线程调度,其它线程需要知道系统
//调用的返回值。
ret sys.Uintreg
lr uintptr
// 保存CPU的rip寄存器的值
bp uintptr // for GOEXPERIMENT=framepointer
}
g结构体
g结构体⽤于代表⼀个goroutine,该结构体保存了goroutine的所有信息,包括栈, gobuf结构体和其它的⼀些状态信息:
// 前文所说的g结构体,它代表了一个goroutine
type g struct {
// Stack parameters.
// stack describes the actual stack memory: [stack.lo, stack.hi).
// stackguard0 is the stack pointer compared in the Go stack growth
//prologue.
// It is stack.lo+StackGuard normally, but can be StackPreempt to
//trigger a preemption.
// stackguard1 is the stack pointer compared in the C stack growth
//prologue.
// It is stack.lo+StackGuard on g0 and gsignal stacks.
// It is ~0 on other goroutine stacks, to trigger a call to morestackc
//(and crash).
// 记录该goroutine使用的栈
stack stack // offset known to runtime/cgo
// 下面两个成员用于栈溢出检查,实现栈的自动伸缩,抢占调度也会用到stackguard0
stackguard0 uintptr // offset known to liblink
stackguard1 uintptr // offset known to liblink
......
// 此goroutine正在被哪个工作线程执行
m *m // current m; offset known to arm liblink
// 保存调度信息,主要是几个寄存器的值
sched gobuf
......
// schedlink字段指向全局运行队列中的下一个g,
//所有位于全局运行队列中的g形成一个链表
schedlink guintptr
......
// 抢占调度标志,如果需要抢占调度,设置preempt为true
preempt bool // preemption signal, duplicates stackguard0
= stackpreempt
......
}