diff --git a/Go Channel.md b/Go Channel.md new file mode 100644 index 0000000..5f616e2 --- /dev/null +++ b/Go Channel.md @@ -0,0 +1,397 @@ +# Go Channel + +[toc] + +## hchan 数据结构 + +``` +// channel 在 runtime 中的结构体 +type hchan struct { + // 队列中目前的元素计数 + qcount uint // total data in the queue + // 环形队列的总大小,ch := make(chan int, 10) => 就是这里这个 10 + dataqsiz uint // size of the circular queue + // void * 的内存 buffer 区域 + buf unsafe.Pointer // points to an array of dataqsiz elements + + // sizeof chan 中的数据 + elemsize uint16 + // runtime._type,代表 channel 中的元素类型的 runtime 结构体 + elemtype *_type // element type + + // 是否已被关闭 + closed uint32 + // 发送索引 + sendx uint // send index + // 接收索引 + recvx uint // receive index + + // 接收 goroutine 对应的 sudog 队列 + recvq waitq // list of recv waiters + // 发送 goroutine 对应的 sudog 队列 + sendq waitq // list of send waiters + + + lock mutex +} + +``` + +## 初始化 + +- 如果当前 Channel 中不存在缓冲区,那么就只会为 hchan 分配一段内存空间; +- 如果当前 Channel 中存储的类型不是指针类型,就会直接为当前的 Channel 和底层的数组分配一块连续的内存空间; +- 在默认情况下会单独为 hchan 和缓冲区分配内存; + +``` +func makechan(t *chantype, size int) *hchan { + elem := t.elem + + // 如果 hchan 中的元素不包含有指针,那么就没什么和 GC 相关的信息了 + var c *hchan + + switch { + case size == 0 || elem.size == 0: + // 如果 channel 的缓冲区大小是 0: var a = make(chan int) + // 或者 channel 中的元素大小是 0: struct{}{} + // Queue or element size is zero. + c = (*hchan)(mallocgc(hchanSize, nil, true)) + // Race detector uses this location for synchronization. + c.buf = unsafe.Pointer(c) + case elem.kind&kindNoPointers != 0: + // Elements do not contain pointers. + // Allocate hchan and buf in one call. + // 通过位运算知道 channel 中的元素不包含指针 + // 占用的空间比较容易计算 + // 直接用 元素数*元素大小 + channel 必须的空间就行了 + // 这种情况下 gc 不会对 channel 中的元素进行 scan + c = (*hchan)(mallocgc(hchanSize+uintptr(size)*elem.size, nil, true)) + c.buf = add(unsafe.Pointer(c), hchanSize) + default: + // Elements contain pointers. + // 和上面那个 case 的写法的区别:调用了两次分配空间的函数 new/mallocgc + c = new(hchan) + c.buf = mallocgc(uintptr(size)*elem.size, elem, true) + } + + c.elemsize = uint16(elem.size) + c.elemtype = elem + c.dataqsiz = uint(size) + + return c +} + +``` + +## send 发送 + +- 直接发送:如果目标 Channel 没有被关闭并且已经有处于读等待的 Goroutine,那么chansend 函数会通过 dequeue 从 recvq 中取出最先陷入等待的 Goroutine 并直接向它发送数据; +- 缓冲区:向 Channel 中发送数据时遇到的第二种情况就是创建的 Channel 包含缓冲区并且 Channel 中的数据没有装满. 在这里我们首先会使用 chanbuf 计算出下一个可以放置待处理变量的位置,然后通过 typedmemmove 将发送的消息拷贝到缓冲区中并增加 sendx 索引和 qcount 计数器,在函数的最后会释放持有的锁。 +- 阻塞发送: + - 调用 getg 获取发送操作时使用的 Goroutine 协程; + - 执行 acquireSudog 函数获取一个 sudog 结构体并设置这一次阻塞发送的相关信息,例如发送的 Channel、是否在 Select 控制结构中、发送数据所在的地址等; + - 将刚刚创建并初始化的 sudog 结构体加入 sendq 等待队列,并设置到当前 Goroutine 的 waiting 上,表示 Goroutine 正在等待该 sudog 准备就绪; + - 调用 goparkunlock 函数将当前的 Goroutine 更新成 Gwaiting 状态并解锁,该 Goroutine 可以被调用 goready 再次唤醒; + +``` +func chansend(c *hchan, ep unsafe.Pointer, block bool, callerpc uintptr) bool { + lock(&c.lock) + + if sg := c.recvq.dequeue(); sg != nil { + // 寻找一个等待中的 receiver + // 越过 channel 的 buffer + // 直接把要发的数据拷贝给这个 receiver + // 然后就返 + send(c, sg, ep, func() { unlock(&c.lock) }, 3) + return true + } + + // qcount 是 buffer 中已塞进的元素数量 + // dataqsize 是 buffer 的总大小 + // 说明还有余量 + if c.qcount < c.dataqsiz { + // Space is available in the channel buffer. Enqueue the element to send. + qp := chanbuf(c, c.sendx) + + // 将 goroutine 的数据拷贝到 buffer 中 + typedmemmove(c.elemtype, qp, ep) + c.sendx++ + + // 环形队列,所以如果已经加到最大了,就回 0 + if c.sendx == c.dataqsiz { + c.sendx = 0 + } + + // 将 buffer 的元素计数 +1 + c.qcount++ + unlock(&c.lock) + return true + } + + // 在 channel 上阻塞,receiver 会帮我们完成后续的工作 + gp := getg() + mysg := acquireSudog() + mysg.releasetime = 0 + + // 打包 sudog + mysg.elem = ep + mysg.waitlink = nil + mysg.g = gp + mysg.isSelect = false + mysg.c = c + gp.waiting = mysg + gp.param = nil + + // 将当前这个发送 goroutine 打包后的 sudog 入队到 channel 的 sendq 队列中 + c.sendq.enqueue(mysg) + + // 将这个发送 g 从 Grunning -> Gwaiting + // 进入休眠 + goparkunlock(&c.lock, waitReasonChanSend, traceEvGoBlockSend, 3) + + // 这里是被唤醒后要执行的代码 + KeepAlive(ep) + + gp.waiting = nil + gp.param = nil + + mysg.c = nil + releaseSudog(mysg) + return true +} + +func send(c *hchan, sg *sudog, ep unsafe.Pointer, unlockf func(), skip int) { + // receiver 的 sudog 已经在对应区域分配过空间 + // 我们只要把数据拷贝过去 + if sg.elem != nil { + sendDirect(c.elemtype, sg, ep) + sg.elem = nil + } + gp := sg.g + unlockf() + gp.param = unsafe.Pointer(sg) + + // Gwaiting -> Grunnable + goready(gp, skip+1) +} + +func sendDirect(t *_type, sg *sudog, src unsafe.Pointer) { + // src is on our stack, dst is a slot on another stack. + + // Once we read sg.elem out of sg, it will no longer + // be updated if the destination's stack gets copied (shrunk). + // So make sure that no preemption points can happen between read & use. + dst := sg.elem + typeBitsBulkBarrier(t, uintptr(dst), uintptr(src), t.size) + // No need for cgo write barrier checks because dst is always + // Go memory. + memmove(dst, src, t.size) +} +``` + +## receive 接收 + +- 直接接收:当 Channel 的 sendq 队列中包含处于等待状态的 Goroutine 时,我们其实就会直接取出队列头的 Goroutine,这里处理的逻辑和发送时所差无几,只是发送数据时调用的是 send 函数,而这里是 recv 函数 +- 缓冲区:另一种接收数据时遇到的情况就是,Channel 的缓冲区中已经包含了一些元素,在这时如果使用 <-ch 从 Channel 中接收元素,我们就会直接从缓冲区中 recvx 的索引位置中取出数据进行处理 +- 阻塞接收:当 Channel 的 sendq 队列中不存在等待的 Goroutine 并且缓冲区中也不存在任何数据时,从管道中接收数据的操作在大多数时候就会变成一个阻塞的操作. + +``` +func chanrecv1(c *hchan, elem unsafe.Pointer) { + chanrecv(c, elem, true) +} + +//go:nosplit +func chanrecv2(c *hchan, elem unsafe.Pointer) (received bool) { + _, received = chanrecv(c, elem, true) + return +} + + +func chanrecv(c *hchan, ep unsafe.Pointer, block bool) (selected, received bool) { + lock(&c.lock) + + // sender 队列中有 sudog 在等待 + // 直接从该 sudog 中获取数据拷贝到当前 g 即可 + if sg := c.sendq.dequeue(); sg != nil { + recv(c, sg, ep, func() { unlock(&c.lock) }, 3) + return true, true + } + + if c.qcount > 0 { + // Receive directly from queue + qp := chanbuf(c, c.recvx) + + // 直接从 buffer 里拷贝数据 + if ep != nil { + typedmemmove(c.elemtype, ep, qp) + } + typedmemclr(c.elemtype, qp) + // 接收索引 +1 + c.recvx++ + if c.recvx == c.dataqsiz { + c.recvx = 0 + } + // buffer 元素计数 -1 + c.qcount-- + unlock(&c.lock) + return true, true + } + + // no sender available: block on this channel. + gp := getg() + mysg := acquireSudog() + mysg.releasetime = 0 + if t0 != 0 { + mysg.releasetime = -1 + } + // No stack splits between assigning elem and enqueuing mysg + // on gp.waiting where copystack can find it. + // 打包成 sudog + mysg.elem = ep + mysg.waitlink = nil + gp.waiting = mysg + mysg.g = gp + mysg.isSelect = false + mysg.c = c + gp.param = nil + // 进入 recvq 队列 + c.recvq.enqueue(mysg) + + // Grunning -> Gwaiting + goparkunlock(&c.lock, "chan receive", traceEvGoBlockRecv, 3) + + // someone woke us up + // 被唤醒 + if mysg != gp.waiting { + throw("G waiting list is corrupted") + } + gp.waiting = nil + if mysg.releasetime > 0 { + blockevent(mysg.releasetime-t0, 2) + } + closed := gp.param == nil + gp.param = nil + mysg.c = nil + releaseSudog(mysg) + // 如果 channel 未被关闭,那就是真的 recv 到数据了 + return true, !closed +} + + +func recv(c *hchan, sg *sudog, ep unsafe.Pointer, unlockf func(), skip int) { + if c.dataqsiz == 0 { + if ep != nil { + // copy data from sender + recvDirect(c.elemtype, sg, ep) + } + } else { + // Queue is full. Take the item at the + // head of the queue. Make the sender enqueue + // its item at the tail of the queue. Since the + // queue is full, those are both the same slot. + qp := chanbuf(c, c.recvx) + + // copy data from queue to receiver + if ep != nil { + typedmemmove(c.elemtype, ep, qp) + } + + // 虽然数据已经给了接受者,但是还是要在 chan 中记录一下 + typedmemmove(c.elemtype, qp, sg.elem) + c.recvx++ + if c.recvx == c.dataqsiz { + c.recvx = 0 + } + c.sendx = c.recvx // c.sendx = (c.sendx+1) % c.dataqsiz + } + sg.elem = nil + gp := sg.g + unlockf() + gp.param = unsafe.Pointer(sg) + if sg.releasetime != 0 { + sg.releasetime = cputicks() + } + + // Gwaiting -> Grunnable + goready(gp, skip+1) +} + + +``` + +## close 关闭 + +在函数执行的最后会为所有被阻塞的 Goroutine 调用 goready 函数重新对这些协程进行调度. + +``` +func closechan(c *hchan) { + // 上锁,这个锁的粒度比较大,一直到释放完所有的 sudog 才解锁 + lock(&c.lock) + + c.closed = 1 + + var glist *g + + // release all readers + for { + sg := c.recvq.dequeue() + // 弹出的 sudog 是 nil + // 说明读队列已经空了 + if sg == nil { + break + } + + // sg.elem unsafe.Pointer,指向 sudog 的数据元素 + // 该元素可能在堆上分配,也可能在栈上 + if sg.elem != nil { + // 释放对应的内存 + typedmemclr(c.elemtype, sg.elem) + sg.elem = nil + } + if sg.releasetime != 0 { + sg.releasetime = cputicks() + } + + // 将 goroutine 入 glist + // 为最后将全部 goroutine 都 ready 做准备 + gp := sg.g + gp.param = nil + gp.schedlink.set(glist) + glist = gp + } + + // release all writers (they will panic) + // 将所有挂在 channel 上的 writer 从 sendq 中弹出 + // 该操作会使所有 writer panic + for { + sg := c.sendq.dequeue() + if sg == nil { + break + } + sg.elem = nil + if sg.releasetime != 0 { + sg.releasetime = cputicks() + } + + // 将 goroutine 入 glist + // 为最后将全部 goroutine 都 ready 做准备 + gp := sg.g + gp.param = nil + gp.schedlink.set(glist) + glist = gp + } + + // 在释放所有挂在 channel 上的读或写 sudog 时 + // 是一直在临界区的 + unlock(&c.lock) + + // Ready all Gs now that we've dropped the channel lock. + for glist != nil { + gp := glist + glist = glist.schedlink.ptr() + gp.schedlink = 0 + // 使 g 的状态切换到 Grunnable + goready(gp, 3) + } +} +``` diff --git a/Go Defer.md b/Go Defer.md new file mode 100644 index 0000000..ec923d1 --- /dev/null +++ b/Go Defer.md @@ -0,0 +1,307 @@ +# Go Defer + +## 用法 + +### 常见使用 + +首先要介绍的就是使用 defer 最常见的场景,也就是在 defer 关键字中完成一些收尾的工作,例如在 defer 中回滚一个数据库的事务: + +``` +func createPost(db *gorm.DB) error { + tx := db.Begin() + defer tx.Rollback() + + if err := tx.Create(&Post{Author: "Draveness"}).Error; err != nil { + return err + } + + return tx.Commit().Error +} +``` + +在使用数据库事务时,我们其实可以使用如上所示的代码在创建事务之后就立刻调用 Rollback 保证事务一定会回滚,哪怕事务真的执行成功了,那么在调用 tx.Commit() 之后再执行 tx.Rollback() 其实也不会影响已经提交的事务。 + +### 作用域 + +当我们在一个 for 循环中使用 defer 时也会在退出函数之前执行其中的代码,下面的代码总共调用了五次 defer 关键字: + +``` +func main() { + for i := 0; i < 5; i++ { + defer fmt.Println(i) + } +} + +$ go run main.go +4 +3 +2 +1 +0 + +``` + +### 传值 + +Go 语言中所有的函数调用其实都是值传递的,defer 虽然是一个关键字,但是也继承了这个特性,假设我们有以下的代码,在运行这段代码时会打印出 0: + +``` +type Test struct { + value int +} + +func (t Test) print() { + println(t.value) +} + +func main() { + test := Test{} + defer test.print() + test.value += 1 +} + +$ go run main.go +0 + +``` + +这其实表明当 defer 调用时其实会对函数中引用的外部参数进行拷贝,所以 test.value += 1 操作并没有修改被 defer 捕获的 test 结构体,不过如果我们修改 print 函数签名的话,其实结果就会稍有不同: + +``` +type Test struct { + value int +} + +func (t *Test) print() { + println(t.value) +} + +func main() { + test := Test{} + defer test.print() + test.value += 1 +} + +$ go run main.go +1 + +``` + +### defer 调用时机 + +``` +func f() (result int) { + defer func() { + result++ + }() + + return 0 +} + +func f() (r int) { + t := 5 + defer func() { + t=t+5 + }() + + return t +} + +func f() (r int) { + defer func(r int) { + r=r+5 + }(r) + + return 1 +} +``` + +使用defer时,用一个简单的转换规则改写一下,就不会迷糊了。改写规则是将return语句拆成两句写,return xxx会被改 写成: + +``` +返回值 = xxx +调用defer函数 +空的return + +``` + +先看例1,它可以改写成这样: + +``` +func f() (result int) { + result = 0 //return语句不是一条原子调用,return xxx其实是赋值+ret指令 + func() { //defer被插入到return之前执行,也就是赋返回值和ret指令之间 + result++ + }() + + return +} +``` + +所以这个返回值是1。 + +再看例2,它可以改写成这样: + +``` +func f() (r int) { + t := 5 + r = t + func() { + t=t+5 + }() + + return +} + +``` + +所以这个的结果是5。 + +最后看例3,它改写后变成: + +``` +func f() (r int) { + r = 1 + func(r int) { + r=r+5 + }(r) + + return 1 +} +``` + +所以这个例子的结果是1。 + +## 数据结构 + +``` +type _defer struct { + siz int32 + started bool + sp uintptr + pc uintptr + fn *funcval + _panic *_panic + link *_defer +} +``` + +在 _defer 结构中的 sp 和 pc 分别指向了栈指针和调用方的程序计数器,fn 存储的就是向 defer 关键字中传入的函数了。 + +## 原理 + +在 Go 语言的编译期间,编译器不仅将 defer 转换成了 deferproc 的函数调用,还在所有调用 defer 的函数结尾(返回之前)插入了 deferreturn。 + +每一个 defer 关键字都会被转换成 deferproc,在这个函数中我们会为 defer 创建一个新的 _defer 结构体并设置它的 fn、pc 和 sp 参数,除此之外我们会将 defer 相关的函数都拷贝到紧挨着结构体的内存空间中: + +``` +func deferproc(siz int32, fn *funcval) { + sp := getcallersp() + argp := uintptr(unsafe.Pointer(&fn)) + unsafe.Sizeof(fn) + callerpc := getcallerpc() + + d := newdefer(siz) + if d._panic != nil { + throw("deferproc: d.panic != nil after newdefer") + } + d.fn = fn + d.pc = callerpc + d.sp = sp + switch siz { + case 0: + case sys.PtrSize: + *(*uintptr)(deferArgs(d)) = *(*uintptr)(unsafe.Pointer(argp)) + default: + memmove(deferArgs(d), unsafe.Pointer(argp), uintptr(siz)) + } + + return0() +} + +``` + +上述函数最终会使用 return0 返回,这个函数的主要作用就是避免在 deferproc 函数中使用 return 返回时又会导致 deferreturn 函数的执行,这也是唯一一个不会触发 defer 的函数了。 + +deferproc 中调用的 newdefer 主要作用就是初始化或者取出一个新的 _defer 结构体: + +``` +func newdefer(siz int32) *_defer { + var d *_defer + sc := deferclass(uintptr(siz)) + gp := getg() + if sc < uintptr(len(p{}.deferpool)) { + pp := gp.m.p.ptr() + if len(pp.deferpool[sc]) == 0 && sched.deferpool[sc] != nil { + lock(&sched.deferlock) + for len(pp.deferpool[sc]) < cap(pp.deferpool[sc])/2 && sched.deferpool[sc] != nil { + d := sched.deferpool[sc] + sched.deferpool[sc] = d.link + d.link = nil + pp.deferpool[sc] = append(pp.deferpool[sc], d) + } + unlock(&sched.deferlock) + } + if n := len(pp.deferpool[sc]); n > 0 { + d = pp.deferpool[sc][n-1] + pp.deferpool[sc][n-1] = nil + pp.deferpool[sc] = pp.deferpool[sc][:n-1] + } + } + if d == nil { + total := roundupsize(totaldefersize(uintptr(siz))) + d = (*_defer)(mallocgc(total, deferType, true)) + } + d.siz = siz + d.link = gp._defer + gp._defer = d + return d +} + +``` + +从最后的一小段代码我们可以看出,所有的 `_defer` 结构体都会关联到所在的 Goroutine 上并且每创建一个新的 `_defer` 都会追加到协程持有的 `_defer` 链表的最前面。 + +deferreturn 其实会从 Goroutine 的链表中取出链表最前面的 _defer 结构体并调用 jmpdefer 函数并传入需要执行的函数和参数: + +``` +func deferreturn(arg0 uintptr) { + gp := getg() + d := gp._defer + if d == nil { + return + } + sp := getcallersp() + + switch d.siz { + case 0: + case sys.PtrSize: + *(*uintptr)(unsafe.Pointer(&arg0)) = *(*uintptr)(deferArgs(d)) + default: + memmove(unsafe.Pointer(&arg0), deferArgs(d), uintptr(d.siz)) + } + fn := d.fn + d.fn = nil + gp._defer = d.link + freedefer(d) + jmpdefer(fn, uintptr(unsafe.Pointer(&arg0))) +} + +``` + +jmpdefer 其实是一个用汇编语言实现的函数,在不同的处理器架构上的实现稍有不同,但是具体的执行逻辑都差不太多,它们的工作其实就是跳转到并执行 defer 所在的代码段并在执行结束之后跳转回 defereturn 函数。 + +``` +TEXT runtime·jmpdefer(SB), NOSPLIT, $0-8 + MOVL fv+0(FP), DX // fn + MOVL argp+4(FP), BX // caller sp + LEAL -4(BX), SP // caller sp after CALL +#ifdef GOBUILDMODE_shared + SUBL $16, (SP) // return to CALL again +#else + SUBL $5, (SP) // return to CALL again +#endif + MOVL 0(DX), BX + JMP BX // but first run the deferred function + +``` + diff --git a/Go Map.md b/Go Map.md new file mode 100644 index 0000000..7478e5d --- /dev/null +++ b/Go Map.md @@ -0,0 +1,631 @@ +# Go Map + +[TOC] + +## 数据结构 + +### hmap + +``` +// A header for a Go map. +type hmap struct { + count int // map 中的元素个数,必须放在 struct 的第一个位置,因为 内置的 len 函数会从这里读取 + flags uint8 + B uint8 // log_2 of # of buckets (最多可以放 loadFactor * 2^B 个元素,再多就要 hashGrow 了) + noverflow uint16 // overflow 的 bucket 的近似数 + hash0 uint32 // hash seed + + buckets unsafe.Pointer // 2^B 大小的数组,如果 count == 0 的话,可能是 nil + oldbuckets unsafe.Pointer // 一半大小的之前的 bucket 数组,只有在 growing 过程中是非 nil + nevacuate uintptr // progress counter for evacuation (buckets less than this have been evacuated) + + extra *mapextra // 当 key 和 value 都可以 inline 的时候,就会用这个字段 +} + +type mapextra struct { + // 如果 key 和 value 都不包含指针,并且可以被 inline(<=128 字节) + // 使用 extra 来存储 overflow bucket,这样可以避免 GC 扫描整个 map + // 然而 bmap.overflow 也是个指针。这时候我们只能把这些 overflow 的指针 + // 都放在 hmap.extra.overflow 和 hmap.extra.oldoverflow 中了 + // overflow 包含的是 hmap.buckets 的 overflow 的 bucket + // oldoverflow 包含扩容时的 hmap.oldbuckets 的 overflow 的 bucket + overflow *[]*bmap + oldoverflow *[]*bmap + + // 指向空闲的 overflow bucket 的指针 + nextOverflow *bmap +} +``` + +- count 用于记录当前哈希表元素数量,这个字段让我们不再需要去遍历整个哈希表来获取长度; +- B 表示了当前哈希表持有的 buckets 数量,但是因为哈希表的扩容是以 2 倍数进行的,所以这里会使用对数来存储,我们可以简单理解成 len(buckets) == 2^B; +- hash0 是哈希的种子,这个值会在调用哈希函数的时候作为参数传进去,它的主要作用就是为哈希函数的结果引入一定的随机性; +- oldbuckets 是哈希在扩容时用于保存之前 buckets 的字段,它的大小都是当前 buckets 的一半; + +### bmap + +哈希表的类型其实都存储在每一个桶中,这个桶的结构体 bmap 其实在 Go 语言源代码中的定义只包含一个简单的 tophash 字段: + +``` +type bmap struct { + tophash [bucketCnt]uint8 +} +``` + +哈希表中桶的真正结构其实是在编译期间运行的函数 bmap 中被『动态』创建的,我们可以根据上面这个函数的实现对结构体 bmap 进行重建: + +``` +type bmap struct { + topbits [8]uint8 + keys [8]keytype + values [8]valuetype + pad uintptr + overflow uintptr +} + +``` + +每一个哈希表中的桶最多只能存储 8 个元素,如果桶中存储的元素超过 8 个,那么这个哈希表的执行效率一定会急剧下降,不过在实际使用中如果一个哈希表存储的数据逐渐增多,我们会对哈希表进行扩容或者使用额外的桶存储溢出的数据,不会让单个桶中的数据超过 8 个。 + +![](img/bucket.png) + +## 初始化 + + +``` +// make(map[k]v, hint) +// 如果编译器认为 map 和第一个 bucket 可以直接创建在栈上,h 和 bucket 可能都是非空 +// h != nil,可以直接在 h 内创建 map +// 如果 h.buckets != nil,其指向的 bucket 可以作为第一个 bucket 来使用 +func makemap(t *maptype, hint int, h *hmap) *hmap { + // 初始化 hmap + if h == nil { + h = (*hmap)(newobject(t.hmap)) + } + h.hash0 = fastrand() + + // 按照提供的元素个数,找一个可以放得下这么多元素的 B 值 + B := uint8(0) + for overLoadFactor(hint, B) { + B++ + } + h.B = B + + // 分配初始的 hash table + // 如果 B == 0,buckets 字段会由 mapassign 来 lazily 分配 + // 因为如果 hint 很大的话,对这部分内存归零会花比较长时间 + if h.B != 0 { + var nextOverflow *bmap + h.buckets, nextOverflow = makeBucketArray(t, h.B) + if nextOverflow != nil { + h.extra = new(mapextra) + h.extra.nextOverflow = nextOverflow + } + } + + return h +} + +``` + +这个函数会通过 fastrand 创建一个随机的哈希种子,然后根据传入的 hint 计算出需要的最小需要的桶的数量,最后再使用 makeBucketArray创建用于保存桶的数组,这个方法其实就是根据传入的 B 计算出的需要创建的桶数量在内存中分配一片连续的空间用于存储数据,在创建桶的过程中还会额外创建一些用于保存溢出数据的桶,数量是 2^(B-4) 个。 + +![](img/hmap.png) + +## 元素访问 + +赋值语句左侧接受参数的个数也会影响最终调用的运行时参数,当接受参数仅为一个时,会使用 mapaccess1 函数,同时接受键对应的值以及一个指示键是否存在的布尔值时就会使用 mapaccess2 函数,mapaccess1 函数仅会返回一个指向目标值的指针: + +``` +func mapaccess2(t *maptype, h *hmap, key unsafe.Pointer) (unsafe.Pointer, bool) { + // map 为空,或者元素数为 0,直接返回未找到 + if h == nil || h.count == 0 { + return unsafe.Pointer(&zeroVal[0]), false + } + if h.flags&hashWriting != 0 { + throw("concurrent map read and map write") + } + alg := t.key.alg + // 不同类型的 key,所用的 hash 算法是不一样的 + // 具体可以参考 algarray + hash := alg.hash(key, uintptr(h.hash0)) + // 如果 B = 3,那么结果用二进制表示就是 111 + // 如果 B = 4,那么结果用二进制表示就是 1111 + m := bucketMask(h.B) + // 按位 &,可以 select 出对应的 bucket + b := (*bmap)(unsafe.Pointer(uintptr(h.buckets) + (hash&m)*uintptr(t.bucketsize))) + // 会用到 h.oldbuckets 时,说明 map 发生了扩容 + // 这时候,新的 buckets 里可能还没有老的内容 + // 所以一定要在老的里面找,否则有可能发生“消失”的诡异现象 + if c := h.oldbuckets; c != nil { + if !h.sameSizeGrow() { + // 说明之前只有一半的 bucket,需要除 2 + m >>= 1 + } + oldb := (*bmap)(unsafe.Pointer(uintptr(c) + (hash&m)*uintptr(t.bucketsize))) + if !evacuated(oldb) { // 如果当前的 bucket 并没有进行数据迁移,那么访问旧的 bucket + b = oldb + } + } + // tophash 取其高 8bit 的值 + top := tophash(hash) + for ; b != nil; b = b.overflow(t) { + // 一个 bucket 在存储满 8 个元素后,就再也放不下了 + // 这时候会创建新的 bucket + // 挂在原来的 bucket 的 overflow 指针成员上 + for i := uintptr(0); i < bucketCnt; i++ { + // 循环对比 bucket 中的 tophash 数组 + // 如果找到了相等的 tophash,那说明就是这个 bucket 了 + if b.tophash[i] != top { + continue + } + k := add(unsafe.Pointer(b), dataOffset+i*uintptr(t.keysize)) + if t.indirectkey { + k = *((*unsafe.Pointer)(k)) + } + if alg.equal(key, k) { + v := add(unsafe.Pointer(b), dataOffset+bucketCnt*uintptr(t.keysize)+i*uintptr(t.valuesize)) + if t.indirectvalue { + v = *((*unsafe.Pointer)(v)) + } + return v, true + } + } + } + + // 所有 bucket 都没有找到,返回零值和 false + return unsafe.Pointer(&zeroVal[0]), false +} + +``` + +## 赋值 + + +``` +// 和 mapaccess 函数差不多,但在没有找到 key 时,会为 key 分配一个新的槽位 +func mapassign(t *maptype, h *hmap, key unsafe.Pointer) unsafe.Pointer { + // 调用对应类型的 hash 算法 + alg := t.key.alg + hash := alg.hash(key, uintptr(h.hash0)) + + // 调用 alg.hash 设置 hashWriting 的 flag,因为 alg.hash 可能会 panic + // 这时候我们没法完成一次写操作 + h.flags |= hashWriting + + if h.buckets == nil { + // 分配第一个 buckt + h.buckets = newobject(t.bucket) // newarray(t.bucket, 1) + } + +again: + // 计算低 8 位 hash,根据计算出的 bucketMask 选择对应的 bucket + // mask : 1111111 + bucket := hash & bucketMask(h.B) + if h.growing() { // 如果正在进行扩容操作 + growWork(t, h, bucket) // 对 bucket 进行增量迁移数据 + } + // 计算出存储的 bucket 的内存位置 + // pos = start + bucketNumber * bucetsize + b := (*bmap)(unsafe.Pointer(uintptr(h.buckets) + bucket*uintptr(t.bucketsize))) + // 计算高 8 位 hash + top := tophash(hash) + + var inserti *uint8 + var insertk unsafe.Pointer + var val unsafe.Pointer + for { + for i := uintptr(0); i < bucketCnt; i++ { + // 遍历 8 个 bucket 中的元素 + // 这里的 bucketCnt 是全局常量 + if b.tophash[i] != top { + // 在 b.tophash[i] != top 的情况下 + // 理论上有可能会是一个空槽位 + // 一般情况下 map 的槽位分布是这样的,e 表示 empty: + // [h1][h2][h3][h4][h5][e][e][e] + // 但在执行过 delete 操作时,可能会变成这样: + // [h1][h2][e][e][h5][e][e][e] + // 所以如果再插入的话,会尽量往前面的位置插 + // [h1][h2][e][e][h5][e][e][e] + // ^ + // ^ + // 这个位置 + // 所以在循环的时候还要顺便把前面的空位置先记下来 + if b.tophash[i] == empty && inserti == nil { + // 如果真的在 bucket 里面找不到 key,那么就要在 val 里面插入新值 + // 如果这个槽位没有被占,说明可以往这里塞 key 和 value + inserti = &b.tophash[i] // tophash 的插入位置 + insertk = add(unsafe.Pointer(b), dataOffset+i*uintptr(t.keysize)) + val = add(unsafe.Pointer(b), dataOffset+bucketCnt*uintptr(t.keysize)+i*uintptr(t.valuesize)) + } + continue // 由于还没有遍历完毕,继续遍历,查找是否有 key 这个元素 + } + + // tophash 相同,key 不一定相同 + k := add(unsafe.Pointer(b), dataOffset+i*uintptr(t.keysize)) + if t.indirectkey { + k = *((*unsafe.Pointer)(k)) + } + + // 如果相同的 hash 位置的 key 和要插入的 key 字面上不相等 + // 如果两个 key 的首八位后最后八位哈希值一样,就会进行其值比较 + // 算是一种哈希碰撞吧 + if !alg.equal(key, k) { + continue + } + + // key 也相同,说明找到了元素 + // 对应的位置已经有 key 了,直接更新就行 + if t.needkeyupdate { + typedmemmove(t.key, k, key) + } + + val = add(unsafe.Pointer(b), dataOffset+bucketCnt*uintptr(t.keysize)+i*uintptr(t.valuesize)) + goto done + } + + // bucket 的 8 个槽没有满足条件的能插入或者能更新的,去 overflow 里继续找 + ovf := b.overflow(t) + // 如果 overflow 为 nil,说明到了 overflow 链表的末端了 + if ovf == nil { + break + } + // 赋值为链表的下一个元素,继续循环 + b = ovf + } + + // 没有找到 key,分配新的空间 + + // 如果触发了最大的 load factor,或者已经有太多 overflow buckets + // 并且这个时刻没有在进行 growing 的途中,那么就开始 growing + if !h.growing() && (overLoadFactor(h.count+1, h.B) || tooManyOverflowBuckets(h.noverflow, h.B)) { + hashGrow(t, h) + // hashGrow 的时候会把当前的 bucket 放到 oldbucket 里 + // 但还没有开始分配新的 bucket,所以需要到 again 重试一次 + // 重试的时候在 growWork 里会把这个 key 的 bucket 优先分配好 + goto again // Growing the table invalidates everything, so try again + } + + if inserti == nil { + // 前面在桶里找的时候,没有找到能塞这个 tophash 的位置 + // 说明当前所有 buckets 都是满的,分配一个新的 bucket + newb := h.newoverflow(t, b) + inserti = &newb.tophash[0] + insertk = add(unsafe.Pointer(newb), dataOffset) + val = add(insertk, bucketCnt*uintptr(t.keysize)) + } + + // 没有找到元素,但是找到了可以插入的地方 + // 把新的 key 和 value 存储到应插入的位置 + if t.indirectkey { + kmem := newobject(t.key) + *(*unsafe.Pointer)(insertk) = kmem + insertk = kmem + } + if t.indirectvalue { + vmem := newobject(t.elem) + *(*unsafe.Pointer)(val) = vmem + } + typedmemmove(t.key, insertk, key) + *inserti = top + h.count++ + +done: + if h.flags&hashWriting == 0 { + throw("concurrent map writes") + } + h.flags &^= hashWriting + if t.indirectvalue { + val = *((*unsafe.Pointer)(val)) + } + return val +} + +``` + +## 删除 + +哈希表的删除逻辑与写入逻辑非常相似. + +``` +func mapdelete(t *maptype, h *hmap, key unsafe.Pointer) { + if h == nil || h.count == 0 { + return + } + if h.flags&hashWriting != 0 { + throw("concurrent map writes") + } + + alg := t.key.alg + hash := alg.hash(key, uintptr(h.hash0)) + + // 调用 alg.hash 设置 hashWriting 的 flag,因为 alg.hash 可能会 panic + // 这时候我们没法完成一次写操作 + h.flags |= hashWriting + + // 按低 8 位 hash 值选择 bucket + bucket := hash & bucketMask(h.B) + if h.growing() { + growWork(t, h, bucket) + } + // 按上面算出的桶的索引,找到 bucket 的内存地址 + // 并强制转换为需要的 bmap 结构 + b := (*bmap)(add(h.buckets, bucket*uintptr(t.bucketsize))) + // 高 8 位 hash 值 + top := tophash(hash) +search: + for ; b != nil; b = b.overflow(t) { + for i := uintptr(0); i < bucketCnt; i++ { + // 和上面的差不多,8 个槽位,分别对比 tophash + // 没找到的话就去外围 for 循环的 overflow 链表中继续查找 + if b.tophash[i] != top { + continue + } + + // b.tophash[i] == top + // 计算 k 所在的槽位的内存地址 + k := add(unsafe.Pointer(b), dataOffset+i*uintptr(t.keysize)) + k2 := k + // 如果 key > 128 字节 + if t.indirectkey { + k2 = *((*unsafe.Pointer)(k2)) + } + + // 当高 8 位哈希值相等时,还需要对具体值进行比较 + // 以避免哈希冲突时值覆盖 + if !alg.equal(key, k2) { + continue + } + + // 如果 key 中是指针,那么清空 key 的内容 + if t.indirectkey { + *(*unsafe.Pointer)(k) = nil + } else if t.key.kind&kindNoPointers == 0 { + memclrHasPointers(k, t.key.size) + } + + // 计算 value 所在的内存地址 + v := add(unsafe.Pointer(b), dataOffset+bucketCnt*uintptr(t.keysize)+i*uintptr(t.valuesize)) + // 和上面 key 的逻辑差不多 + if t.indirectvalue { + *(*unsafe.Pointer)(v) = nil + } else if t.elem.kind&kindNoPointers == 0 { + memclrHasPointers(v, t.elem.size) + } else { + memclrNoHeapPointers(v, t.elem.size) + } + // 设置 tophash[i] = 0 + b.tophash[i] = empty + // hmap 的大小计数 -1 + h.count-- + break search + } + } + + if h.flags&hashWriting == 0 { + throw("concurrent map writes") + } + h.flags &^= hashWriting +} + +``` + +## 扩容 + +扩容触发在 mapassign 中,我们之前注释过了,主要是两点: + +- 是不是已经到了 load factor 的临界点,即元素个数 >= 桶个数 * 6.5,这时候说明大部分的桶可能都快满了,如果插入新元素,有大概率需要挂在 overflow 的桶上。 +- overflow 的桶是不是太多了,当 bucket 总数 < 2 ^ 15 时,如果 overflow 的 bucket 总数 >= bucket 的总数,那么我们认为 overflow 的桶太多了。当 bucket 总数 >= 2 ^ 15 时,那我们直接和 2 ^ 15 比较,overflow 的 bucket >= 2 ^ 15 时,即认为溢出桶太多了。为啥会导致这种情况呢?是因为我们对 map 一边插入,一边删除,会导致其中很多桶出现空洞,这样使得 bucket 使用率不高,值存储得比较稀疏。在查找时效率会下降。 + +两种情况官方采用了不同的解决方法: + +- 针对 1,将 B + 1,进而 hmap 的 bucket 数组扩容一倍; +- 针对 2,通过移动 bucket 内容,使其倾向于紧密排列从而提高 bucket 利用率。 + +实际上这里还有一种麻烦的情况,如果 map 中有某个键存在大量的哈希冲突的话,也会导致落入 2 中的判断,这时候对 bucket 的内容进行移动其实没什么意义,反而是纯粹的无用功,所以理论上存在对 Go 的 map 进行 hash 碰撞攻击的可能性。 + + +``` +func hashGrow(t *maptype, h *hmap) { + // 如果已经超过了 load factor 的阈值,那么需要对 map 进行扩容,即 B = B + 1,bucket 总数会变为原来的二倍 + // 如果还没到阈值,那么只需要保持相同数量的 bucket,横向拍平就行了 + + bigger := uint8(1) + if !overLoadFactor(h.count+1, h.B) { + bigger = 0 + h.flags |= sameSizeGrow + } + oldbuckets := h.buckets + newbuckets, nextOverflow := makeBucketArray(t, h.B+bigger, nil) + + flags := h.flags &^ (iterator | oldIterator) + if h.flags&iterator != 0 { + flags |= oldIterator + } + + // 提交扩容结果 + h.B += bigger + h.flags = flags + h.oldbuckets = oldbuckets + h.buckets = newbuckets + h.nevacuate = 0 + h.noverflow = 0 + + if h.extra != nil && h.extra.overflow != nil { + // 把当前的 overflow 赋值给 oldoverflow + if h.extra.oldoverflow != nil { + throw("oldoverflow is not nil") + } + h.extra.oldoverflow = h.extra.overflow + h.extra.overflow = nil + } + if nextOverflow != nil { + if h.extra == nil { + h.extra = new(mapextra) + } + h.extra.nextOverflow = nextOverflow + } + + // 实际的哈希表元素的拷贝工作是在 growWork 和 evacuate 中增量慢慢地进行的 +} +``` + +在哈希表扩容的过程中,我们会通过 makeBucketArray 创建新的桶数组和一些预创建的溢出桶,随后对将原有的桶数组设置到 oldbuckets 上并将新的空桶设置到 buckets 上,原有的溢出桶也使用了相同的逻辑进行更新。 + +![](img/hashgrow.png) + +我们在上面的函数中还看不出来 sameSizeGrow 导致的区别,因为这里其实只是创建了新的桶并没有对数据记性任何的拷贝和转移,哈希表真正的『数据迁移』的执行过程其实是在 evacuate 函数中进行的,evacuate 函数会对传入桶中的元素进行『再分配』。 + +``` +func growWork(t *maptype, h *hmap, bucket uintptr) { + // 确保我们移动的 oldbucket 对应的是我们马上就要用到的那一个 + evacuate(t, h, bucket&h.oldbucketmask()) + + // 如果还在 growing 状态,再多移动一个 oldbucket + if h.growing() { + evacuate(t, h, h.nevacuate) + } +} + +func evacuate(t *maptype, h *hmap, oldbucket uintptr) { + b := (*bmap)(add(h.oldbuckets, oldbucket*uintptr(t.bucketsize))) + newbit := h.noldbuckets() + if !evacuated(b) { + // TODO: reuse overflow buckets instead of using new ones, if there + // is no iterator using the old buckets. (If !oldIterator.) + + // xy 包含的是移动的目标 + // x 表示新 bucket 数组的前(low)半部分 + // y 表示新 bucket 数组的后(high)半部分 + var xy [2]evacDst + x := &xy[0] + x.b = (*bmap)(add(h.buckets, oldbucket*uintptr(t.bucketsize))) + x.k = add(unsafe.Pointer(x.b), dataOffset) + x.v = add(x.k, bucketCnt*uintptr(t.keysize)) + + if !h.sameSizeGrow() { + // 如果 map 大小(hmap.B)增大了,那么我们只计算 y + // 否则 GC 可能会看到损坏的指针 + y := &xy[1] + y.b = (*bmap)(add(h.buckets, (oldbucket+newbit)*uintptr(t.bucketsize))) + y.k = add(unsafe.Pointer(y.b), dataOffset) + y.v = add(y.k, bucketCnt*uintptr(t.keysize)) + } + + for ; b != nil; b = b.overflow(t) { + k := add(unsafe.Pointer(b), dataOffset) + v := add(k, bucketCnt*uintptr(t.keysize)) + for i := 0; i < bucketCnt; i, k, v = i+1, add(k, uintptr(t.keysize)), add(v, uintptr(t.valuesize)) { + top := b.tophash[i] + if top == empty { + b.tophash[i] = evacuatedEmpty + continue + } + if top < minTopHash { + throw("bad map state") + } + k2 := k + if t.indirectkey { + k2 = *((*unsafe.Pointer)(k2)) + } + var useY uint8 + if !h.sameSizeGrow() { + // 计算哈希,以判断我们的数据要转移到哪一部分的 bucket + // 可能是 x 部分,也可能是 y 部分 + hash := t.key.alg.hash(k2, uintptr(h.hash0)) + if h.flags&iterator != 0 && !t.reflexivekey && !t.key.alg.equal(k2, k2) { + // 为什么要加 reflexivekey 的判断,可以参考这里: + // https://go-review.googlesource.com/c/go/+/1480 + // key != key,只有在 float 数的 NaN 时会出现 + // 比如: + // n1 := math.NaN() + // n2 := math.NaN() + // fmt.Println(n1, n2) + // fmt.Println(n1 == n2) + // 这种情况下 n1 和 n2 的哈希值也完全不一样 + // 这里官方表示这种情况是不可复现的 + // 需要在 iterators 参与的情况下才能复现 + // 但是对于这种 key 我们也可以随意对其目标进行发配 + // 同时 tophash 对于 NaN 也没啥意义 + // 还是按正常的情况下算一个随机的 tophash + // 然后公平地把这些 key 平均分布到各 bucket 就好 + useY = top & 1 // 让这个 key 50% 概率去 Y 半区 + top = tophash(hash) + } else { + // 这里写的比较 trick + // 比如当前有 8 个桶 + // 那么如果 hash & 8 != 0 + // 那么说明这个元素的 hash 这种形式 + // xxx1xxx + // 而扩容后的 bucketMask 是 + // 1111 + // 所以实际上这个就是 + // xxx1xxx & 1000 > 0 + // 说明这个元素在扩容后一定会去上半区 + // 所以就是 useY 了 + if hash&newbit != 0 { + useY = 1 + } + } + } + + if evacuatedX+1 != evacuatedY { + throw("bad evacuatedN") + } + + b.tophash[i] = evacuatedX + useY // evacuatedX + 1 == evacuatedY + dst := &xy[useY] // 移动目标 + + if dst.i == bucketCnt { + dst.b = h.newoverflow(t, dst.b) + dst.i = 0 + dst.k = add(unsafe.Pointer(dst.b), dataOffset) + dst.v = add(dst.k, bucketCnt*uintptr(t.keysize)) + } + dst.b.tophash[dst.i&(bucketCnt-1)] = top // mask dst.i as an optimization, to avoid a bounds check + if t.indirectkey { + *(*unsafe.Pointer)(dst.k) = k2 // 拷贝指针 + } else { + typedmemmove(t.key, dst.k, k) // 拷贝值 + } + if t.indirectvalue { + *(*unsafe.Pointer)(dst.v) = *(*unsafe.Pointer)(v) + } else { + typedmemmove(t.elem, dst.v, v) + } + dst.i++ + // These updates might push these pointers past the end of the + // key or value arrays. That's ok, as we have the overflow pointer + // at the end of the bucket to protect against pointing past the + // end of the bucket. + dst.k = add(dst.k, uintptr(t.keysize)) + dst.v = add(dst.v, uintptr(t.valuesize)) + } + } + // Unlink the overflow buckets & clear key/value to help GC. + if h.flags&oldIterator == 0 && t.bucket.kind&kindNoPointers == 0 { + b := add(h.oldbuckets, oldbucket*uintptr(t.bucketsize)) + // Preserve b.tophash because the evacuation + // state is maintained there. + ptr := add(b, dataOffset) + n := uintptr(t.bucketsize) - dataOffset + memclrHasPointers(ptr, n) + } + } + + if oldbucket == h.nevacuate { + advanceEvacuationMark(h, t, newbit) + } +} + +``` + +evacuate 函数在最开始时会创建一个用于保存分配目的 evacDst 结构体数组,其中保存了目标桶的指针、目标桶存储的元素数量以及当前键和值存储的位置。 + +![](img/hashgrow1.png) + +如果这是一次不改变大小的扩容,这两个 evacDst 结构体只会初始化一个,当哈希表的容量翻倍时,一个桶中的元素会被分流到新创建的两个桶中,这两个桶同时会被 evacDst 数组引用. + +如果新的哈希表中有八个桶,在大多数情况下,原来经过桶掩码结果为一的数据会因为桶掩码增加了一位而被分留到了新的一号桶和五号桶,所有的数据也都会被 typedmemmove 拷贝到目标桶的键和值所在的内存空间. + +该函数的最后会调用 advanceEvacuationMark 函数,它会增加哈希的 nevacuate 计数器,然后在所有的旧桶都被分流后删除这些无用的数据,然而因为 Go 语言数据的迁移过程不是一次性执行完毕的,它只会在写入或者删除时触发 evacuate 函数增量完成的,所以不会瞬间对性能造成影响。 diff --git a/Go Select.md b/Go Select.md new file mode 100644 index 0000000..bafedc4 --- /dev/null +++ b/Go Select.md @@ -0,0 +1,493 @@ +# Go Select + +[TOC] + +## 数据结构 + +select 在 Go 语言的源代码中其实不存在任何的结构体表示,但是 select 控制结构中 case 却使用了 scase 结构体来表示。 + +由于非 default 的 case 中都与 Channel 的发送和接收数据有关,所以在 scase 结构体中也包含一个 c 字段用于存储 case 中使用的 Channel,elem 是用于接收或者发送数据的变量地址、kind 表示当前 case 的种类,总共包含以下四种: + +``` +const ( + // scase.kind + // send 或者 recv 发生在一个 nil channel 上,就有可能出现这种情况 + caseNil = iota + caseRecv + caseSend + caseDefault +) + +// select 中每一个 case 的数据结构定义 +type scase struct { + // 数据元素 + elem unsafe.Pointer // data element + // channel 本体 + c *hchan // chan + kind uint16 + + releasetime int64 + pc uintptr // return pc (for race detector / msan) +} + +``` + +### 非阻塞的收发 + +如果一个 select 控制结构中包含一个 default 表达式,那么这个 select 并不会等待其它的 Channel 准备就绪,而是会非阻塞地读取或者写入数据: + +``` +func main() { + ch := make(chan int) + select { + case i := <-ch: + println(i) + + default: + println("default") + } +} +``` + +当我们运行上面的代码时其实也并不会阻塞当前的 Goroutine,而是会直接执行 default 条件中的内容并返回。 + +### 随机执行 + +另一个使用 select 遇到的情况其实就是同时有多个 case 就绪后,select 如何进行选择的问题,select 在遇到两个 <-ch 同时响应时其实会随机选择一个 case 执行其中的表达式。 + +## 编译期间 + +编译器在中间代码生成期间会根据 select 中 case 的不同对控制语句进行优化,这一过程其实都发生在 walkselectcases 函数中,我们在这里会分四种情况分别介绍优化的过程和结果: + +- select 中不存在任何的 case; +- select 中只存在一个 case; +- select 中存在两个 case,其中一个 case 是 default 语句; +- 通用的 select 条件; + +### 不存在任何的 case + +首先介绍的其实就是最简单的情况,也就是当 select 结构中不包含任何的 case 时,编译器是如何进行处理的: + +``` +func walkselectcases(cases *Nodes) []*Node { + n := cases.Len() + + if n == 0 { + return []*Node{mkcall("block", nil, nil)} + } + // ... +} + +``` + +这段代码非常简单并且容易理解,它直接将类似 select {} 的空语句,转换成对 block 函数的调用: + +``` +func block() { + gopark(nil, nil, waitReasonSelectNoCases, traceEvGoStop, 1) +} +``` + +这其实也在告诉我们一个空的 select 语句会直接阻塞当前的 Goroutine。 + + +### 只包含一个 case + +如果当前的 select 条件只包含一个 case,那么就会就会执行如下的优化策略将原来的 select 语句改写成 if 条件语句,下面是在 select 中从 Channel 接受数据时被改写的情况: + +``` +select { +case v, ok <-ch: + // ... +} + +if ch == nil { + block() +} +v, ok := <-ch +// ... + +``` + +我们可以看到如果在 select 中仅存在一个 case,那么当 case 中处理的 Channel 是空指针时,就会发生和没有 case 的 select 语句一样的情况,也就是直接挂起当前 Goroutine 并且永远不会被唤醒。 + +### 两个 case,default 语句 + +在下一次的优化策略执行之前,walkselectcases 函数会先将 case 中所有 Channel 都转换成指向 Channel 的地址以便于接下来的优化和通用逻辑的执行,改写之后就会进行最后一次的代码优化,触发的条件就是 — select 中包含两个 case,但是其中一个是 default,我们可以分成发送和接收两种情况介绍处理的过程。 + +- 发送 + +首先就是 Channel 的发送过程,也就是 case 中的表达式是 OSEND 类型,在这种情况下会使用 if/else 语句改写代码: + +``` +select { +case ch <- i: + // ... +default: + // ... +} + +if selectnbsend(ch, i) { + // ... +} else { + // ... +} + +``` + +这里最重要的函数其实就是 selectnbsend,它的主要作用就是非阻塞地向 Channel 中发送数据,我们在 Channel 一节曾经提到过发送数据的 chansend 函数包含一个 block 参数,这个参数会决定这一次的发送是不是阻塞的: + +``` +func selectnbsend(c *hchan, elem unsafe.Pointer) (selected bool) { + return chansend(c, elem, false, getcallerpc()) +} +``` + +在这里我们只需要知道当前的发送过程不是阻塞的,哪怕是没有接收方、缓冲区空间不足导致失败了也会立即返回。 + +- 接收 + +由于从 Channel 中接收数据可能会返回一个或者两个值,所以这里的情况会比发送时稍显复杂,不过改写的套路和逻辑确是差不多的: + +``` +select { +case v <- ch: // case v, received <- ch: + // ... +default: + // ... +} + +if selectnbrecv(&v, ch) { // if selectnbrecv2(&v, &received, ch) { + // ... +} else { + // ... +} + +``` + +返回值数量不同会导致最终使用函数的不同,两个用于非阻塞接收消息的函数 selectnbrecv 和 selectnbrecv2 其实只是对 chanrecv 返回值的处理稍有不同: + +``` +func selectnbrecv(elem unsafe.Pointer, c *hchan) (selected bool) { + selected, _ = chanrecv(c, elem, false) + return +} + +func selectnbrecv2(elem unsafe.Pointer, received *bool, c *hchan) (selected bool) { + selected, *received = chanrecv(c, elem, false) + return +} + +``` + +- 通用阶段 + +在默认的情况下,select 语句会在编译阶段经过如下过程的处理: + +- 将所有的 case 转换成包含 Channel 以及类型等信息的 scase 结构体; +- 调用运行时函数 selectgo 获取被选择的 scase 结构体索引,如果当前的 scase 是一个接收数据的操作,还会返回一个指示当前 case 是否是接收的布尔值; +- 通过 for 循环生成一组 if 语句,在语句中判断自己是不是被选中的 case + +一个包含三个 case 的正常 select 语句其实会被展开成如下所示的逻辑,我们可以看到其中处理的三个部分: + +``` +selv := [3]scase{} +order := [6]uint16 +for i, cas := range cases { + c := scase{} + c.kind = ... + c.elem = ... + c.c = ... +} +chosen, revcOK := selectgo(selv, order, 3) +if chosen == 0 { + // ... + break +} +if chosen == 1 { + // ... + break +} +if chosen == 2 { + // ... + break +} + +``` + +展开后的 select 其实包含三部分,最开始初始化数组并转换 scase 结构体,使用 selectgo 选择执行的 case 以及最后通过 if 判断选中的情况并执行 case 中的表达式,需要注意的是这里其实也仅仅展开了 select 控制结构,select 语句执行最重要的过程其实也是选择 case 执行的过程,这是我们在下一节运行时重点介绍的。 + +## 运行时 + +selectgo 是会在运行期间运行的函数,这个函数的主要作用就是从 select 控制结构中的多个 case 中选择一个需要执行的 case,随后的多个 if 条件语句就会根据 selectgo 的返回值执行相应的语句。 + +### 初始化 + +selectgo 函数首先会进行执行必要的一些初始化操作,也就是决定处理 case 的两个顺序,其中一个是 pollOrder 另一个是 lockOrder: + +``` +func selectgo(cas0 *scase, order0 *uint16, ncases int) (int, bool) { + cas1 := (*[1 << 16]scase)(unsafe.Pointer(cas0)) + order1 := (*[1 << 17]uint16)(unsafe.Pointer(order0)) + + scases := cas1[:ncases:ncases] + pollorder := order1[:ncases:ncases] + lockorder := order1[ncases:][:ncases:ncases] + + for i := range scases { + cas := &scases[i] + if cas.c == nil && cas.kind != caseDefault { + *cas = scase{} + } + } + + for i := 1; i < ncases; i++ { + j := fastrandn(uint32(i + 1)) + pollorder[i] = pollorder[j] + pollorder[j] = uint16(i) + } + + // sort the cases by Hchan address to get the locking order. + // ... + + sellock(scases, lockorder) + + // ... +} + +``` + +Channel 的轮询顺序是通过 fastrandn 随机生成的,这其实就导致了如果多个 Channel 同时『响应』,select 会随机选择其中的一个执行;而另一个 lockOrder 就是根据 Channel 的地址确定的,根据相同的顺序锁定 Channel 能够避免死锁的发生,最后调用的 sellock 就会按照之前生成的顺序锁定所有的 Channel。 + +### 循环 + +#### 非阻塞遍历 + +当我们为 select 语句确定了轮询和锁定的顺序并锁定了所有的 Channel 之后就会开始进入 select 的主循环,查找或者等待 Channel 准备就绪,循环中会遍历所有的 case 并找到需要被唤起的 sudog 结构体,在这段循环的代码中,我们会分四种不同的情况处理 select 中的多个 case: + +- caseNil — 当前 case 不包含任何的 Channel,就直接会被跳过; +- caseRecv — 当前 case 会从 Channel 中接收数据; + - 如果当前 Channel 的 sendq 上有等待的 Goroutine 就会直接跳到 recv 标签所在的代码段,从 Goroutine 中获取最新发送的数据; + - 如果当前 Channel 的缓冲区不为空就会跳到 bufrecv 标签处从缓冲区中获取数据; + - 如果当前 Channel 已经被关闭就会跳到 rclose 做一些清除的收尾工作; +- caseSend — 当前 case 会向 Channel 发送数据; + - 如果当前 Channel 已经被关闭就会直接跳到 sclose 代码段; + - 如果当前 Channel 的 recvq 上有等待的 Goroutine 就会跳到 send 代码段向 Channel 直接发送数据; +- caseDefault — 当前 case 表示默认情况,如果循环执行到了这种情况就表示前面的所有 case 都没有被执行,所以这里会直接解锁所有的 Channel 并退出 selectgo 函数,这时也就意味着当前 select 结构中的其他收发语句都是非阻塞的。 + +``` +loop: + // pass 1 - look for something already waiting + var dfli int + var dfl *scase + var casi int + var cas *scase + var recvOK bool + for i := 0; i < ncases; i++ { + casi = int(pollorder[i]) + cas = &scases[casi] + c = cas.c + + switch cas.kind { + case caseNil: + continue + + case caseRecv: + sg = c.sendq.dequeue() + if sg != nil { + goto recv + } + if c.qcount > 0 { + goto bufrecv + } + if c.closed != 0 { + goto rclose + } + + case caseSend: + if raceenabled { + racereadpc(c.raceaddr(), cas.pc, chansendpc) + } + if c.closed != 0 { + goto sclose + } + sg = c.recvq.dequeue() + if sg != nil { + goto send + } + if c.qcount < c.dataqsiz { + goto bufsend + } + + case caseDefault: + dfli = casi + dfl = cas + } + } +``` + +这其实是循环执行的第一次遍历,主要作用就是寻找所有 case 中 Channel 是否有可以立刻被处理的情况,无论是在包含等待的 Goroutine 还是缓冲区中存在数据,只要满足条件就会立刻处理。 + +下面是各个 + +``` +bufrecv: + // can receive from buffer + recvOK = true + qp = chanbuf(c, c.recvx) + if cas.elem != nil { + typedmemmove(c.elemtype, cas.elem, qp) + } + typedmemclr(c.elemtype, qp) + c.recvx++ + if c.recvx == c.dataqsiz { + c.recvx = 0 + } + c.qcount-- + selunlock(scases, lockorder) + goto retc + +bufsend: + // can send to buffer + typedmemmove(c.elemtype, chanbuf(c, c.sendx), cas.elem) + c.sendx++ + if c.sendx == c.dataqsiz { + c.sendx = 0 + } + c.qcount++ + selunlock(scases, lockorder) + goto retc + +recv: + // can receive from sleeping sender (sg) + recv(c, sg, cas.elem, func() { selunlock(scases, lockorder) }, 2) + if debugSelect { + print("syncrecv: cas0=", cas0, " c=", c, "\n") + } + recvOK = true + goto retc + +rclose: + // read at end of closed channel + selunlock(scases, lockorder) + recvOK = false + if cas.elem != nil { + typedmemclr(c.elemtype, cas.elem) + } + goto retc + +send: + // can send to a sleeping receiver (sg) + send(c, sg, cas.elem, func() { selunlock(scases, lockorder) }, 2) + if debugSelect { + print("syncsend: cas0=", cas0, " c=", c, "\n") + } + goto retc + +sclose: + // send on closed channel + selunlock(scases, lockorder) + panic(plainError("send on closed channel")) +``` + +#### 加入队列后阻塞 + +如果不能立刻找到活跃的 Channel 就会进入循环的下一个过程,按照需要将当前的 Goroutine 加入到所有 Channel 的 sendq 或者 recvq 队列中: + +``` +func selectgo(cas0 *scase, order0 *uint16, ncases int) (int, bool) { + // ... + gp = getg() + nextp = &gp.waiting + for _, casei := range lockorder { + casi = int(casei) + cas = &scases[casi] + if cas.kind == caseNil { + continue + } + c = cas.c + sg := acquireSudog() + sg.g = gp + sg.isSelect = true + sg.elem = cas.elem + sg.c = c + *nextp = sg + nextp = &sg.waitlink + + switch cas.kind { + case caseRecv: + c.recvq.enqueue(sg) + + case caseSend: + c.sendq.enqueue(sg) + } + } + + gp.param = nil + gopark(selparkcommit, nil, waitReasonSelect, traceEvGoBlockSelect, 1) + + // ... +} +``` + +这里创建 sudog 并入队的过程其实和 Channel 中直接进行发送和接收时的过程几乎完全相同,只是除了在入队之外,这些 sudog 结构体都会被串成链表附着在当前 Goroutine 上,在入队之后会调用 gopark 函数挂起当前的 Goroutine 等待调度器的唤醒。 + +![](img/waiting.png) + +#### 唤醒 + +等到 select 对应的一些 Channel 准备好之后,当前 Goroutine 就会被调度器唤醒,这时就会继续执行 selectgo 函数中剩下的逻辑,也就是从上面 入队的 sudog 结构体中获取数据: + +``` +func selectgo(cas0 *scase, order0 *uint16, ncases int) (int, bool) { + // ... + gp.selectDone = 0 + sg = (*sudog)(gp.param) + gp.param = nil + + casi = -1 + cas = nil + sglist = gp.waiting + gp.waiting = nil + + for _, casei := range lockorder { + k = &scases[casei] + if sg == sglist { + casi = int(casei) + cas = k + } else { + if k.kind == caseSend { + c.sendq.dequeueSudoG(sglist) + } else { + c.recvq.dequeueSudoG(sglist) + } + } + sgnext = sglist.waitlink + sglist.waitlink = nil + releaseSudog(sglist) + sglist = sgnext + } + + c = cas.c + + if cas.kind == caseRecv { + recvOK = true + } + + selunlock(scases, lockorder) + goto retc + // ... + + +retc: + return casi, recvOK + +} + +``` + +在第三次根据 lockOrder 遍历全部 case 的过程中,我们会先获取 Goroutine 接收到的参数 param,这个参数其实就是被唤醒的 sudog 结构,我们会依次对比所有 case 对应的 sudog 结构找到被唤醒的 case 并释放其他未被使用的 sudog 结构。 + +由于当前的 select 结构已经挑选了其中的一个 case 进行执行,那么剩下 case 中没有被用到的 sudog 其实就会直接忽略并且释放掉了,为了不影响 Channel 的正常使用,我们还是需要将这些废弃的 sudog 从 Channel 中出队;而除此之外的发生事件导致我们被唤醒的 sudog 结构已经在 Channel 进行收发时就已经出队了,不需要我们再次处理。 + +注意 gp.waiting 就是我们在阻塞睡眠之前,sodug 的链表,存储着所有的 sodug。 \ No newline at end of file diff --git a/Go Semaphore.md b/Go Semaphore.md new file mode 100644 index 0000000..7249766 --- /dev/null +++ b/Go Semaphore.md @@ -0,0 +1,533 @@ +# Go Semaphore + +[toc] + +## 基本概念 + +Semaphore 是 Golang 的 mutex 实现的基础,Semaphore semacquire 保证只有 (*addr) 个 Goroutine 获取 Semaphore 成功,其他的 Goroutine 再调用 semacquire 就会直接被调度,等待着其他的 Goroutine 调用 Semrelease 函数复活。 + +有趣的是,当调用 require 的时候,(*addr) 会立刻减一,直到变成 0 之后,就不会再减为负数了。即使有再多的 G 在等待着信号量,`(*addr)` 也是 0。 + +当调用 release 之后,(*addr) 会自动自增,唤醒排在队列里面的 G,G 被唤醒之后,再对 `(*addr)` 递减,所以只要有 G 在 treap 中等待,那么 `(*addr)` 就一直保持着 0,直到所有的 G 都从 treap 中唤醒,这个时候 release 才会使得 `(*addr)` 成为正数,直到还原为初始值。 + +值得注意的是,当 G 被唤醒之后,并不是直接就返回,而是需要再次抢夺 `(*addr)`,因为很可能调用 release 和它刚刚被调度被唤醒中间有一段时间,其他的 G 在这段时间里抢夺了信号量。只有 release 的参数加上 handoff 的时候,release 会对 sodog.ticket 设置为 1,同时直接递减 (*addr) 的值,G 被调度唤醒之后,才能直接获得信号量。 + +``` +//go:linkname sync_runtime_Semacquire sync.runtime_Semacquire +func sync_runtime_Semacquire(addr *uint32) { + semacquire1(addr, false, semaBlockProfile, 0) +} + +//go:linkname poll_runtime_Semacquire internal/poll.runtime_Semacquire +func poll_runtime_Semacquire(addr *uint32) { + semacquire1(addr, false, semaBlockProfile, 0) +} + +//go:linkname sync_runtime_Semrelease sync.runtime_Semrelease +func sync_runtime_Semrelease(addr *uint32, handoff bool, skipframes int) { + semrelease1(addr, handoff, skipframes) +} + +//go:linkname sync_runtime_SemacquireMutex sync.runtime_SemacquireMutex +func sync_runtime_SemacquireMutex(addr *uint32, lifo bool, skipframes int) { + semacquire1(addr, lifo, semaBlockProfile|semaMutexProfile, skipframes) +} + +//go:linkname poll_runtime_Semrelease internal/poll.runtime_Semrelease +func poll_runtime_Semrelease(addr *uint32) { + semrelease(addr) +} + +``` + +## 数据结构 + +### semaRoot + +其实 Semaphore 实现原理很简单: + +- 首先利用 atomic 来判断 `(*addr)` 当前的数值,如果当前 `(*addr)` 还大于 0,那么直接返回继续执行; + +- 如果已经为 0 了,那么就找到 addr 映射的 semaRoot,先自增等待数 semaRoot.nwait。很多个 addr 可能映射到 semtable 数组中同一个 semaRoot。 + +- semaRoot 里面有个节点 sudog 类型的 treap 树,这个 treap 树的根就是 semaRoot.treap 变量。 + +- 遍历这个 treap 树,这个 treap 树是二叉搜索树,节点是按照 addr 地址大小来排序的。 + +- 在这个 treap 树中去找值为 addr 的节点,这个节点是所有被阻塞在 (addr) 上的 Goroutine 组合,把自己入队这个链表。 +- gopark 自己当前的 G,等待着唤醒即可。 + +每次调用 semrelease1 的时候,过程相反。 + +我们先来看看数据结构: + +``` +type semaRoot struct { + lock mutex + treap *sudog // root of balanced tree of unique waiters. + nwait uint32 // Number of waiters. Read w/o the lock. +} + +// Prime to not correlate with any user patterns. +const semTabSize = 251 + +var semtable [semTabSize]struct { + root semaRoot + pad [sys.CacheLineSize - unsafe.Sizeof(semaRoot{})]byte +} + +func semroot(addr *uint32) *semaRoot { + return &semtable[(uintptr(unsafe.Pointer(addr))>>3)%semTabSize].root +} + +``` + +### sudog 数据结构 + +sudog 是 G 结构的包装,用于连接多个等待某个条件的 Goroutine。prev/next 是双向链表的指针。 + +在 treap 中,prev 还代表着其左子树,next 代表着其右子树,parent 是其父节点。waitlink 是阻塞在同一个信号上的队列单链表指针,waittail 是队列尾节点,ticket 是 treap 的随机数,elem 是 treap 的 value(信号量地址),acquiretime 与 releasetime 代表着阻塞的时间。 + +``` +type sudog struct { + g *g + + next *sudog + prev *sudog + elem unsafe.Pointer // data element (may point to stack) + + acquiretime int64 + releasetime int64 + ticket uint32 + parent *sudog // semaRoot binary tree + waitlink *sudog // g.waiting list or semaRoot + waittail *sudog // semaRoot + c *hchan // channel +} + +``` + +## semacquire 过程 + +过程如上一个小节所述: + +- 首先利用 cansemacquire 来判断 addr 当前的值,如果还大于 0,那么减一,直接返回当前的 G;如果已经为 0 了,那么就得接着执行 semacquire 函数 +- 初始化 sodug 结构体,ticket 是用于 treap 的随机数,保障 treap 大体平衡。acquiretime 是 G 进入调度的时间,对应的 releasetime 被出队的时间。 +- root.nwait 自增 +- 获取一个锁,这个 mutex 是一个比较底层的实现,是 runtime 专用的一种锁,这个锁实现的临界区通常非常小。如果拿不到当前线程可能会被 futexsleep 一小段时间。 +- 拿到锁之后,再次判断当前的 addr 的值,如果已经变成了大于 0,直接解锁,然后返回。 +- 在 treap 树中插入节点,入队 +- goparkunlock 进行调度,调度开始之前的时候会自动解锁。这样临界区结束。 +- 调度回来之后,判断是否真的可以返回,而不是误返回。误返回会重新入队并进入调度。 +- s.ticket 代表特殊对待当前的 G,代表着调用 release 函数的时候使用了 handoff 参数,这时候就不需要再利用 cansemacquire 去抢夺信号量了。 +- cansemacquire 是其他的 Goroutine 调用了 semrelease + +``` +func semroot(addr *uint32) *semaRoot { + return &semtable[(uintptr(unsafe.Pointer(addr))>>3)%semTabSize].root +} + +func cansemacquire(addr *uint32) bool { + for { + v := atomic.Load(addr) + if v == 0 { + return false + } + if atomic.Cas(addr, v, v-1) { + return true + } + } +} + +func semacquire(addr *uint32) { + semacquire1(addr, false, 0, 0) +} + +func semacquire1(addr *uint32, lifo bool, profile semaProfileFlags, skipframes int) { + gp := getg() + if gp != gp.m.curg { + throw("semacquire not on the G stack") + } + + // Easy case. + if cansemacquire(addr) { + return + } + + s := acquireSudog() + root := semroot(addr) + t0 := int64(0) + s.releasetime = 0 + s.acquiretime = 0 + s.ticket = 0 + + for { + lock(&root.lock) + // Add ourselves to nwait to disable "easy case" in semrelease. + atomic.Xadd(&root.nwait, 1) + // Check cansemacquire to avoid missed wakeup. + if cansemacquire(addr) { + atomic.Xadd(&root.nwait, -1) + unlock(&root.lock) + break + } + // Any semrelease after the cansemacquire knows we're waiting + // (we set nwait above), so go to sleep. + root.queue(addr, s, lifo) + goparkunlock(&root.lock, waitReasonSemacquire, traceEvGoBlockSync, 4+skipframes) // 开始调度 + if s.ticket != 0 || cansemacquire(addr) { // 调度返回 + break + } + } + + releaseSudog(s) +} + +``` + +## semrelease 过程 + +- 对 addr 自增 +- 首先判断 root.nwait 是否是 0,如果是的话,就是说明当前整个 root 都没有 G 等待 +- 获取锁,然后再次检查 +- treap 找到 addr 对应的节点,然后进行出队 +- 如果设置了 handoff 参数,那么直接递减 (*addr) 信号量,不需要取出的 G 再进行抢夺。 +- readyWithTime 赋值 s.releasetime,然后唤醒对应的 G + +``` +func semrelease(addr *uint32) { + semrelease1(addr, false, 0) +} + +func semrelease1(addr *uint32, handoff bool, skipframes int) { + root := semroot(addr) + atomic.Xadd(addr, 1) + + if atomic.Load(&root.nwait) == 0 { + return + } + + // Harder case: search for a waiter and wake it. + lock(&root.lock) + if atomic.Load(&root.nwait) == 0 { + // The count is already consumed by another goroutine, + // so no need to wake up another goroutine. + unlock(&root.lock) + return + } + s, t0 := root.dequeue(addr) + if s != nil { + atomic.Xadd(&root.nwait, -1) + } + unlock(&root.lock) + if s != nil { // May be slow, so unlock first + if s.ticket != 0 { + throw("corrupted semaphore ticket") + } + if handoff && cansemacquire(addr) { + s.ticket = 1 + } + readyWithTime(s, 5+skipframes) + } +} + +func readyWithTime(s *sudog, traceskip int) { + if s.releasetime != 0 { + s.releasetime = cputicks() + } + goready(s.g, traceskip) +} +``` + +## treap queue 入队过程 + +sudog 按照地址 hash 到 251 个 bucket 中的其中一个,每一个 bucket 都是一棵 treap。而相同 addr 上的 sudog 会形成一个链表。 + +为啥同一个地址的 sudog 不需要展开放在 treap 中呢?显然,sudog 唤醒的时候,block 在同一个 addr 上的 goroutine,说明都是加的同一把锁,这些 goroutine 被唤醒肯定是一起被唤醒的,相同地址的 g 并不需要查找才能找到,只要决定是先进队列的被唤醒(fifo)还是后进队列的被唤醒(lifo)就可以了。 + +### treap 树插入 + +要理解 treap 树相对于普通二叉搜索树的优点,我们需要先看看普通二叉搜索树的插入过程: + +``` +int BSTreeNodeInsertR(BSTreeNode **tree,DataType x) //搜索树的插入 +{ + if(*tree == NULL) + { + *tree = BuyTreeNode(x); + return 0; + } + + if ((*tree)->_data > x) + return BSTreeNodeInsertR(&(*tree)->_left,x); + else if ((*tree)->_data < x) + return BSTreeNodeInsertR(&(*tree)->_right,x); + else + return -1; +} + +``` + +我们可以看到,这段代码从根 root 开始递归查找,直到叶子节点,然后把自己挂到叶子节点的左孩子或者右孩子。这种插入方法很容易造成二叉树的不平衡,有的地方深度为 10,有的地方深度为 2。 + +AVL 树通过左旋或者右旋,可以实现不破坏二叉树的特征基础上减小局部的深度。但是 AVL 树要求过于严格,它不允许任何左右子树的深度差值超过 1,这样虽然查询特别快速,但是插入操作会进行很多左旋右旋的动作,效率比较低, + +红黑树采用节点黑、红的特点实现近似的 AVL 树,效率比较高,但是实现上过于繁琐。 + +跳表是另一种实现快速查找的数据结构,采用的是随机建立索引的方式,实现快速查找链表。 + +Golang 中大量使用的是 treap 树,这个树在二叉搜索树的基础上,在每个节点上加入随机 ticket。它的原理很简单,对于随机插入的二叉堆,大概率下二叉堆是近似平衡的。 + +我们可以利用这个规则,先找到需要插入的叶子节点,然后赋予一个随机数,利用这个随机数调整最小堆。调整最小堆的过程,并不是普通的直接替换父子节点即可,而是进行左旋与右旋操作,保障二叉树的性质: + +``` +func (root *semaRoot) queue(addr *uint32, s *sudog, lifo bool) { + s.g = getg() + s.elem = unsafe.Pointer(addr) + s.next = nil + s.prev = nil + + var last *sudog + pt := &root.treap + for t := *pt; t != nil; t = *pt { + last = t + if uintptr(unsafe.Pointer(addr)) < uintptr(t.elem) { + pt = &t.prev // 左子树 + } else { + pt = &t.next // 右子树 + } + } + + s.ticket = fastrand() | 1 // 随机数的生成 + s.parent = last + *pt = s + + // Rotate up into tree according to ticket (priority). + for s.parent != nil && s.parent.ticket > s.ticket { // 最小二叉堆的调整 + if s.parent.prev == s { + root.rotateRight(s.parent) // 右旋 + } else { + root.rotateLeft(s.parent) // 左旋 + } + } +} + +``` + +左旋和右旋的代码也比较简单: + +``` +func (root *semaRoot) rotateLeft(x *sudog) { + // p -> (x a (y b c)) + p := x.parent + a, y := x.prev, x.next + b, c := y.prev, y.next + + y.prev = x + x.parent = y + y.next = c // 个人认为是不必要的步骤 + if c != nil { + c.parent = y + } + + x.prev = a + if a != nil { + a.parent = x + } + x.next = b // 个人认为是不必要的步骤 + if b != nil { + b.parent = x + } + + y.parent = p + if p == nil { + root.treap = y + } else if p.prev == x { + p.prev = y + } else { + if p.next != x { + throw("semaRoot rotateLeft") + } + p.next = y + } +} + +func (root *semaRoot) rotateRight(y *sudog) { + // p -> (y (x a b) c) + p := y.parent + x, c := y.prev, y.next + a, b := x.prev, x.next + + x.prev = a + if a != nil { + a.parent = x + } + x.next = y + + y.parent = x + y.prev = b + if b != nil { + b.parent = y + } + y.next = c + if c != nil { + c.parent = y + } + + x.parent = p + if p == nil { + root.treap = x + } else if p.prev == y { + p.prev = x + } else { + if p.next != y { + throw("semaRoot rotateRight") + } + p.next = x + } +} +``` + +### sodog 链表的插入 + +当 treap 树中已经存在了节点,这个时候就需要更新等待的队列。 + +插入队尾比较简单,只需要更新前一个元素的 waitlink 即可。 + +但是插入队首比较麻烦,因为队首元素要替代之前的队首成为 treap 节点,之前的元素 + +``` +func (root *semaRoot) queue(addr *uint32, s *sudog, lifo bool) { + ... + if t.elem == unsafe.Pointer(addr) { + // Already have addr in list. + if lifo { + // 插入链表队首 + + // 继承之前队首的属性 + *pt = s + s.ticket = t.ticket + s.acquiretime = t.acquiretime + s.parent = t.parent + s.prev = t.prev + s.next = t.next + + // 更新 treap 节点的左右节点 + if s.prev != nil { + s.prev.parent = s + } + if s.next != nil { + s.next.parent = s + } + + // Add t first in s's wait list. + s.waitlink = t + s.waittail = t.waittail + if s.waittail == nil { + s.waittail = t + } + + // 重置之前队首节点的属性 + t.parent = nil + t.prev = nil + t.next = nil + t.waittail = nil + } else { + // 插入链表队尾 + if t.waittail == nil { + t.waitlink = s + } else { + t.waittail.waitlink = s + } + t.waittail = s + s.waitlink = nil + } + return + } + ... +} +``` + +## treap 出队过程 + + +``` +func (root *semaRoot) dequeue(addr *uint32) (found *sudog, now int64) { + ps := &root.treap + s := *ps + for ; s != nil; s = *ps { + if s.elem == unsafe.Pointer(addr) { + goto Found + } + if uintptr(unsafe.Pointer(addr)) < uintptr(s.elem) { + ps = &s.prev + } else { + ps = &s.next + } + } + return nil, 0 + +Found: + now = int64(0) + if s.acquiretime != 0 { + now = cputicks() + } + if t := s.waitlink; t != nil { + // 需要用 t 来替代 s 在 treap 的节点 + *ps = t + t.ticket = s.ticket + t.parent = s.parent + t.prev = s.prev + + if t.prev != nil { + t.prev.parent = t + } + t.next = s.next + if t.next != nil { + t.next.parent = t + } + + if t.waitlink != nil { + t.waittail = s.waittail + } else { + t.waittail = nil + } + + t.acquiretime = now + s.waitlink = nil + s.waittail = nil + } else { + // 在 treap 中删除 addr 节点 + + // 先调整左右子树 + // 注意这里是 for 循环,只要 s 还有左右子树就不断的调整 + // 左右子树,谁的 ticket 小,谁做父节点 + for s.next != nil || s.prev != nil { + if s.next == nil || s.prev != nil && s.prev.ticket < s.next.ticket { + root.rotateRight(s) + } else { + root.rotateLeft(s) + } + } + + // 删除 s 节点,s 现在是叶子节点 + if s.parent != nil { + if s.parent.prev == s { + s.parent.prev = nil + } else { + s.parent.next = nil + } + } else { + root.treap = nil + } + } + s.parent = nil + s.elem = nil + s.next = nil + s.prev = nil + s.ticket = 0 + return s, now +} + +```· + diff --git a/Go Slice.md b/Go Slice.md new file mode 100644 index 0000000..b74ef7b --- /dev/null +++ b/Go Slice.md @@ -0,0 +1,326 @@ +# Go Slice + +[TOC] + +## 数组 + +数组是由相同类型元素的集合组成的数据结构,计算机会为数组分配一块连续的内存来保存数组中的元素,我们可以利用数组中元素的索引快速访问元素对应的存储地址,常见的数组大多都是一维的线性数组。 + +数组作为一种数据类型,一般情况下由两部分组成,其中一部分表示了数组中存储的元素类型,另一部分表示数组最大能够存储的元素个数。 + +Go 语言中数组的大小在初始化之后就无法改变,数组存储元素的类型相同,但是大小不同的数组类型在 Go 语言看来也是完全不同的,只有两个条件都相同才是同一个类型。 + +``` +func NewArray(elem *Type, bound int64) *Type { + if bound < 0 { + Fatalf("NewArray: invalid bound %v", bound) + } + t := New(TARRAY) + t.Extra = &Array{Elem: elem, Bound: bound} + t.SetNotInHeap(elem.NotInHeap()) + return t +} + +``` + +编译期间的数组类型 Array 就包含两个结构,一个是元素类型 Elem,另一个是数组的大小上限 Bound,这两个字段构成了数组类型,而当前数组是否应该在堆栈中初始化也在编译期间就确定了。 + +### 创建 + +Go 语言中的数组有两种不同的创建方式,一种是我们显式指定数组的大小,另一种是编译器通过源代码自行推断数组的大小: + +``` +arr1 := [3]int{1, 2, 3} +arr2 := [...]int{1, 2, 3} + +``` + +后一种声明方式在编译期间就会被『转换』成为前一种,下面我们先来介绍数组大小的编译期推导过程。 + +这两种不同的方式会导致编译器做出不同的处理,如果我们使用第一种方式 [10]T,那么变量的类型在编译进行到 类型检查 阶段就会被推断出来,在这时编译器会使用 NewArray 创建包含数组大小的 Array 类型,而如果使用 [...]T 的方式,虽然在这一步也会创建一个 Array 类型 Array{Elem: elem, Bound: -1},但是其中的数组大小上限会是 -1 的结构,这意味着还需要后面的 typecheckcomplit 函数推导该数组的大小: + +``` +func typecheckcomplit(n *Node) (res *Node) { + // ... + + switch t.Etype { + case TARRAY, TSLICE: + var length, i int64 + nl := n.List.Slice() + for i2, l := range nl { + i++ + if i > length { + length = i + } + } + + if t.IsDDDArray() { + t.SetNumElem(length) + } + } +} + +func (t *Type) SetNumElem(n int64) { + t.wantEtype(TARRAY) + at := t.Extra.(*Array) + if at.Bound >= 0 { + Fatalf("SetNumElem array %v already has bound %d", t, at.Bound) + } + at.Bound = n +} +``` + +这个删减后的 typecheckcomplit 函数通过遍历元素来推导当前数组的长度,我们能看出 [...]T 类型的声明不是在运行时被推导的,它会在类型检查期间就被推断出正确的数组大小。 + +对于一个由字面量组成的数组,根据数组元素数量的不同,编译器会在负责初始化字面量的 anylit 函数中做两种不同的优化:如果数组中元素的个数小于或者等于 4 个,那么所有的变量会直接在栈上初始化,如果数组元素大于 4 个,变量就会在静态存储区初始化然后拷贝到栈上。 + +### 访问和赋值 + +无论是在栈上还是静态存储区,数组在内存中其实就是一连串的内存空间,表示数组的方法就是一个指向数组开头的指针,这一片内存空间不知道自己存储的是什么变量。 + +数组访问越界的判断也都是在编译期间由静态类型检查完成的。 + +无论是编译器还是字符串,它们的越界错误都会在编译期间发现,但是数组访问操作 OINDEX 会在编译期间被转换成两个 SSA 指令: + +``` +PtrIndex ptr idx +Load ptr mem + +``` + +编译器会先获取数组的内存地址和访问的下标,然后利用 PtrIndex 计算出目标元素的地址,再使用 Load 操作将指针中的元素加载到内存中。 + +数组的赋值和更新操作 a[i] = 2 也会生成 SSA 期间就计算出数组当前元素的内存地址,然后修改当前内存地址的内容,其实会被转换成如下所示的 SSA 操作: + +``` +LocalAddr {sym} base _ +PtrIndex ptr idx +Store {t} ptr val mem + +``` + +在这个过程中会确实能够目标数组的地址,再通过 PtrIndex 获取目标元素的地址,最后将数据存入地址中,从这里我们可以看出无论是数组的寻址还是赋值都是在编译阶段完成的,没有运行时的参与。 + +## 切片 + +在 Golang 中,切片类型的声明与数组有一些相似,由于切片是『动态的』,它的长度并不固定,所以声明类型时只需要指定切片中的元素类型. + +切片在编译期间的类型应该只会包含切片中的元素类型,NewSlice 就是编译期间用于创建 Slice 类型的函数: + +``` +func NewSlice(elem *Type) *Type { + if t := elem.Cache.slice; t != nil { + if t.Elem() != elem { + Fatalf("elem mismatch") + } + return t + } + + t := New(TSLICE) + t.Extra = Slice{Elem: elem} + elem.Cache.slice = t + return t +} + +``` + +我们可以看到上述方法返回的类型 TSLICE 的 Extra 字段是一个只包含切片内元素类型的 Slice{Elem: elem} 结构,也就是说切片内元素的类型是在编译期间确定的。 + +### 结构 + +编译期间的切片其实就是一个 Slice 类型,但是在运行时切片其实由如下的 SliceHeader 结构体表示,其中 Data 字段是一个指向数组的指针,Len 表示当前切片的长度,而 Cap 表示当前切片的容量,也就是 Data 数组的大小: + +``` +type SliceHeader struct { + Data uintptr + Len int + Cap int +} + +``` + +Data 作为一个指针指向的数组其实就是一片连续的内存空间,这片内存空间可以用于存储切片中保存的全部元素,数组其实就是一片连续的内存空间,数组中的元素只是逻辑上的概念,底层存储其实都是连续的,所以我们可以将切片理解成一片连续的内存空间加上长度与容量标识。 + +切片与数组不同,获取数组大小、对数组中的元素的访问和更新在编译期间就已经被转换成了数字和对内存的直接操作,但是切片是运行时才会确定的结构,所有的操作还需要依赖 Go 语言的运行时来完成,我们接下来就会介绍切片的一些常见操作的实现原理。 + +### 初始化 + +首先需要介绍的就是切片的创建过程,Go 语言中的切片总共有两种初始化的方式,一种是使用字面量初始化新的切片,另一种是使用关键字 make 创建切片: + +``` +slice := []int{1, 2, 3} +slice := make([]int, 10) + +``` + +对于字面量 slice,会在 SSA 代码生成阶段被转换成 OpSliceMake 操作 + +对于关键字 make,如果当前的切片不会发生逃逸并且切片非常小的时候,仍然会被转为 OpSliceMake 操作。否则会调用: + +``` +makeslice(type, len, cap) + +``` + +当切片的容量和大小不能使用 int 来表示时,就会实现 makeslice64 处理容量和大小更大的切片,无论是 makeslice 还是 makeslice64,这两个方法都是在结构逃逸到堆上初始化时才需要调用的。 + +接下来,我们回到用于创建切片的 makeslice 函数,这个函数的实现其实非常简单: + +``` +func makeslice(et *_type, len, cap int) unsafe.Pointer { + mem, overflow := math.MulUintptr(et.size, uintptr(cap)) + if overflow || mem > maxAlloc || len < 0 || len > cap { + mem, overflow := math.MulUintptr(et.size, uintptr(len)) + if overflow || mem > maxAlloc || len < 0 { + panicmakeslicelen() + } + panicmakeslicecap() + } + + return mallocgc(mem, et, true) +} + +``` + +上述代码的主要工作就是用切片中元素大小和切片容量相乘计算出切片占用的内存空间,如果内存空间的大小发生了溢出、申请的内存大于最大可分配的内存、传入的长度小于 0 或者长度大于容量,那么就会直接报错,当然大多数的错误都会在编译期间就检查出来,mallocgc 就是用于申请内存的函数,这个函数的实现还是比较复杂,如果遇到了比较小的对象会直接初始化在 Golang 调度器里面的 P 结构中,而大于 32KB 的一些对象会在堆上初始化。 + +### 访问 + +对切片常见的操作就是获取它的长度或者容量,这两个不同的函数 len 和 cap 其实被 Go 语言的编译器看成是两种特殊的操作 OLEN 和 OCAP,它们会在 SSA 生成阶段 被转换成 OpSliceLen 和 OpSliceCap 操作. + +除了获取切片的长度和容量之外,访问切片中元素使用的 OINDEX 操作也都在 SSA 中间代码生成期间就转换成对地址的获取操作. + +### 追加 + +向切片中追加元素应该是最常见的切片操作,在 Go 语言中我们会使用 append 关键字向切片中追加元素,追加元素会根据是否 inplace 在中间代码生成阶段转换成以下的两种不同流程,如果 append 之后的切片不需要赋值回原有的变量,也就是如 append(slice, 1, 2, 3) 所示的表达式会被转换成如下的过程: + +``` +ptr, len, cap := slice +newlen := len + 3 +if newlen > cap { + ptr, len, cap = growslice(slice, newlen) + newlen = len + 3 +} +*(ptr+len) = 1 +*(ptr+len+1) = 2 +*(ptr+len+2) = 3 +return makeslice(ptr, newlen, cap) + +``` + +我们会先对切片结构体进行解构获取它的数组指针、大小和容量,如果新的切片大小大于容量,那么就会使用 growslice 对切片进行扩容并将新的元素依次加入切片并创建新的切片,但是 slice = apennd(slice, 1, 2, 3) 这种 inplace 的表达式就只会改变原来的 slice 变量: + +``` +a := &slice +ptr, len, cap := slice +newlen := len + 3 +if uint(newlen) > uint(cap) { + newptr, len, newcap = growslice(slice, newlen) + vardef(a) + *a.cap = newcap + *a.ptr = newptr +} +newlen = len + 3 +*a.len = newlen +*(ptr+len) = 1 +*(ptr+len+1) = 2 +*(ptr+len+2) = 3 + +``` + +上述两段代码的逻辑其实差不多,最大的区别在于最后的结果是不是赋值会原有的变量,不过从 inplace 的代码可以看出 Go 语言对类似的过程进行了优化,所以我们并不需要担心 append 会在数组容量足够时导致发生切片的复制。 + +到这里我们已经了解了在切片容量足够时如何向切片中追加元素,但是如果切片的容量不足时就会调用 growslice 为切片扩容: + +``` +func growslice(et *_type, old slice, cap int) slice { + newcap := old.cap + doublecap := newcap + newcap + if cap > doublecap { + newcap = cap + } else { + if old.len < 1024 { + newcap = doublecap + } else { + for 0 < newcap && newcap < cap { + newcap += newcap / 4 + } + if newcap <= 0 { + newcap = cap + } + } + } + +``` + +扩容其实就是需要为切片分配一块新的内存空间,分配内存空间之前需要先确定新的切片容量,Go 语言根据切片的当前容量选择不同的策略进行扩容: + +- 如果期望容量大于当前容量的两倍就会使用期望容量; +- 如果当前切片容量小于 1024 就会将容量翻倍; +- 如果当前切片容量大于 1024 就会每次增加 25% 的容量,直到新容量大于期望容量; + +确定了切片的容量之后,我们就可以开始计算切片中新数组的内存占用了,计算的方法就是将目标容量和元素大小相乘: + +``` + var overflow bool + var lenmem, newlenmem, capmem uintptr + switch { + // ... + default: + lenmem = uintptr(old.len) * et.size + newlenmem = uintptr(cap) * et.size + capmem, overflow = math.MulUintptr(et.size, uintptr(newcap)) + capmem = roundupsize(capmem) + newcap = int(capmem / et.size) + } + + var p unsafe.Pointer + if et.kind&kindNoPointers != 0 { + p = mallocgc(capmem, nil, false) + memclrNoHeapPointers(add(p, newlenmem), capmem-newlenmem) + } else { + p = mallocgc(capmem, et, true) + if writeBarrier.enabled { + bulkBarrierPreWriteSrcOnly(uintptr(p), uintptr(old.array), lenmem) + } + } + memmove(p, old.array, lenmem) + + return slice{p, old.len, newcap} +} + +``` + +如果当前切片中元素不是指针类型,那么就会调用 memclrNoHeapPointers 函数将超出当前长度的位置置空并在最后使用 memmove 将原数组内存中的内容拷贝到新申请的内存中, 不过无论是 memclrNoHeapPointers 还是 memmove 函数都使用目标机器上的汇编指令进行实现. + +### 拷贝 + +``` +func slicecopy(to, fm slice, width uintptr) int { + if fm.len == 0 || to.len == 0 { + return 0 + } + + n := fm.len + if to.len < n { + n = to.len + } + + if width == 0 { + return n + } + + // ... + + size := uintptr(n) * width + if size == 1 { + *(*byte)(to.array) = *(*byte)(fm.array) + } else { + memmove(to.array, fm.array, size) + } + return n +} + +``` + +上述函数的实现非常直接,它将切片中的全部元素通过 memmove 或者数组指针的方式将整块内存中的内容拷贝到目标的内存区域. \ No newline at end of file diff --git a/Go Sync.md b/Go Sync.md new file mode 100644 index 0000000..be31cfe --- /dev/null +++ b/Go Sync.md @@ -0,0 +1,706 @@ +# Go Sync + +[TOC] + +## notifyList + +### 基本原理 + +相比 Semaphore 来说,sync.Cond 非常简单,它没有 treap 树,只有一个单链表连接着所有被 cond 阻塞的 G。 + +notifyList 有两个非常重要的成员,wait 和 notify,这两个都是 int 类型的变量,每次调用 cond.wait,wait 就自增 1,每次调用 cond.notify,notify 就自增 1,并且唤醒 sodog.ticket 和当前 notify 数值相同的 Goroutine。 + +### 数据结构 + +``` +type notifyList struct { + wait uint32 + + notify uint32 + + // List of parked waiters. + lock mutex + head *sudog + tail *sudog +} + +``` + +### notifyListAdd + +调用 wait 函数之前,要先调用 add 函数,自增 wait 属性。 + +``` +func notifyListAdd(l *notifyList) uint32 { + return atomic.Xadd(&l.wait, 1) - 1 +} + +``` + +### notifyListWait + +- 如果自增之后的 wait 还是小于等于 notify,那么说明其他 Goroutine 已经调用了 notify 函数,直接返回即可 +- 设置 ticket 为当前的 wait 数 +- 更新队列链表 +- goparkunlock 进行调度 + +``` +func notifyListWait(l *notifyList, t uint32) { + lock(&l.lock) + + // Return right away if this ticket has already been notified. + if less(t, l.notify) { + unlock(&l.lock) + return + } + + // Enqueue itself. + s := acquireSudog() + s.g = getg() + s.ticket = t + s.releasetime = 0 + t0 := int64(0) + + if l.tail == nil { + l.head = s + } else { + l.tail.next = s + } + l.tail = s + goparkunlock(&l.lock, waitReasonSyncCondWait, traceEvGoBlockCond, 3) + + releaseSudog(s) +} + +``` + +### notifyListNotifyOne + +- 这个函数先判断是否有新增的 waiter,如果没有那也不必 notify 了,直接返回 +- 加锁 +- 递增 notify 属性值 +- 在链表中找到 ticket 为 wait 的那个 sodog,唤醒它 + +``` +func notifyListNotifyOne(l *notifyList) { + // Fast-path: if there are no new waiters since the last notification + // we don't need to acquire the lock at all. + if atomic.Load(&l.wait) == atomic.Load(&l.notify) { + return + } + + lock(&l.lock) + + // Re-check under the lock if we need to do anything. + t := l.notify + if t == atomic.Load(&l.wait) { + unlock(&l.lock) + return + } + + // Update the next notify ticket number. + atomic.Store(&l.notify, t+1) + + for p, s := (*sudog)(nil), l.head; s != nil; p, s = s, s.next { + if s.ticket == t { + n := s.next + if p != nil { + p.next = n + } else { + l.head = n + } + if n == nil { + l.tail = p + } + unlock(&l.lock) + s.next = nil + readyWithTime(s, 4) + return + } + } + unlock(&l.lock) +} + +``` + +### notifyListNotifyAll + +``` +func notifyListNotifyAll(l *notifyList) { + // Fast-path: if there are no new waiters since the last notification + // we don't need to acquire the lock. + if atomic.Load(&l.wait) == atomic.Load(&l.notify) { + return + } + + lock(&l.lock) + s := l.head + l.head = nil + l.tail = nil + + atomic.Store(&l.notify, atomic.Load(&l.wait)) + unlock(&l.lock) + + // Go through the local list and ready all waiters. + for s != nil { + next := s.next + s.next = nil + readyWithTime(s, 4) + s = next + } +} + +``` + +## sync.Cond + +``` +// Cond 实现了一种条件变量,可以让 goroutine 都等待、或宣布一个事件的发生 +// +// 每一个 Cond 都有一个对应的 Locker L,可以是一个 *Mutex 或者 *RWMutex +// 当条件发生变化及调用 Wait 方法时,必须持有该锁 +// +// Cond 在首次使用之后同样不能被拷贝 +type Cond struct { + noCopy noCopy + + // 在观测或修改条件时,必须持有 L + L Locker + + notify notifyList + checker copyChecker +} + +func NewCond(l Locker) *Cond { + return &Cond{L: l} +} + +// Wait 会原子地解锁 c.L,并挂起当前调用 Wait 的 goroutine +// 之后恢复执行时,Wait 在返回之前对 c.L 加锁。和其它系统不一样 +// Wait 在被 Broadcast 或 Signal 唤醒之前,是不能返回的 +// +// 因为 c.L 在 Wait 第一次恢复执行之后是没有被锁住的,调用方 +// 在 Wait 返回之后没办法假定 condition 为 true。 +// 因此,调用方应该在循环中调用 Wait +// +// c.L.Lock() +// for !condition() { +// c.Wait() +// } +// .. 这时候 condition 一定为 true.. +// c.L.Unlock() +// +func (c *Cond) Wait() { + c.checker.check() + t := runtime_notifyListAdd(&c.notify) + c.L.Unlock() + runtime_notifyListWait(&c.notify, t) + c.L.Lock() +} + +// Signal 只唤醒等待在 c 上的一个 goroutine。 +// 对于 caller 来说在调用 Signal 时持有 c.L 也是允许的,不过没有必要 +func (c *Cond) Signal() { + c.checker.check() + runtime_notifyListNotifyOne(&c.notify) +} + +// Broadcast 唤醒所有在 c 上等待的 goroutine +// 同样在调用 Broadcast 时,可以持有 c.L,但没必要 +func (c *Cond) Broadcast() { + c.checker.check() + runtime_notifyListNotifyAll(&c.notify) +} + +// 检查结构体是否被拷贝过,因为其持有指向自身的指针 +// 指针值和实际地址不一致时,即说明发生了拷贝 +type copyChecker uintptr + +func (c *copyChecker) check() { + if uintptr(*c) != uintptr(unsafe.Pointer(c)) && + !atomic.CompareAndSwapUintptr((*uintptr)(c), 0, uintptr(unsafe.Pointer(c))) && + uintptr(*c) != uintptr(unsafe.Pointer(c)) { + panic("sync.Cond is copied") + } +} + +// noCopy may be embedded into structs which must not be copied +// after the first use. +// +// See https://golang.org/issues/8005#issuecomment-190753527 +// for details. +type noCopy struct{} + +// Lock is a no-op used by -copylocks checker from `go vet`. +func (*noCopy) Lock() {} + +``` + + +## sync.map + +### 用法 + +- Store 写入 +- Load 读取,返回值有两个,第一个是value,第二个是bool变量表示key是否存在 +- Delete 删除 +- LoadOrStore 存在就读,不存在就写 +- Range 遍历,注意遍历的快照 + +并发hashmap的方案有很多,下面简单提一下几种,然后再讨论golang实现时的考虑。 +第一种是最简单的,直接在不支持并发的hashmap上,使用一个读写锁的保护,这也是golang sync map还没出来前,大家常用的方法。这种方法的缺点是写会堵塞读。 + +第二种是数据库常用的方法,分段锁,每一个读写锁保护一段区间,golang的第三方库也有人是这么实现的。java的ConcurrentHashMap也是这么实现的。平均情况下这样的性能还挺好的,但是极端情况下,如果某个区间有热点写,那么那个区间的读请求也会受到影响。 + +第三种方法是我们C++自己造轮子时经常用的,使用使用链表法解决冲突,然后链表使用CAS去解决并发下冲突,这样读写都是无锁,我觉得这种挺好的,性能非常高,不知为啥其他语言不这么实现。 + +然后在《An overview of sync.Map》中有提到,在cpu核数很多的情况下,因为cache contention,reflect.New、sync.RWMutex、atomic.AddUint32都会很慢,golang团队为了适应cpu核很多的情况,没有采用上面的几种常见的方案。 + +golang sync map的目标是实现适合读多写少的场景、并且要求稳定性很好,不能出现像分段锁那样读经常被阻塞的情况。golang sync map基于map做了一层封装,在大部分情况下,不过写入性能比较差。下面来详细说说实现。 + +### 基本原理 + +要读受到的影响尽量小,那么最容易想到的想法,就是读写分离。golang sync map也是受到这个想法的启发(我自认为)设计出来的。使用了两个map,一个叫read,一个叫dirty,两个map存储的都是指针,指向value数据本身,所以两个map是共享value数据的,更新value对两个map同时可见。 + +#### 增 + +和普通的读写分离不同,并发 map 的 read 实际上除了读,还可以改和删,但是不能进行增。 + +read不能新增key,那么数据怎么来的呢?sync map中会记录miss cache的次数,当miss次数大于等于dirty元素个数时,就会把dirty变成read,原来的dirty清空。 + +为了方便dirty直接变成read,那么得保证read中存在的数据dirty必须有,所以在dirty是空的时候,如果要新增一个key,那么会把read中的元素复制到dirty中,然后写入新key。 + +我们可以把这个时间段称之为一个 map 周期。 + +#### 改查 + +对于 read 和 dirty 中都存在的 key,直接利用 CAS 对 read 进行删改查就可以了,由于 read 和 dirty 共享 entry,因此 read 一旦更改了 entry,dirty 那里的数据就被一并更改了。 + +#### 删 + +golang 的并发 map 并不是真正的删除 map 中的 key,而是在 read 中为 entry.p 赋值为 nil,这样 read 和 dirty 这个 key 就会共享 nil,代表数据已经被删除了。这么做,实际上是为删除的 key 做一个缓存,缓存时间为一个 map 周期,假如删除一段时间后,又想重新用这个 key,直接执行更新操作即可,对 map 的影响比较小。 + +前面说过,当 miss 的数量过多,会触发 read 被 dirty 替换,dirty 会被清空。这个时候,被删除的 key 会跟随着 dirty 来到 read,但是他的 value 仍然是 nil。 + +当又有新的 key 需要写入的时候,需要将 read 的所有 key 同步到 dirty 中去。那么 value 是 nil 的那些 key 如何处理呢? + +答案是,不会被同步到 dirty 中去,但是会把 read 中 value 从 nil 改为 expunged。这个时候被删除的 key 就危险了。 + +如果这个时候,key 被重利用,那么 read 会重新从 expunged 改为 nil,并且向 dirty 写入这个新的 key,它就像一个正常的 key 一样了。 + +但是如果这个 key 一直没有人重新赋值了,那么下一个周期,read 被 dirty 替换的时候,这个 key 就真的不存在了。 + +总结下就是: + +- 周期一:key 被删除,value 在 read 和 dirty 中都是 nil +- 周期二:key 只存在与 read,value 为 expunged;dirty 中没有这个 key +- 周期三:key 在 read 和 dirty 中都不存在。 + +### 数据结构 + +- Map 中的 mu 是保护 dirty 的锁 +- read 实际上是 readOnly 类型,只是 golang 使用了 atomic 对它进行了保护,让它可以在更新之后,立刻被其他的 Goroutine 看到 +- dirty 就是专门负责添加新元素的 map,注意 entry 是指针,它实际上和 readOnly 的 map 共享一个 entry +- misses 就是查询 read 失败的次数,达到一个阈值,就会将 dirty 替换 read + +注意对于 entry.p,有两个特殊值,一个是nil,另一个是expunged。我们在上面已经讲过了它的作用,分别代表着被删除的 key 所处的周期状态。 + +``` +type Map struct { + mu Mutex + + read atomic.Value // readOnly + + dirty map[interface{}]*entry + + misses int +} + +// readOnly is an immutable struct stored atomically in the Map.read field. +type readOnly struct { + m map[interface{}]*entry + amended bool // true if the dirty map contains some key not in m. +} + +// expunged is an arbitrary pointer that marks entries which have been deleted +// from the dirty map. +var expunged = unsafe.Pointer(new(interface{})) + +// An entry is a slot in the map corresponding to a particular key. +type entry struct { + p unsafe.Pointer // *interface{} +} + +``` + +![](img/map.png) + +### Load 读取 + +- 读取时,先去read读取; +- 如果没有,并且 read.amended 为 false,那么说明,自从 read 被 dirty 替换,还没有新的 key 写入,此时 ditry 为空,read 没有那就是真的没有,直接返回 nil,false +- 如果没有,而且 read.amended 为 true,说明有新的 key 写入到了 dirty。那么加锁,然后去 dirty 读取,同时调用 missLocked(),再解锁。 +- 在 missLocked 中,会递增 misses 变量,如果 misses>len(dirty),那么把 dirty 提升为 read,清空原来的dirty。 + +在代码中,我们可以看到一个double check,检查read没有,上锁,再检查read中有没有,是因为有可能在第一次检查之后,上锁之前的间隙,dirty提升为read了,这时如果不double check,可能会导致一个存在的key却返回给调用方说不存在。 在下面的其他操作中,我们经常会看到这个double check。 + +``` +func (m *Map) Load(key interface{}) (value interface{}, ok bool) { + read, _ := m.read.Load().(readOnly) + e, ok := read.m[key] + + if !ok && read.amended { + m.mu.Lock() + + read, _ = m.read.Load().(readOnly) + e, ok = read.m[key] + if !ok && read.amended { + e, ok = m.dirty[key] + + m.missLocked() + } + m.mu.Unlock() + } + + if !ok { + return nil, false + } + + return e.load() +} + +func (e *entry) load() (value interface{}, ok bool) { + p := atomic.LoadPointer(&e.p) + if p == nil || p == expunged { + return nil, false + } + return *(*interface{})(p), true +} + +func (m *Map) missLocked() { + m.misses++ + if m.misses < len(m.dirty) { + return + } + m.read.Store(readOnly{m: m.dirty}) + m.dirty = nil + m.misses = 0 +} +``` + +### Store 写入 + +- 写入的时候,先看read中能否查到key, + - 在read中存在的话,而且不是 expunged 状态,直接通过read中的entry来更新值;但是如果更新的过程中,被别的 G 更改为 expunged,那就不能再更新了,因为会涉及到 dirty 的写入。 + - 如果是 expunged 状态,因为涉及到 dirty 的写入,所以要加锁。 +- 在read中不存在,那么就上锁,然后double check。这里需要留意,分几种情况: + - double check发现read中存在,直接更新。 + - double check发现read中存在,但是是expunged,那么有可能加锁之前就是 expunged 状态,或者在加锁的过程中,这个 key 经历了周期一的删除,现在处于周期二的 read 中。我们需要将 expunged 改为 nil,并且将 key 添加到 dirty 中,然后更新它的值。 +- dirty中存在,直接更新。说明这个 key 是当前这个周期新建的 key,只存在与 dirty 中,还没有同步到 read 中去。 +- read 和 dirty中都不存在,那就是说明此时不是更新操作,而是插入的操作: + - 如果 read.amended 为 false,那这次就是自从 read 被 dirty 替换之后的第一次新 key 插入。需要将read复制到dirty中,最后再把新值写入到dirty中。复制的时候调用的是dirtyLocked(),在复制到dirty的时候,read中为nil的元素,会更新为expunged,并且不复制到dirty中。 + - 如果 read.amended 为 true,那么直接对 dirty 插入新增就可以了 + +我们可以看到,在更新read中的数据时,使用的是tryStore,通过CAS来解决冲突,在CAS出现冲突后,如果发现数据被置为expung,tryStore那么就不会写入数据,而是会返回false,在Store流程中,就是接着往下走,在dirty中写入。 + + +``` +func (m *Map) Store(key, value interface{}) { + read, _ := m.read.Load().(readOnly) + if e, ok := read.m[key]; ok && e.tryStore(&value) { + return + } + + m.mu.Lock() + read, _ = m.read.Load().(readOnly) + if e, ok := read.m[key]; ok { + if e.unexpungeLocked() { + // 将 expunged 转为 nil + m.dirty[key] = e + } + e.storeLocked(&value) + } else if e, ok := m.dirty[key]; ok { + e.storeLocked(&value) + } else { + if !read.amended { + // We're adding the first new key to the dirty map. + // Make sure it is allocated and mark the read-only map as incomplete. + m.dirtyLocked() + m.read.Store(readOnly{m: read.m, amended: true}) + } + m.dirty[key] = newEntry(value) + } + m.mu.Unlock() +} + +func (e *entry) tryStore(i *interface{}) bool { + for { + p := atomic.LoadPointer(&e.p) + if p == expunged { + return false + } + if atomic.CompareAndSwapPointer(&e.p, p, unsafe.Pointer(i)) { + return true + } + } +} + +func (e *entry) unexpungeLocked() (wasExpunged bool) { + return atomic.CompareAndSwapPointer(&e.p, expunged, nil) +} + +func (m *Map) dirtyLocked() { + if m.dirty != nil { + return + } + + read, _ := m.read.Load().(readOnly) + m.dirty = make(map[interface{}]*entry, len(read.m)) + for k, e := range read.m { + if !e.tryExpungeLocked() { // 将 nil 转为 expunged 状态,并且不会赋值给 dirty + m.dirty[k] = e + } + } +} + +func (e *entry) tryExpungeLocked() (isExpunged bool) { + p := atomic.LoadPointer(&e.p) + for p == nil { + if atomic.CompareAndSwapPointer(&e.p, nil, expunged) { + return true + } + p = atomic.LoadPointer(&e.p) + } + return p == expunged +} +``` + +### Delete 删除 + +删除很简单,read中存在,就把read中的entry.p置为nil,如果只在ditry中存在,那么就直接从dirty中删掉对应的entry。 + +``` +func (m *Map) Delete(key interface{}) { + read, _ := m.read.Load().(readOnly) + e, ok := read.m[key] + if !ok && read.amended { + m.mu.Lock() + read, _ = m.read.Load().(readOnly) + e, ok = read.m[key] + if !ok && read.amended { + delete(m.dirty, key) + } + m.mu.Unlock() + } + if ok { + e.delete() + } +} + +func (e *entry) delete() (hadValue bool) { + for { + p := atomic.LoadPointer(&e.p) + if p == nil || p == expunged { + return false + } + if atomic.CompareAndSwapPointer(&e.p, p, nil) { + return true + } + } +} + +``` + +## sync.waitgroup + +我们先了解下 waitgroup 的用法,它最常用的场景是需要并发 n 个 G,需要等待 n 个 G 全部结束后,跑接下来的代码。 + +- 首先会在当前 G 中调用 add 函数,传入一个数目 n; +- 然后开始创建 Goroutine,并调用 wait 函数,阻塞 +- 每个 G 在将要结束的时候,调用 done 函数 +- 所有的 G 运行接受之后,n 被减为 0,这时候唤醒被 wait 阻塞的 G + +waitgroup 的原理也是十分简单,属性中有一个 state1 的 64 位数组,前 32 位代表着当前的 waitgroup 现有的counter 数目,后 32 为代表着调用 waitgroup.wait 的协程数目。 + +- 当调用 add 函数的时候,前 32 为加上 n 数目 +- 当调用 done 函数的时候,前 32 为减 1 +- 当调用 wait 函数的时候,递增后 32 位,并且阻塞在 sema 信号量上 +- 当最后一个 G 调用 done 函数后,发现前 32 已经变成了 0,开始利用 sema 信号唤醒所有的等待者 + +``` +// 在主 goroutine 中 Add 和 Wait,在其它 goroutine 中 Done +// 在第一次使用之后,不能对 WaitGroup 再进行拷贝 +type WaitGroup struct { + noCopy noCopy + + // state1 的高 32 位是计数器,低 32 位是 waiter 计数 + // 64 位的 atomic 操作需要按 64 位对齐,但是 32 位编译器没法保证这种对齐 + // 所以分配 12 个字节(多分配了 4 个字节) + // 当 state 没有按 8 对齐时,我们可以偏 4 个字节来使用 + // 按 8 对齐时: + // 0000...0000 0000...0000 0000...0000 + // |- 4 bytes-| |- 4 bytes -| |- 4 bytes -| + // 使用 使用 不使用 + // 没有按 8 对齐时: + // |- 4 bytes-| |- 4 bytes -| |- 4 bytes -| + // 不使用 使用 使用 + // |-low-> ---------> ------> -----------> high-| + state1 [12]byte + sema uint32 +} + +func (wg *WaitGroup) state() *uint64 { + // 判断 state 是否按照 8 字节对齐 + if uintptr(unsafe.Pointer(&wg.state1))%8 == 0 { + // 已对齐时,使用低 8 字节即可 + return (*uint64)(unsafe.Pointer(&wg.state1)) + } else { + // 未对齐时,使用高 8 字节 + return (*uint64)(unsafe.Pointer(&wg.state1[4])) + } +} + +// Add 一个 delta,delta 可能是负值,在 WaitGroup 的 counter 上增加该值 +// 如果 counter 变成 0,所有阻塞在 Wait 函数上的 goroutine 都会被释放 +// 如果 counter 变成了负数,Add 会直接 panic +// 当 counter 是 0 且 Add 的 delta 为正的操作必须发生在 Wait 调用之前。 +// 而当 counter > 0 且 Add 的 delta 为负的操作则可以发生在任意时刻。 +// 一般来讲,Add 操作应该在创建 goroutine 或者其它需要等待的事件发生之前调用 +// 如果 wg 被用来等待几组独立的事件集合 +// 新的 Add 调用应该在所有 Wait 调用返回之后再调用 +// 参见 wg 的 example +func (wg *WaitGroup) Add(delta int) { + statep := wg.state() + + state := atomic.AddUint64(statep, uint64(delta)<<32) + v := int32(state >> 32) // counter 高位 4 字节 + w := uint32(state) // waiter counter,截断,取低位 4 个字节 + + if v < 0 { + panic("sync: negative WaitGroup counter") + } + if w != 0 && delta > 0 && v == int32(delta) { + panic("sync: WaitGroup misuse: Add called concurrently with Wait") + } + if v > 0 || w == 0 { + return + } + + // 当前 goroutine 已经把 counter 设为 0,且 waiter 数 > 0 + // 这时候不能有状态的跳变 + // - Add 不能和 Wait 进行并发调用 + // - Wait 如果发现 counter 已经等于 0,则不应该对 waiter 数加一了 + // 这里是对 wg 误用的简单检测 + if *statep != state { + panic("sync: WaitGroup misuse: Add called concurrently with Wait") + } + + // 此时 v 为 0,代表着可以唤醒了 + // 重置 waiter 计数为 0 + *statep = 0 + for ; w != 0; w-- { + runtime_Semrelease(&wg.sema, false) + } +} + +// Done 其实就是 wg 的 counter - 1 +// 进入 Add 函数后 +// 如果 counter 变为 0 会触发 runtime_Semrelease 通知所有阻塞在 Wait 上的 g +func (wg *WaitGroup) Done() { + wg.Add(-1) +} + +// Wait 会阻塞直到 wg 的 counter 变为 0 +func (wg *WaitGroup) Wait() { + statep := wg.state() + + for { + state := atomic.LoadUint64(statep) + v := int32(state >> 32) // counter + w := uint32(state) // waiter count + if v == 0 { // counter + return + } + + // 如果没成功,可能有并发,循环再来一次相同流程 + // 成功直接返回 + if atomic.CompareAndSwapUint64(statep, state, state+1) { + runtime_Semacquire(&wg.sema) // 和上面的 Add 里的 runtime_Semrelease 是对应的 + if *statep != 0 { + panic("sync: WaitGroup is reused before previous Wait has returned") + } + return + } + } +} + +``` + +不过新的代码又优化了数据结构 + +现在如果在 64 系统中,最后的 32bit 也不浪费,变成了信号量。 + +如果在 32 系统中,前 32bit 是信号量,后面 64bit 还是 counter 和 waiter。 + +代码原理没有大的变化。 + +``` +type WaitGroup struct { + noCopy noCopy + + // 64-bit value: high 32 bits are counter, low 32 bits are waiter count. + // 64-bit atomic operations require 64-bit alignment, but 32-bit + // compilers do not ensure it. So we allocate 12 bytes and then use + // the aligned 8 bytes in them as state, and the other 4 as storage + // for the sema. + state1 [3]uint32 +} + +func (wg *WaitGroup) state() (statep *uint64, semap *uint32) { + if uintptr(unsafe.Pointer(&wg.state1))%8 == 0 { + return (*uint64)(unsafe.Pointer(&wg.state1)), &wg.state1[2] + } else { + return (*uint64)(unsafe.Pointer(&wg.state1[1])), &wg.state1[0] + } +} +``` + +## sync.once + +golang 的注释解释了为何不建议直接进行 CAS 操作,为何一定要用到锁 mutex,原因是如果 2 个 G 并发调用,要保证 2 个 G 返回的时候,F 已经执行完毕。如果按照注释那种代码,失败的 G 会立刻返回,但是此时 F 还未执行完毕。 + +``` +type Once struct { + done uint32 + m Mutex +} + +func (o *Once) Do(f func()) { + // Note: Here is an incorrect implementation of Do: + // + // if atomic.CompareAndSwapUint32(&o.done, 0, 1) { + // f() + // } + // + // Do guarantees that when it returns, f has finished. + // This implementation would not implement that guarantee: + // given two simultaneous calls, the winner of the cas would + // call f, and the second would return immediately, without + // waiting for the first's call to f to complete. + // This is why the slow path falls back to a mutex, and why + // the atomic.StoreUint32 must be delayed until after f returns. + if atomic.LoadUint32(&o.done) == 0 { + // Outlined slow-path to allow inlining of the fast-path. + o.doSlow(f) + } +} + +func (o *Once) doSlow(f func()) { + o.m.Lock() + defer o.m.Unlock() + if o.done == 0 { + defer atomic.StoreUint32(&o.done, 1) + f() + } +} +``` \ No newline at end of file diff --git "a/Go Sync\342\200\224\342\200\224Mutex.md" "b/Go Sync\342\200\224\342\200\224Mutex.md" new file mode 100644 index 0000000..9c22829 --- /dev/null +++ "b/Go Sync\342\200\224\342\200\224Mutex.md" @@ -0,0 +1,552 @@ +# Go Sync——Mutex + +[TOC] + +## 基本原理 + +锁的整体设计有以下几点: + +- CAS原子操作。 +- 需要有一种阻塞和唤醒机制。 +- 尽量减少阻塞和唤醒切换成本。 +- 锁尽量公平,后来者要排队。即使被后来者插队了,也要照顾先来者,不能有“饥饿”现象。 + +### CAS 原子操作 + +只有通过 CAS 原子操作,我们才能够原子的更改 mutex 的状态,否则很有可能出现多个协程同时进入临界区的情况。 + +### 阻塞与唤醒 + +阻塞和唤醒机制是 mutex 必要的功能,这个 Golang 完全依赖信号量 sema。 + +### 自旋 spin + +减少切换成本的方法就是不切换,简单而直接。不切换的方式就是让竞争者自旋。自旋一会儿,然后抢锁。不成功就再自旋。到达上限次数才阻塞。 + +不同平台上自旋所用的指令不一样。例如在amd64平台下,汇编的实现如下: + +``` +func sync_runtime_doSpin() { + procyield(active_spin_cnt) +} + +active_spin_cnt = 30 + +TEXT runtime·procyield(SB),NOSPLIT,$0-0 + MOVL cycles+0(FP), AX +again: + // 自旋cycles次,每次自旋执行PAUSE指令 + PAUSE + SUBL $1, AX + JNZ again + RET + +``` + +是否允许自旋的判断是严格的。而且最多自旋四次,每次30个CPU时钟周期。 + +能不能自旋全由这个条件语句决定 `if old&(mutexLocked|mutexStarving) == mutexLocked && runtime_canSpin(iter)`。 + +``` +const active_spin = 4 +func sync_runtime_canSpin(i int) bool { + // 自旋次数不能大于 active_spin(4) 次 + // cpu核数只有一个,不能自旋 + // 没有空闲的p了,不能自旋 + if i >= active_spin || ncpu <= 1 || gomaxprocs <= int32(sched.npidle+sched.nmspinning)+1 { + return false + } + // 当前g绑定的p里面本地待运行队列不为空,不能自旋 + if p := getg().m.p.ptr(); !runqempty(p) { + return false + } + return true +} + +``` + +- 锁已被占用,并且锁不处于饥饿模式。 +- 积累的自旋次数小于最大自旋次数(active_spin=4)。 +- cpu核数大于1。 +- 有空闲的P。 +- 当前goroutine所挂载的P下,本地待运行队列为空。 + +可以看到自旋要求严格,毕竟在锁竞争激烈时,还无限制地自旋就肯定会影响其他goroutine。 + +### mutex 结构 + +Mutex结构简单的就只有两个成员变量。sema是信号量。 + +``` +type Mutex struct { + // [阻塞的goroutine个数, starving标识, woken标识, locked标识] + state int32 + sema uint32 +} +``` + +这里主要介绍state的结构: + +![](img/mutex.png) + +一个32位的变量,被划分成上图的样子。右边的标识也有对应的常量: + +``` +const ( + mutexLocked = 1 << iota // mutex is locked + mutexWoken + mutexStarving + mutexWaiterShift = iota +) +``` + +含义如下: + +- mutexLocked对应右边低位第一个bit。值为1,表示锁被占用。值为0,表示锁未被占用。 +- mutexWoken对应右边低位第二个bit。值为1,表示打上唤醒标记。值为0,表示没有唤醒标记。 +- mutexStarving对应右边低位第三个bit。值为1,表示锁处于饥饿模式。值为0,表示锁存于正常模式。 +- mutexWaiterShift是偏移量。它值为3。用法是state>>=mutexWaiterShift之后,state的值就表示当前阻塞等待锁的goroutine个数。最多可以阻塞2^29个goroutine。 + + +## mutex 模式: 空闲/正常/饥饿/唤醒 + +### 空闲模式 + +在 Golang 中,抢锁实际上要先试图把 mutex 从 Null 状态转为 mutexLocked 状态: + +``` +func (m *Mutex) Lock() { + // Fast path: grab unlocked mutex. + if atomic.CompareAndSwapInt32(&m.state, 0, mutexLocked) { + return + } + + m.lockSlow() +} + +``` +值得注意的是这个 CAS 的初值为 0,这个是在 mutex 资源很空闲的情况,一步到位抢锁成功的情况。 + +但凡 mutex 进入了自旋、锁死、唤醒、饥饿等等状态,这个 CAS 操作都不会成功。 + +### 正常模式 + +#### 自旋?/加锁?/CAS 成功? + +正常模式下,对于新来的 goroutine 而言, + +- 发现此时 mutex 已经被锁住,它首先会尝试自旋, +- 如果 mutex 并没有被锁,或者不符合自旋条件,直接尝试抢锁。 +- 符合自旋条件的,说明此时锁已经被占用,开始自旋。自旋过程中会设置 mutexWoken 标志,这样只要 unlock 过程中发现了 mutexWoken 标志,那么 unlock 就不会试图唤醒排队的 G,自旋的 G 可以立刻拿到锁。 +- 自旋结束的,取消 woken 状态 +- 如果锁此时是占用状态,那么就对 wait 自增。 +- 接下来,如果此时锁还没有被占用,那就开始试图利用 CAS 加锁。 +- 如果加锁失败,那说明存在并发的 lock 操作,重新开始即可 +- 此时 CAS 加锁成功,并且之前是未加锁状态,那么直接结束。 + +``` +func (m *Mutex) lockSlow() { +var waitStartTime int64 + starving := false + awoke := false + iter := 0 + old := m.state + for { + if old&(mutexLocked|mutexStarving) == mutexLocked && runtime_canSpin(iter) { + if !awoke && old&mutexWoken == 0 && old>>mutexWaiterShift != 0 && + atomic.CompareAndSwapInt32(&m.state, old, old|mutexWoken) { + awoke = true + } + runtime_doSpin() + iter++ + old = m.state + continue + } + + new := old + + ... + new |= mutexLocked + + if old&(mutexLocked) != 0 { + new += 1 << mutexWaiterShift + } + + if awoke { + new &^= mutexWoken + } + + if atomic.CompareAndSwapInt32(&m.state, old, new) { + if old&(mutexLocked|mutexStarving) == 0 { + break // locked the mutex with CAS + } + ... + } else { + old = m.state + } + } +} +``` + +#### 阻塞 + +CAS 操作成功之后: + +- 如果之前已经加锁了,CAS 只是增加等待的 G 个数,那么接下来就得考虑进行阻塞了 +- 首先更新 waitStartTime,代表第一次阻塞时间 +- runtime_SemacquireMutex 利用信号量进行阻塞,由于是第一次阻塞,直接放到等待队列的尾部即可。 + +``` + if atomic.CompareAndSwapInt32(&m.state, old, new) { + // If we were already waiting before, queue at the front of the queue. + queueLifo := 0 + if waitStartTime == 0 { + waitStartTime = runtime_nanotime() + } + + runtime_SemacquireMutex(&m.sema, queueLifo, 1) + + ... + } + +``` + +#### Unlock 解锁 + +- 解锁之后,如果发现 state 直接为 0 了,说明没有 G 等待着 mutex,直接返回即可。 +- 为 mutex.state 添加 mutexWoken 标志,试图让自旋的 G 快速获得 mutex。 +- 不断循环直到成功,或者期间其他 G 自旋或者加锁成功。 + +``` +func (m *Mutex) Unlock() { + // Fast path: drop lock bit. + new := atomic.AddInt32(&m.state, -mutexLocked) + if new != 0 { + // Outlined slow path to allow inlining the fast path. + // To hide unlockSlow during tracing we skip one extra frame when tracing GoUnblock. + m.unlockSlow(new) + } +} + +func (m *Mutex) unlockSlow(new int32) { + if new&mutexStarving == 0 { // 非饥饿状态 + old := new + for { + // 没有等待的 G,直接返回 + // 如果此时 mutexWoken 标志已经被置 1,那么让自旋的 G 抢到锁,不需要从等待队列中去取 + // 如果此时锁已经被占用,那说明有新的 G 抢到了锁,直接返回 + if old>>mutexWaiterShift == 0 || old&(mutexLocked|mutexWoken) != 0 { + return + } + + // 加入 mutexWoken 标志,表明当前锁并不是空闲状态,因此此时还有 G 在等待着锁 + new = (old - 1< starvationThresholdNs + old = m.state + ... + awoke = true + iter = 0 + } + } + +``` + +### 饥饿模式 + +饥饿模式下,对于新来的goroutine,它只有一个选择,就是追加到阻塞队列尾部,等待被唤醒的。而且在该模式下,所有锁竞争者都不能自旋。 + +#### 饥饿模式 lock + +- 饥饿模式不允许自旋, +- 饥饿模式也不允许试图加锁 +- 饥饿模式下,只能递增 mutex 的 wait 数量 +- 将当前 G 阻塞 + +``` +func (m *Mutex) lockSlow() { + old := m.state + for { + new := old + if old&(mutexLocked|mutexStarving) != 0 { + new += 1 << mutexWaiterShift + } + + if atomic.CompareAndSwapInt32(&m.state, old, new) { + runtime_SemacquireMutex(&m.sema, queueLifo, 1) + + ... + } else { + old = m.state + } + } +} + +``` + +#### 饥饿模式 unlock + +饥饿模式下,调用 runtime_Semrelease,并且使用 handoff 参数唤醒等待队列,handoff 的作用就是在调度等待队列的时候,确保其他 G 调用 runtime_SemacquireMutex 会被阻塞。 + +``` +func (m *Mutex) unlockSlow(new int32) { + if (new+mutexLocked)&mutexLocked == 0 { + throw("sync: unlock of unlocked mutex") + } + if new&mutexStarving == 0 { + ... + } else { + runtime_Semrelease(&m.sema, true, 1) + } +} + +``` + +#### 饥饿模式唤醒 + +处于饥饿模式的 G 重新被唤醒之后,如果 mutex 的等待队列为空,那么就取消饥饿模式。 + +``` +if atomic.CompareAndSwapInt32(&m.state, old, new) { + ... + runtime_SemacquireMutex(&m.sema, queueLifo, 1) + starving = starving || runtime_nanotime()-waitStartTime > starvationThresholdNs + old = m.state + if old&mutexStarving != 0 { + delta := int32(mutexLocked - 1<>mutexWaiterShift == 1 { + delta -= mutexStarving + } + atomic.AddInt32(&m.state, delta) + break + } + awoke = true + iter = 0 +} + +``` + +## 完全版 + + +``` +func (m *Mutex) Lock() { + // 尝试CAS上锁 + if atomic.CompareAndSwapInt32(&m.state, 0, mutexLocked) { + return + } + // 上锁成功,直接返回 + m.lockSlow() +} + +func (m *Mutex) lockSlow() { + var waitStartTime int64 + starving := false + awoke := false + iter := 0 + old := m.state + for { + // Don't spin in starvation mode, ownership is handed off to waiters + // so we won't be able to acquire the mutex anyway. + if old&(mutexLocked|mutexStarving) == mutexLocked && runtime_canSpin(iter) { + // Active spinning makes sense. + // Try to set mutexWoken flag to inform Unlock + // to not wake other blocked goroutines. + if !awoke && old&mutexWoken == 0 && old>>mutexWaiterShift != 0 && + atomic.CompareAndSwapInt32(&m.state, old, old|mutexWoken) { + awoke = true + } + runtime_doSpin() + iter++ + old = m.state + continue + } + new := old + // Don't try to acquire starving mutex, new arriving goroutines must queue. + if old&mutexStarving == 0 { + new |= mutexLocked + } + if old&(mutexLocked|mutexStarving) != 0 { + new += 1 << mutexWaiterShift + } + // The current goroutine switches mutex to starvation mode. + // But if the mutex is currently unlocked, don't do the switch. + // Unlock expects that starving mutex has waiters, which will not + // be true in this case. + if starving && old&mutexLocked != 0 { + new |= mutexStarving + } + if awoke { + // The goroutine has been woken from sleep, + // so we need to reset the flag in either case. + if new&mutexWoken == 0 { + throw("sync: inconsistent mutex state") + } + new &^= mutexWoken + } + if atomic.CompareAndSwapInt32(&m.state, old, new) { + if old&(mutexLocked|mutexStarving) == 0 { + break // locked the mutex with CAS + } + // If we were already waiting before, queue at the front of the queue. + queueLifo := waitStartTime != 0 + if waitStartTime == 0 { + waitStartTime = runtime_nanotime() + } + runtime_SemacquireMutex(&m.sema, queueLifo, 1) + starving = starving || runtime_nanotime()-waitStartTime > starvationThresholdNs + old = m.state + if old&mutexStarving != 0 { + // If this goroutine was woken and mutex is in starvation mode, + // ownership was handed off to us but mutex is in somewhat + // inconsistent state: mutexLocked is not set and we are still + // accounted as waiter. Fix that. + if old&(mutexLocked|mutexWoken) != 0 || old>>mutexWaiterShift == 0 { + throw("sync: inconsistent mutex state") + } + delta := int32(mutexLocked - 1<>mutexWaiterShift == 1 { + // Exit starvation mode. + // Critical to do it here and consider wait time. + // Starvation mode is so inefficient, that two goroutines + // can go lock-step infinitely once they switch mutex + // to starvation mode. + delta -= mutexStarving + } + atomic.AddInt32(&m.state, delta) + break + } + awoke = true + iter = 0 + } else { + old = m.state + } + } +} +``` + +## sync.RWMutex + +读写锁的设计比较简单,它的实现是建立在 mutex 锁的基础上的。 + +它的原理也很简单: + +- 加读锁:只需要递增 readerCount 即可,无需加锁,因为读锁是允许并发读的 +- 解读锁:只需要递减 readerCount 即可。 +- 加写锁: + - 因为写锁是独占的,所以就需要先利用 w.lock 加锁。 + - 加锁成功之后,首先要将 readerCount 减去一个常量,代表着这个时候有写锁在等待,更新 readerWait 的值为此时 readerCount 的值,代表着位于读锁前面的,写锁要等待的读锁的个数。 + - 使用 writerSem 信号量阻塞当前的 G +- 加读锁:这个时候如果再想加读锁,就没有那么简单了。这时候发现 readerCount 小于 0,那么就说明此时有写锁,此时仍然递增 readerCount,告诉写锁,自己被阻塞了。然后利用信号量 readerSem 阻塞住当前的 Goroutine。 +- 解读锁:递减 readerCount 后发现 readerCount 小于 0,说明我们在临界区的时候,有写锁尝试加锁失败,那么此时我们还需要递减 readerWait,如果 readerWait 为 0 了,说明写锁前面的读锁已经全部处理完毕,使用 writerSem 信号量唤醒写锁所在的 G +- 解写锁:当写锁保护的临界区完毕之后,readerCount 代表着所有等待着写锁的读锁个数,使用 readerSem 唤醒这些阻塞的 G + + +``` +type RWMutex struct { + w Mutex // held if there are pending writers + writerSem uint32 // semaphore for writers to wait for completing readers + readerSem uint32 // semaphore for readers to wait for completing writers + readerCount int32 // number of pending readers + readerWait int32 // number of departing readers +} + +func (rw *RWMutex) RLock() { + if atomic.AddInt32(&rw.readerCount, 1) < 0 { + // A writer is pending, wait for it. + runtime_SemacquireMutex(&rw.readerSem, false, 0) + } +} + +func (rw *RWMutex) RUnlock() { + if race.Enabled { + _ = rw.w.state + race.ReleaseMerge(unsafe.Pointer(&rw.writerSem)) + race.Disable() + } + if r := atomic.AddInt32(&rw.readerCount, -1); r < 0 { + // Outlined slow-path to allow the fast-path to be inlined + rw.rUnlockSlow(r) + } + if race.Enabled { + race.Enable() + } +} + +func (rw *RWMutex) rUnlockSlow(r int32) { + if r+1 == 0 || r+1 == -rwmutexMaxReaders { + race.Enable() + throw("sync: RUnlock of unlocked RWMutex") + } + // A writer is pending. + if atomic.AddInt32(&rw.readerWait, -1) == 0 { + // The last reader unblocks the writer. + runtime_Semrelease(&rw.writerSem, false, 1) + } +} + +func (rw *RWMutex) Lock() { + rw.w.Lock() + // Announce to readers there is a pending writer. + r := atomic.AddInt32(&rw.readerCount, -rwmutexMaxReaders) + rwmutexMaxReaders + // Wait for active readers. + if r != 0 && atomic.AddInt32(&rw.readerWait, r) != 0 { + runtime_SemacquireMutex(&rw.writerSem, false, 0) + } +} + + +func (rw *RWMutex) Unlock() { + // Announce to readers there is no active writer. + r := atomic.AddInt32(&rw.readerCount, rwmutexMaxReaders) + + // Unblock blocked readers, if any. + for i := 0; i < int(r); i++ { + runtime_Semrelease(&rw.readerSem, false, 0) + } + // Allow other writers to proceed. + rw.w.Unlock() +} + +``` \ No newline at end of file diff --git "a/Go interface \345\217\215\345\260\204.md" "b/Go interface \345\217\215\345\260\204.md" new file mode 100644 index 0000000..47c0bcb --- /dev/null +++ "b/Go interface \345\217\215\345\260\204.md" @@ -0,0 +1,1148 @@ +# Go interface 反射 +[TOC] + +## 反射用法 + +### 反射定律 + +#### 从接口值到反射对象的反射 + +反射是一种检查存储在接口变量中的(类型,值)对的机制。作为一个开始,我们需要知道reflect包中的两个类型:Type和Value。这两种类型给了我们访问一个接口变量中所包含的内容的途径,另外两个简单的函数reflect.Typeof和reflect.Valueof可以检索一个接口值的reflect.Type和reflect.Value部分。 + +``` +package main + +import ( + "fmt" + "reflect" +) + +func main() { + var x float64 = 3.4 + fmt.Println("type:", reflect.TypeOf(x)) +} + +``` + +reflect.Typeof 签名里就包含了一个空接口: + +``` +func TypeOf(i interface{}) Type + +``` + +当我们调用reflect.Typeof(x)的时候,x首先被保存到一个空接口中,这个空接口然后被作为参数传递。reflect.Typeof 会把这个空接口拆包(unpack)恢复出类型信息。 + +当然,reflect.Valueof可以把值恢复出来 + +``` +var x float64 = 3.4 +fmt.Println("value:", reflect.ValueOf(x))//Valueof方法会返回一个Value类型的对象 + +``` + +reflect.Type和reflect.Value这两种类型都提供了大量的方法让我们可以检查和操作这两种类型。一个重要的例子是: + +- Value类型有一个 Type 方法可以返回reflect.Value类型的Type(这个方法返回的是值的静态类型即static type,也就是说如果定义了type MyInt int64,那么这个函数返回的是MyInt类型而不是int64 +- Type 和 Value 都有一个Kind方法可以返回一个常量用于指示一个项到底是以什么形式(也就是底层类型即underlying type,继续前面括号里提到的,Kind返回的是int64而不是MyInt)存储的,这些常量包括:Unit, Float64, Slice等等。而且,有关Value类型的带有名字诸如Int和Float的方法可让让我们获取存在里面的值(比如int64和float64): + + ``` + var x float64 = 3.4 + v := reflect.ValueOf(x) + fmt.Println("type:", v.Type()) + fmt.Println("kind is float64:", v.Kind() == reflect.Float64) + fmt.Println("value:", v.Float()) + + type: float64 + kind is float64: true + value: 3.4 + ``` +反射库里有俩性质值得单独拿出来说说。第一个性质是,为了保持API简单,Value的”setter”和“getter”类型的方法操作的是可以包含某个值的最大类型:比如,所有的有符号整型,只有针对int64类型的方法,因为它是所有的有符号整型中最大的一个类型。也就是说,Value的Int方法返回的是一个int64,同时SetInt的参数类型采用的是一个int64;所以,必要时要转换成实际类型: + +``` +var x uint8 = 'x' +v := reflect.ValueOf(x) +fmt.Println("type:", v.Type()) // uint8. +fmt.Println("kind is uint8: ", v.Kind() == reflect.Uint8) // true. +x = uint8(v.Uint())// v.Uint returns a uint64.看到啦嘛?这个地方必须进行强制类型转换! + +``` + +第二个性质是,反射对象(reflection object)的Kind描述的是底层类型(underlying type) + +#### 从反射队形到接口值的反射 + +就像物理学上的反射,Go中到反射可以生成它的逆。 + +给定一个reflect.Value,我们能用Interface方法把它恢复成一个接口值;效果上就是这个Interface方法把类型和值的信息打包成一个接口表示并且返回结果: + +``` +func (v Value) Interface() interface{} + +``` + +``` +y := v.Interface().(float64) // y will have type float64. +fmt.Println(y) + +``` +我们甚至可以做得更好一些,fmt.Println等方法的参数是一个空接口类型的值,所以我们可以让fmt包自己在内部完成我们在上面代码中做的工作。因此,为了正确打印一个reflect.Value,我们只需把Interface方法的返回值直接传递给这个格式化输出例程: + +``` +fmt.Println(v.Interface()) + +``` + +``` +fmt.Printf("value is %7.1e\n", v.Interface()) + +3.4e+00 +``` +还有就是,我们不需要对v.Interface方法的结果调用类型断言(type-assert)为float64;空接口类型值内部包含有具体值的类型信息,并且Printf方法会把它恢复出来。 + +简要的说,Interface方法是Valueof函数的逆,除了它的返回值的类型总是interface{}静态类型。 + +#### 为了修改一个反射对象,值必须是settable的 + +下面是一些不能正常运行的代码,但是很值得研究: + +``` +var x float64 = 3.4 +v := reflect.ValueOf(x) +v.SetFloat(7.1) // Error: will panic. + +``` +问题不是出在值7.1不是可以寻址的,而是出在v不是settable的。Settability是Value的一条性质,而且,不是所有的Value都具备这条性质。 + +Value的CanSet方法用与测试一个Value的settablity;在我们的例子中, + +``` +var x float64 = 3.4 +v := reflect.ValueOf(x) +fmt.Println("settability of v:", v.CanSet()) + +settability of v: false +``` +如果对一个non-settable的Value调用Set方法会出现错误。但是,settability到底是什么呢? + +settability有点像addressability,但是更加严格。 + +settability是一个性质,描述的是一个反射对象能够修改创造它的那个实际存储的值的能力。settability由反射对象是否保存原始项(original item)而决定。 + +``` +var x float64 = 3.4 +v := reflect.ValueOf(x) + +``` + +我们传递了x的一个副本给reflect.Valueof函数,所以作为reflect.Valueof参数被创造出来的接口值只是x的一个副本,而不是x本身。 + +因为,如果下面这条语句 + +``` +v.SetFloat(7.1) + +``` + +执行成功(当然不可能执行成功啦,假设而已),它不会更新x,即使v看起来像是从x创造而来,所以它更新的只是存储在反射值内部的x的一个副本,而x本身不受丝毫影响,所以如果真这样的话,将会非常那令人困惑,而且一点用都没有!所以,这么干是非法的,而settability就是用来阻止这种哦给你非法状况出现的。 + +如果我们想通过反射来修改x,我们必须把我们想要修改的值的指针传给一个反射库。 + +首先,我们像平常一样初始化x,然后创造一个指向它的反射值,叫做p. + +``` +var x float64 = 3.4 +p := reflect.ValueOf(&x) // Note: take the address of x.注意这里哦!我们把x地址传进去了! +fmt.Println("type of p:", p.Type()) +fmt.Println("settability of p:", p.CanSet()) + +type of p: *float64 +settability of p: false +``` + +反射对象p不是settable的,但是我们想要设置的不是p,而是(效果上来说)*p。为了得到p指向的东西,我们调用Value的Elem方法,这样就能迂回绕过指针,同时把结果保存在叫v的Value中: + +``` +v := p.Elem() +fmt.Println("settability of v:", v.CanSet()) + +settability of v: true + +``` + +现在v就是一个settable的反射对象了,并且因为v表示x,我们最终能够通过v.SetFloat方法来修改x的值: + +``` +v.SetFloat(7.1) +fmt.Println(v.Interface()) +fmt.Println(x) + +``` + +输出正是我们所期待的,反射理解起来有点困难,但是它确实正在做编程语言要做的,尽管是通过掩盖了所发生的一切的反射Types和Vlues来实现的。这样好了,你就直接记住反射Values为了修改它们所表示的东西必须要有这些东西的地址。 + +### type 的方法集 + +来源 :[Golang学习 - reflect 包](https://www.cnblogs.com/golove/p/5909541.html) + +``` +type Type interface { + // Methods applicable to all types. + + // 获取 t 类型的值在分配内存时的字节对齐值。 + Align() int + + // 获取 t 类型的值作为结构体字段时的字节对齐值。 + FieldAlign() int + + // 根据索引获取 t 类型的方法,如果方法不存在,则 panic。 + // 如果 t 是一个实际的类型,则返回值的 Type 和 Func 字段会列出接收者。 + // 如果 t 只是一个接口,则返回值的 Type 不列出接收者,Func 为空值。 + Method(int) Method + + // 根据名称获取 t 类型的方法。 + MethodByName(string) (Method, bool) + + // 获取 t 类型的方法数量。 + NumMethod() int + + // 获取 t 类型在其包中定义的名称,未命名类型则返回空字符串。 + Name() string + + // 获取 t 类型所在包的名称,未命名类型则返回空字符串。 + PkgPath() string + + // 获取 t 类型的值在分配内存时的大小,功能和 unsafe.SizeOf 一样。 + Size() uintptr + + // 获取 t 类型的字符串描述,不要通过 String 来判断两种类型是否一致。 + String() string + + // 获取 t 类型的类别。 + Kind() Kind + + // 判断 t 类型是否实现了 u 接口。 + Implements(u Type) bool + + // 判断 t 类型的值可否赋值给 u 类型。 + AssignableTo(u Type) bool + + // 判断 t 类型的值可否转换为 u 类型。 + ConvertibleTo(u Type) bool + + // 判断 t 类型的值可否进行比较操作 + Comparable() bool + + // Methods applicable only to some types, depending on Kind. + // 特定类型的函数: + // + // Int*, Uint*, Float*, Complex*: Bits + // Array: Elem, Len + // Chan: ChanDir, Elem + // Func: In, NumIn, Out, NumOut, IsVariadic. + // Map: Key, Elem + // Ptr: Elem + // Slice: Elem + // Struct: Field, FieldByIndex, FieldByName, FieldByNameFunc, NumField + + // 获取数值类型的位宽,t 必须是整型、浮点型、复数型 + Bits() int + + // 获取通道的方向 + ChanDir() ChanDir + + // For concreteness, if t represents func(x int, y ... float64), then + // + // t.NumIn() == 2 + // t.In(0) is the reflect.Type for "int" + // t.In(1) is the reflect.Type for "[]float64" + // t.IsVariadic() == true + + // 判断函数是否具有可变参数。 + // 如果有可变参数,则 t.In(t.NumIn()-1) 将返回一个切片。 + IsVariadic() bool + + // 数组、切片、映射、通道、指针、接口 + // 获取元素类型、获取指针所指对象类型,获取接口的动态类型 + Elem() Type + + // 根据索引获取字段 + Field(i int) StructField + + // 根据索引链获取嵌套字段 + FieldByIndex(index []int) StructField + + // 根据名称获取字段 + FieldByName(name string) (StructField, bool) + + // 根据指定的匹配函数 math 获取字段 + FieldByNameFunc(match func(string) bool) (StructField, bool) + + // 根据索引获取函数的参数信息 + In(i int) Type + + // Key returns a map type's key type. + // It panics if the type's Kind is not Map. + Key() Type + + // Len returns an array type's length. + // It panics if the type's Kind is not Array. + Len() int + + // 获取字段数量 + NumField() int + + // 获取函数的参数数量 + NumIn() int + + // 获取函数的返回值数量 + NumOut() int + + // 根据索引获取函数的返回值信息 + Out(i int) Type + + common() *rtype + uncommon() *uncommonType +} + +``` + +### value 方法集 + +``` +// 特殊 + + +// 判断 v 值是否可寻址 +// 1、指针的 Elem() 可寻址 +// 2、切片的元素可寻址 +// 3、可寻址数组的元素可寻址 +// 4、可寻址结构体的字段可寻址,方法不可寻址 +// 也就是说,如果 v 值是指向数组的指针“&数组”,通过 v.Elem() 获取该指针指向的数组,那么 +// 该数组就是可寻址的,同时该数组的元素也是可寻址的,如果 v 就是一个普通数组,不是通过解引 +// 用得到的数组,那么该数组就不可寻址,其元素也不可寻址。结构体亦然。 +func (v Value) CanAddr() bool + +// 获取 v 值的地址,相当于 & 取地址操作。v 值必须可寻址。 +func (v Value) Addr() reflect.Value + +// 判断 v 值是否可以被修改。只有可寻址的 v 值可被修改。 +// 结构体中的非导出字段(通过 Field() 等方法获取的)不能修改,所有方法不能修改。 +func (v Value) CanSet() bool + +// 判断 v 值是否可以转换为接口类型 +// 结构体中的非导出字段(通过 Field() 等方法获取的)不能转换为接口类型 +func (v Value) CanInterface() bool + +// 将 v 值转换为空接口类型。v 值必须可转换为接口类型。 +func (v Value) Interface() interface{} + +// 使用一对 uintptr 返回接口的数据 +func (v Value) InterfaceData() [2]uintptr + + + + + + +// 指针 +// 将 v 值转换为 uintptr 类型,v 值必须是切片、映射、通道、函数、指针、自由指针。 +func (v Value) Pointer() uintptr + +// 获取 v 值的地址。v 值必须是可寻址类型(CanAddr)。 +func (v Value) UnsafeAddr() uintptr + +// 将 UnsafePointer 类别的 v 值修改为 x,v 值必须是 UnsafePointer 类别,必须可修改。 +func (v Value) SetPointer(x unsafe.Pointer) + +// 判断 v 值是否为 nil,v 值必须是切片、映射、通道、函数、接口、指针。 +// IsNil 并不总等价于 Go 的潜在比较规则,比如对于 var i interface{},i == nil 将返回 +// true,但是 reflect.ValueOf(i).IsNil() 将 panic。 +func (v Value) IsNil() bool + +// 获取“指针所指的对象”或“接口所包含的对象” +func (v Value) Elem() reflect.Value + + +// 通用 + +// 获取 v 值的字符串描述 +func (v Value) String() string + +// 获取 v 值的类型 +func (v Value) Type() reflect.Type + +// 返回 v 值的类别,如果 v 是空值,则返回 reflect.Invalid。 +func (v Value) Kind() reflect.Kind + +// 获取 v 的方法数量 +func (v Value) NumMethod() int + +// 根据索引获取 v 值的方法,方法必须存在,否则 panic +// 使用 Call 调用方法的时候不用传入接收者,Go 会自动把 v 作为接收者传入。 +func (v Value) Method(int) reflect.Value + +// 根据名称获取 v 值的方法,如果该方法不存在,则返回空值(reflect.Invalid)。 +func (v Value) MethodByName(string) reflect.Value + +// 判断 v 本身(不是 v 值)是否为零值。 +// 如果 v 本身是零值,则除了 String 之外的其它所有方法都会 panic。 +func (v Value) IsValid() bool + +// 将 v 值转换为 t 类型,v 值必须可转换为 t 类型,否则 panic。 +func (v Value) Convert(t Type) reflect.Value + +// 获取 + +// 获取 v 值的内容,如果 v 值不是有符号整型,则 panic。 +func (v Value) Int() int64 + +// 获取 v 值的内容,如果 v 值不是无符号整型(包括 uintptr),则 panic。 +func (v Value) Uint() uint64 + +// 获取 v 值的内容,如果 v 值不是浮点型,则 panic。 +func (v Value) Float() float64 + +// 获取 v 值的内容,如果 v 值不是复数型,则 panic。 +func (v Value) Complex() complex128 + +// 获取 v 值的内容,如果 v 值不是布尔型,则 panic。 +func (v Value) Bool() bool + +// 获取 v 值的长度,v 值必须是字符串、数组、切片、映射、通道。 +func (v Value) Len() int + +// 获取 v 值的容量,v 值必须是数值、切片、通道。 +func (v Value) Cap() int + +// 获取 v 值的第 i 个元素,v 值必须是字符串、数组、切片,i 不能超出范围。 +func (v Value) Index(i int) reflect.Value + +// 获取 v 值的内容,如果 v 值不是字节切片,则 panic。 +func (v Value) Bytes() []byte + +// 获取 v 值的切片,切片长度 = j - i,切片容量 = v.Cap() - i。 +// v 必须是字符串、数值、切片,如果是数组则必须可寻址。i 不能超出范围。 +func (v Value) Slice(i, j int) reflect.Value + +// 获取 v 值的切片,切片长度 = j - i,切片容量 = k - i。 +// i、j、k 不能超出 v 的容量。i <= j <= k。 +// v 必须是字符串、数值、切片,如果是数组则必须可寻址。i 不能超出范围。 +func (v Value) Slice3(i, j, k int) reflect.Value + +// 根据 key 键获取 v 值的内容,v 值必须是映射。 +// 如果指定的元素不存在,或 v 值是未初始化的映射,则返回零值(reflect.ValueOf(nil)) +func (v Value) MapIndex(key Value) reflect.Value + +// 获取 v 值的所有键的无序列表,v 值必须是映射。 +// 如果 v 值是未初始化的映射,则返回空列表。 +func (v Value) MapKeys() []reflect.Value + +// 判断 x 是否超出 v 值的取值范围,v 值必须是有符号整型。 +func (v Value) OverflowInt(x int64) bool + +// 判断 x 是否超出 v 值的取值范围,v 值必须是无符号整型。 +func (v Value) OverflowUint(x uint64) bool + +// 判断 x 是否超出 v 值的取值范围,v 值必须是浮点型。 +func (v Value) OverflowFloat(x float64) bool + +// 判断 x 是否超出 v 值的取值范围,v 值必须是复数型。 +func (v Value) OverflowComplex(x complex128) bool + +------------------------------ + +// 设置(这些方法要求 v 值必须可修改) + +// 设置 v 值的内容,v 值必须是有符号整型。 +func (v Value) SetInt(x int64) + +// 设置 v 值的内容,v 值必须是无符号整型。 +func (v Value) SetUint(x uint64) + +// 设置 v 值的内容,v 值必须是浮点型。 +func (v Value) SetFloat(x float64) + +// 设置 v 值的内容,v 值必须是复数型。 +func (v Value) SetComplex(x complex128) + +// 设置 v 值的内容,v 值必须是布尔型。 +func (v Value) SetBool(x bool) + +// 设置 v 值的内容,v 值必须是字符串。 +func (v Value) SetString(x string) + +// 设置 v 值的长度,v 值必须是切片,n 不能超出范围,不能为负数。 +func (v Value) SetLen(n int) + +// 设置 v 值的内容,v 值必须是切片,n 不能超出范围,不能小于 Len。 +func (v Value) SetCap(n int) + +// 设置 v 值的内容,v 值必须是字节切片。x 可以超出 v 值容量。 +func (v Value) SetBytes(x []byte) + +// 设置 v 值的键和值,如果键存在,则修改其值,如果键不存在,则添加键和值。 +// 如果将 val 设置为零值(reflect.ValueOf(nil)),则删除该键。 +// 如果 v 值是一个未初始化的 map,则 panic。 +func (v Value) SetMapIndex(key, val reflect.Value) + +// 设置 v 值的内容,v 值必须可修改,x 必须可以赋值给 v 值。 +func (v Value) Set(x reflect.Value) + +------------------------------ + +// 结构体 + +// 获取 v 值的字段数量,v 值必须是结构体。 +func (v Value) NumField() int + +// 根据索引获取 v 值的字段,v 值必须是结构体。如果字段不存在则 panic。 +func (v Value) Field(i int) reflect.Value + +// 根据索引链获取 v 值的嵌套字段,v 值必须是结构体。 +func (v Value) FieldByIndex(index []int) reflect.Value + +// 根据名称获取 v 值的字段,v 值必须是结构体。 +// 如果指定的字段不存在,则返回零值(reflect.ValueOf(nil)) +func (v Value) FieldByName(string) reflect.Value + +// 根据匹配函数 match 获取 v 值的字段,v 值必须是结构体。 +// 如果没有匹配的字段,则返回零值(reflect.ValueOf(nil)) +func (v Value) FieldByNameFunc(match func(string) bool) Value + + +// 函数 + +// 通过参数列表 in 调用 v 值所代表的函数(或方法)。函数的返回值存入 r 中返回。 +// 要传入多少参数就在 in 中存入多少元素。 +// Call 即可以调用定参函数(参数数量固定),也可以调用变参函数(参数数量可变)。 +func (v Value) Call(in []Value) (r []Value) + +// 通过参数列表 in 调用 v 值所代表的函数(或方法)。函数的返回值存入 r 中返回。 +// 函数指定了多少参数就在 in 中存入多少元素,变参作为一个单独的参数提供。 +// CallSlice 只能调用变参函数。 +func (v Value) CallSlice(in []Value) []Value + + + +// 通道 + +// 发送数据(会阻塞),v 值必须是可写通道。 +func (v Value) Send(x reflect.Value) + +// 接收数据(会阻塞),v 值必须是可读通道。 +func (v Value) Recv() (x reflect.Value, ok bool) + +// 尝试发送数据(不会阻塞),v 值必须是可写通道。 +func (v Value) TrySend(x reflect.Value) bool + +// 尝试接收数据(不会阻塞),v 值必须是可读通道。 +func (v Value) TryRecv() (x reflect.Value, ok bool) + +// 关闭通道,v 值必须是通道。 +func (v Value) Close() + + +``` + +``` + +// 示例 +var f1 = func(a int, b []int) { fmt.Println(a, b) } +var f2 = func(a int, b ...int) { fmt.Println(a, b) } + +func main() { + v1 := reflect.ValueOf(f1) + v2 := reflect.ValueOf(f2) + + a := reflect.ValueOf(1) + b := reflect.ValueOf([]int{1, 2, 3}) + + v1.Call([]reflect.Value{a, b}) + v2.Call([]reflect.Value{a, a, a, a, a, a}) + + //v1.CallSlice([]reflect.Value{a, b}) // 非变参函数,不能用 CallSlice。 + v2.CallSlice([]reflect.Value{a, b}) +} + +``` + +### 样例 + +- 类型的字段标识 + +下面是分析一个struct值,t,的简单例子。我们用这个struct的地址创建一个反射对象,因为我们想一会改变它的值。然后我们把typeofT变量设置为这个反射对象的类型,接着使用一些直接的方法调用(细节请见reflect包)来迭代各个域。注意,我们从struct类型中提取了各个域的名字,但是这些域本身都是rreflect.Value对象。 + +``` +type T struct { + A int + B string +} +t := T{23, "skidoo"} +s := reflect.ValueOf(&t).Elem() +typeOfT := s.Type()//把s.Type()返回的Type对象复制给typeofT,typeofT也是一个反射。 +for i := 0; i < s.NumField(); i++ { + f := s.Field(i)//迭代s的各个域,注意每个域仍然是反射。 + fmt.Printf("%d: %s %s = %v\n", i, + typeOfT.Field(i).Name, f.Type(), f.Interface())//提取了每个域的名字 +} + +``` + +``` +0: A int = 23 +1: B string = skidoo + +``` + +reflect.Type的Field方法将返回一个reflect.StructField,里面含有每个成员的名字、类型和可选的成员标签等信息。 + + + +因为s包含了一个settable的反射对象,所以我们可以修改这个structure的各个域。 + +``` +s.Field(0).SetInt(77) +s.Field(1).SetString("Sunset Strip") +fmt.Println("t is now", t) + +t is now {77 Sunset Strip} + +``` + +- 类型的方法集 + +``` +func Print(x interface{}) { + v := reflect.ValueOf(x) + t := v.Type() + fmt.Printf("type %s\n", t) + + for i := 0; i < v.NumMethod(); i++ { + methType := v.Method(i).Type() + fmt.Printf("func (%s) %s%s\n", t, t.Method(i).Name, + strings.TrimPrefix(methType.String(), "func")) + } +} + +``` + +reflect.Type和reflect.Value都提供了一个Method方法。每次t.Method(i)调用将一个reflect.Method的实例,对应一个用于描述一个方法的名称和类型的结构体。每次v.Method(i)方法调用都返回一个reflect.Value以表示对应的值(§6.4),也就是一个方法是帮到它的接收者的。使用reflect.Value.Call方法(我们之类没有演示),将可以调用一个Func类型的Value,但是这个例子中只用到了它的类型。 + +``` +methods.Print(time.Hour) +// Output: +// type time.Duration +// func (time.Duration) Hours() float64 +// func (time.Duration) Minutes() float64 +// func (time.Duration) Nanoseconds() int64 +// func (time.Duration) Seconds() float64 +// func (time.Duration) String() string + +methods.Print(new(strings.Replacer)) +// Output: +// type *strings.Replacer +// func (*strings.Replacer) Replace(string) string +// func (*strings.Replacer) WriteString(io.Writer, string) (int, error) + +``` + +## 反射的原理 + +### Typeof + + +Typeof 函数非常简单,在调用 Typeof 函数的时候,变量就已经被转化为 interface 类型,Typeof 只需要将它的 typ 属性取出来即可。 + +``` +func TypeOf(i interface{}) Type { + eface := *(*emptyInterface)(unsafe.Pointer(&i)) + return toType(eface.typ) +} + +func toType(t *rtype) Type { + if t == nil { + return nil + } + return t +} +``` +#### type.Name 函数 + +解析类型的名称是一个反射很基础的功能,它和 String 方法的不同在于,它不会包含类型所在包的名字,例如 `main.Cat` 与 `Cat`,所以一定不要用 name 来区分类型。 + +从实现来看,Name 是建立在 String 函数的基础上的,它找到了 `.` 这个字符然后分割了字符串。 + +从下面的代码中可以看到,rtype 的 str(nameoff) 属性并不是简单的距离,而是距离各个模块 types 的距离。 + +``` +func (t *rtype) Name() string { + if t.tflag&tflagNamed == 0 { + return "" + } + s := t.String() + i := len(s) - 1 + for i >= 0 && s[i] != '.' { + i-- + } + return s[i+1:] +} + +func (t *rtype) String() string { + s := t.nameOff(t.str).name() + if t.tflag&tflagExtraStar != 0 { + return s[1:] + } + return s +} + +func (t *rtype) nameOff(off nameOff) name { + return name{(*byte)(resolveNameOff(unsafe.Pointer(t), int32(off)))} +} + +// reflect_resolveNameOff resolves a name offset from a base pointer. +//go:linkname reflect_resolveNameOff reflect.resolveNameOff +func reflect_resolveNameOff(ptrInModule unsafe.Pointer, off int32) unsafe.Pointer { + return unsafe.Pointer(resolveNameOff(ptrInModule, nameOff(off)).bytes) +} + +func resolveNameOff(ptrInModule unsafe.Pointer, off nameOff) name { + if off == 0 { + return name{} + } + base := uintptr(ptrInModule) + for md := &firstmoduledata; md != nil; md = md.next { + if base >= md.types && base < md.etypes { + res := md.types + uintptr(off) + if res > md.etypes { + println("runtime: nameOff", hex(off), "out of range", hex(md.types), "-", hex(md.etypes)) + throw("runtime: name offset out of range") + } + return name{(*byte)(unsafe.Pointer(res))} + } + } + + // No module found. see if it is a run time name. + reflectOffsLock() + res, found := reflectOffs.m[int32(off)] + reflectOffsUnlock() + if !found { + println("runtime: nameOff", hex(off), "base", hex(base), "not in ranges:") + for next := &firstmoduledata; next != nil; next = next.next { + println("\ttypes", hex(next.types), "etypes", hex(next.etypes)) + } + throw("runtime: name offset base pointer out of range") + } + return name{(*byte)(res)} +} +``` + +### type.Field + +``` +func (t *rtype) Field(i int) StructField { + if t.Kind() != Struct { + panic("reflect: Field of non-struct type") + } + tt := (*structType)(unsafe.Pointer(t)) + return tt.Field(i) +} + +func (t *structType) Field(i int) (f StructField) { + if i < 0 || i >= len(t.fields) { + panic("reflect: Field index out of bounds") + } + p := &t.fields[i] + f.Type = toType(p.typ) + f.Name = p.name.name() + f.Anonymous = p.embedded() + if !p.name.isExported() { + f.PkgPath = t.pkgPath.name() + } + if tag := p.name.tag(); tag != "" { + f.Tag = StructTag(tag) + } + f.Offset = p.offset() + + // NOTE(rsc): This is the only allocation in the interface + // presented by a reflect.Type. It would be nice to avoid, + // at least in the common cases, but we need to make sure + // that misbehaving clients of reflect cannot affect other + // uses of reflect. One possibility is CL 5371098, but we + // postponed that ugliness until there is a demonstrated + // need for the performance. This is issue 2320. + f.Index = []int{i} + return +} + +``` + +### type.Method 方法 + +对于 golang 里面的类型,它们的方法都是存储在 uncommon 的部分当中,而且他们的数据结构是: + +``` +type method struct { + name nameOff // name of method + mtyp typeOff // method type (without receiver) + ifn textOff // fn used in interface call (one-word receiver) + tfn textOff // fn used for normal method call +} + +``` + +数据结构中,mtyp 是 method 类型的地址,ifn 是接口函数的地址,tfn 是普通函数的地址。 + +它会被 Method 函数转换为 Method 类型: + +``` +type Method struct { + // Name is the method name. + // PkgPath is the package path that qualifies a lower case (unexported) + // method name. It is empty for upper case (exported) method names. + // The combination of PkgPath and Name uniquely identifies a method + // in a method set. + // See https://golang.org/ref/spec#Uniqueness_of_identifiers + Name string + PkgPath string + + Type Type // method type + Func Value // func with receiver as first argument + Index int // index for Type.Method +} + +``` +Method 的 Type 由 mtyp 而来,Func 由 tfn/ifn 而来,而 Func 是 Value 类型,Func.typ 还是 mtyp,ptr 是 tfn/ifn。 + + +``` +func (t *rtype) Method(i int) (m Method) { + if t.Kind() == Interface { + tt := (*interfaceType)(unsafe.Pointer(t)) + return tt.Method(i) + } + methods := t.exportedMethods() + if i < 0 || i >= len(methods) { + panic("reflect: Method index out of range") + } + p := methods[i] + pname := t.nameOff(p.name) + m.Name = pname.name() + fl := flag(Func) + mtyp := t.typeOff(p.mtyp) + ft := (*funcType)(unsafe.Pointer(mtyp)) + in := make([]Type, 0, 1+len(ft.in())) + in = append(in, t) + for _, arg := range ft.in() { + in = append(in, arg) + } + out := make([]Type, 0, len(ft.out())) + for _, ret := range ft.out() { + out = append(out, ret) + } + mt := FuncOf(in, out, ft.IsVariadic()) + m.Type = mt + tfn := t.textOff(p.tfn) + fn := unsafe.Pointer(&tfn) + m.Func = Value{mt.(*rtype), fn, fl} + + m.Index = i + return m +} + + +func (t *rtype) exportedMethods() []method { + ut := t.uncommon() + if ut == nil { + return nil + } + return ut.exportedMethods() +} + +func (t *uncommonType) exportedMethods() []method { + if t.xcount == 0 { + return nil + } + return (*[1 << 16]method)(add(unsafe.Pointer(t), uintptr(t.moff), "t.xcount > 0"))[:t.xcount:t.xcount] +} +``` + +### ValueOf + + +``` +func ValueOf(i interface{}) Value { + if i == nil { + return Value{} + } + + // TODO: Maybe allow contents of a Value to live on the stack. + // For now we make the contents always escape to the heap. It + // makes life easier in a few places (see chanrecv/mapassign + // comment below). + escapes(i) + + return unpackEface(i) +} + +func unpackEface(i interface{}) Value { + e := (*emptyInterface)(unsafe.Pointer(&i)) + // NOTE: don't read e.word until we know whether it is really a pointer or not. + t := e.typ + if t == nil { + return Value{} + } + f := flag(t.Kind()) + if ifaceIndir(t) { + f |= flagIndir + } + return Value{t, e.word, f} +} +``` + +### value.Field + +通过 value 的 Field 可以获取到结构体的内部属性值,结构体的内部属性都是 structField 类型的,每个 structField.offsetEmbed 是该属性值距离结构体地址的偏移量。 + +``` +func (v Value) Field(i int) Value { + if v.kind() != Struct { + panic(&ValueError{"reflect.Value.Field", v.kind()}) + } + tt := (*structType)(unsafe.Pointer(v.typ)) + if uint(i) >= uint(len(tt.fields)) { + panic("reflect: Field index out of range") + } + field := &tt.fields[i] + typ := field.typ + + // Inherit permission bits from v, but clear flagEmbedRO. + fl := v.flag&(flagStickyRO|flagIndir|flagAddr) | flag(typ.Kind()) + // Using an unexported field forces flagRO. + if !field.name.isExported() { + if field.embedded() { + fl |= flagEmbedRO + } else { + fl |= flagStickyRO + } + } + // Either flagIndir is set and v.ptr points at struct, + // or flagIndir is not set and v.ptr is the actual struct data. + // In the former case, we want v.ptr + offset. + // In the latter case, we must have field.offset = 0, + // so v.ptr + field.offset is still the correct address. + ptr := add(v.ptr, field.offset(), "same as non-reflect &v.field") + return Value{typ, ptr, fl} +} + +type structField struct { + name name // name is always non-empty + typ *rtype // type of field + offsetEmbed uintptr // byte offset of field<<1 | isEmbedded +} + +func (f *structField) offset() uintptr { + return f.offsetEmbed >> 1 +} + +``` + +### value.Method + +我们从下面的代码中可以看到,Method 也是返回一个 Value,但是这个 Value 的 ptr 并不是第 i 个函数的地址,而是原封不动的将原 value 的 ptr 返回了,仅仅是对 flag 设置比特位而已。 + + +``` +func (v Value) Method(i int) Value { + if v.typ == nil { + panic(&ValueError{"reflect.Value.Method", Invalid}) + } + if v.flag&flagMethod != 0 || uint(i) >= uint(v.typ.NumMethod()) { + panic("reflect: Method index out of range") + } + if v.typ.Kind() == Interface && v.IsNil() { + panic("reflect: Method on nil interface value") + } + fl := v.flag & (flagStickyRO | flagIndir) // Clear flagEmbedRO + fl |= flag(Func) + fl |= flag(i)<>flagMethodShift) + } else if v.flag&flagIndir != 0 { + fn = *(*unsafe.Pointer)(v.ptr) + } else { + fn = v.ptr + } + + if fn == nil { + panic("reflect.Value.Call: call of nil function") + } + + isSlice := op == "CallSlice" + n := t.NumIn() + if isSlice { + if !t.IsVariadic() { + panic("reflect: CallSlice of non-variadic function") + } + if len(in) < n { + panic("reflect: CallSlice with too few input arguments") + } + if len(in) > n { + panic("reflect: CallSlice with too many input arguments") + } + } else { + if t.IsVariadic() { + n-- + } + if len(in) < n { + panic("reflect: Call with too few input arguments") + } + if !t.IsVariadic() && len(in) > n { + panic("reflect: Call with too many input arguments") + } + } + for _, x := range in { + if x.Kind() == Invalid { + panic("reflect: " + op + " using zero Value argument") + } + } + for i := 0; i < n; i++ { + if xt, targ := in[i].Type(), t.In(i); !xt.AssignableTo(targ) { + panic("reflect: " + op + " using " + xt.String() + " as type " + targ.String()) + } + } + if !isSlice && t.IsVariadic() { + // prepare slice for remaining values + m := len(in) - n + slice := MakeSlice(t.In(n), m, m) + elem := t.In(n).Elem() + for i := 0; i < m; i++ { + x := in[n+i] + if xt := x.Type(); !xt.AssignableTo(elem) { + panic("reflect: cannot use " + xt.String() + " as type " + elem.String() + " in " + op) + } + slice.Index(i).Set(x) + } + origIn := in + in = make([]Value, n+1) + copy(in[:n], origIn) + in[n] = slice + } + + nin := len(in) + if nin != t.NumIn() { + panic("reflect.Value.Call: wrong argument count") + } + nout := t.NumOut() + + // Compute frame type. + frametype, _, retOffset, _, framePool := funcLayout(t, rcvrtype) + + // Allocate a chunk of memory for frame. + var args unsafe.Pointer + if nout == 0 { + args = framePool.Get().(unsafe.Pointer) + } else { + // Can't use pool if the function has return values. + // We will leak pointer to args in ret, so its lifetime is not scoped. + args = unsafe_New(frametype) + } + off := uintptr(0) + + // Copy inputs into args. + if rcvrtype != nil { + storeRcvr(rcvr, args) + off = ptrSize + } + for i, v := range in { + v.mustBeExported() + targ := t.In(i).(*rtype) + a := uintptr(targ.align) + off = (off + a - 1) &^ (a - 1) + n := targ.size + if n == 0 { + // Not safe to compute args+off pointing at 0 bytes, + // because that might point beyond the end of the frame, + // but we still need to call assignTo to check assignability. + v.assignTo("reflect.Value.Call", targ, nil) + continue + } + addr := add(args, off, "n > 0") + v = v.assignTo("reflect.Value.Call", targ, addr) + if v.flag&flagIndir != 0 { + typedmemmove(targ, addr, v.ptr) + } else { + *(*unsafe.Pointer)(addr) = v.ptr + } + off += n + } + + // Call. + call(frametype, fn, args, uint32(frametype.size), uint32(retOffset)) + + // For testing; see TestCallMethodJump. + if callGC { + runtime.GC() + } + + var ret []Value + if nout == 0 { + typedmemclr(frametype, args) + framePool.Put(args) + } else { + // Zero the now unused input area of args, + // because the Values returned by this function contain pointers to the args object, + // and will thus keep the args object alive indefinitely. + typedmemclrpartial(frametype, args, 0, retOffset) + + // Wrap Values around return values in args. + ret = make([]Value, nout) + off = retOffset + for i := 0; i < nout; i++ { + tv := t.Out(i) + a := uintptr(tv.Align()) + off = (off + a - 1) &^ (a - 1) + if tv.Size() != 0 { + fl := flagIndir | flag(tv.Kind()) + ret[i] = Value{tv.common(), add(args, off, "tv.Size() != 0"), fl} + // Note: this does introduce false sharing between results - + // if any result is live, they are all live. + // (And the space for the args is live as well, but as we've + // cleared that space it isn't as big a deal.) + } else { + // For zero-sized return value, args+off may point to the next object. + // In this case, return the zero value instead. + ret[i] = Zero(tv) + } + off += tv.Size() + } + } + + return ret +} +``` diff --git a/Go interface.md b/Go interface.md new file mode 100644 index 0000000..be63bb3 --- /dev/null +++ b/Go interface.md @@ -0,0 +1,1089 @@ +# Go interface + +[TOC] + +## 基本用法 + +### 指针和接口 + +#### 结构体的函数 + +Go 语言是一个有指针类型的编程语言,当指针和接口同时出现时就会遇到一些让人困惑或者感到诡异的问题,接口在定义一组方法时其实没有对实现的接受者做限制,所以我们其实会在一个类型上看到以下两种不同的实现方式: + +![](img/interface.png) + +- addressable 变量的函数调用 + +对于函数来说,只要变量是 addressable 的,无所谓变量是 T 还是 `*T`,也无所谓函数的接受者是 T 还是 `*T`,编译器都会进行优化: + +对于 Cat 结构体来说,无论函数定义为结构体类型还是指针类型,被初始化为结构体还是指针,它都能直接调用: + +``` +type Cat struct{} + +func (c Cat) Walk() { + fmt.Println("catwalk") +} +func (c Cat) Quack() { + fmt.Println("meow") +} + +func main() { + var t Cat + t.Walk() + t.Quack() + + var d = &t + d.Walk() + d.Quack() +} +``` + + +``` +type Cat struct{} + +func (c *Cat) Walk() { + fmt.Println("catwalk") +} +func (c *Cat) Quack() { + fmt.Println("meow") +} + +func main() { + var t Cat + t.Walk() + t.Quack() + + var d = &t + d.Walk() + d.Quack() +} + +``` + +- 非 addressable 变量的函数调用 + +如果使用类似右值的方式调用的话,情况有些不太相同。 + +如果函数被定义为结构体,右值不管怎么调用都可以。 + +``` +type Cat struct{} + +func (c Cat) Walk() { + fmt.Println("catwalk") +} +func (c Cat) Quack() { + fmt.Println("meow") +} + +func main() { + Cat{}.Walk() + Cat{}.Quack() + + (&Cat{}).Walk() + (&Cat{}).Quack() +} +``` + +但是如果函数被定义为指针的话,就比较麻烦, 这个代码编译之后,会报错: + +``` +type Cat struct{} + +func (c *Cat) Walk() { + fmt.Println("catwalk") +} +func (c *Cat) Quack() { + fmt.Println("meow") +} + +func main() { + Cat{}.Walk() + Cat{}.Quack() + + (&Cat{}).Walk() + (&Cat{}).Quack() +} + +./test.go:20:7: cannot call pointer method on Cat literal +./test.go:20:7: cannot take the address of Cat literal + +``` +原因就是右值匿名结构体可以看做是个只读的变量值,是不允许取到地址的,因此无法调用指针类型的函数。 + +但是我们在调用之前,先去取地址,类似 `(&Cat{}).Walk()` 这个代码是没有问题的,编译器将在堆中构建 Cat 结构体,将地址存放到栈里,不会把它看做右值。 + +#### 接口的函数 + +对于接口和变量的转换来说,是否可以转换成功就不是是否可以 addressable 可以决定的了。决定是否可以转换的关键是 [Method sets](https://golang.org/ref/spec#Method_sets)。 + +Method sets 规定 `*T` 可以访问所有的 `*T` 和 T 的方法集,而 T 只能访问 T 的方法集。 + +- *T 赋值 + +对于 *T 来说,它可以接收所有的函数,无论接受者是什么: + +``` +type Duck interface { + Walk() + Quack() +} + +type Cat struct{} + +func (c Cat) Walk() { + fmt.Println("catwalk") +} +func (c Cat) Quack() { + fmt.Println("meow") +} + +func main() { + var c Duck = &Cat{} + c.Walk() + c.Quack() + + var t Cat + var d Duck = &t + d.Walk() + d.Quack() +} + +``` + +``` +type Duck interface { + Walk() + Quack() +} + +type Cat struct{} + +func (c *Cat) Walk() { + fmt.Println("catwalk") +} +func (c *Cat) Quack() { + fmt.Println("meow") +} + +func main() { + var c Duck = &Cat{} + c.Walk() + c.Quack() + + var t Cat + var d Duck = &t + d.Walk() + d.Quack() +} + +``` + +- T 赋值 + +T 可以访问接受者为 T 的函数,因此可以转换为相应的 interface 成功 + +``` +type Duck interface { + Walk() + Quack() +} + +type Cat struct{} + +func (c Cat) Walk() { + fmt.Println("catwalk") +} +func (c Cat) Quack() { + fmt.Println("meow") +} + +func main() { + var t Cat + var c Duck = t + c.Walk() + c.Quack() + + var d Duck = Cat{} + d.Walk() + d.Quack() +} +``` + +但是,和结构体的函数调用不同的是,T 无法调用 `*T` 的函数,因此 T 并没有接受者为 `*T` 的 Walk/Quack 方法,因此它无法转换为 Duck 接口。 + +``` +type Duck interface { + Walk() + Quack() +} + +type Cat struct{} + +func (c *Cat) Walk() { + fmt.Println("catwalk") +} +func (c *Cat) Quack() { + fmt.Println("meow") +} + +func main() { + var t Cat + var c Duck = t + c.Walk() + c.Quack() + + var d Duck = Cat{} + d.Walk() + d.Quack() +} + +./test.go:21:6: cannot use t (type Cat) as type Duck in assignment: + Cat does not implement Duck (Quack method has pointer receiver) + +``` + +编译器会提醒我们『Cat 类型并没有实现 Duck 接口,Quack 方法的接受者是指针』,这两种情况其实非常让人困惑,尤其是对于刚刚接触 Go 语言接口的开发者,想要理解这个问题,首先要知道 Go 语言在进行 参数传递 时都是值传递的。 + +![](img/methodset.png) + +官方文档写的比较清楚,原因有两个: + +- 一个是有些临时变量是无法 addressable 的,这部分变量不允许去取变量的地址,自然没有办法调用 `*T` 的函数 +- 另一个原因是,即使是可以 addressable 的变量 S,如果调用 `*T` 的方法目的是改变变量 S 的内部属性值,但是偏偏 interface 的转化过程是复制一份变量 S1(`var d Duck = S` 实际上是复制了一份 S 到 d 的内部属性 d.data 中),导致改变的也仅仅是 S1,造成了歧义。这里就是 golang 官方为了避免歧义,在接口的转化过程中,直接禁止 `T` 拥有 `*T` 的函数。如果想要改变 S 变量,请传递指针变量,`var d Duck = &S`,这样 interface 复制的就是 *S 的指针地址,调用函数才能真正的更改 S 的内部属性值。 +- 对于函数调用来说,如果 T 调用了 (`*T`) 的方法,编译器直接就对 T 进行了取地址的操作;而 interface 在转化阶段因为采取了复制的操作,导致了反直觉的效果。因此这两个采取的策略是不同的。 + +一般来说,我们提倡声明方法时使用指针,隐式或者显示转化为 interface 的时候也使用指针,可以避免对象的复制。 + +### nil 和 non-nil + +我们可以通过一个例子理解『Go 语言的接口类型不是任意类型』这一句话,下面的代码在 main 函数中初始化了一个 *TestStruct 结构体指针,由于指针的零值是 nil,所以变量 s 在初始化之后也是 nil: + +``` +package main + +type TestStruct struct{} + +func NilOrNot(v interface{}) { + if v == nil { + println("nil") + } else { + println("non-nil") + } +} + +func main() { + var s *TestStruct + NilOrNot(s) +} + +$ go run main.go +non-nil + +``` + +但是当我们将 s 变量传入 NilOrNot 时,该方法却打印出了 non-nil 字符串,这主要是因为调用 NilOrNot 函数时其实会发生隐式的类型转换,变量 nil 会被转换成 interface{} 类型,interface{} 类型是一个结构体,它除了包含 nil 变量之外还包含变量的类型信息,也就是 TestStruct,所以在这里会打印出 non-nil,我们会在接下来详细介绍结构的实现原理。 + +## _type + +### _type + +![](img/_type.png) + +在Go语言中_type这个结构体非常重要,记录着某种数据类型的一些基本特征,比如这个数据类型占用的内存大小(size字段),数据类型的名称(nameOff字段)等等。每种数据类型都存在一个与之对应的_type结构体。 + +``` +//src/runtime/type.go +type type struct { + size uintptr // 大小 + ptrdata uintptr //size of memory prefix holding all pointers + hash uint32 //类型Hash + tflag tflag //类型的特征标记 + align uint8 //_type 作为整体交量存放时的对齐字节数 + fieldalign uint8 //当前结构字段的对齐字节数 + kind uint8 //基础类型枚举值和反射中的 Kind 一致,kind 决定了如何解析该类型 + alg *typeAlg //指向一个函数指针表,该表有两个函数,一个是计算类型 Hash 函 + //数,另一个是比较两个类型是否相同的 equal 函数 + //gcdata stores the GC type data for the garbage collector. + //If the KindGCProg bit is set in kind, gcdata is a GC program. + //Otherwise it is a ptrmask bitmap. See mbitmap.go for details. + gcdata *byte //GC 相关信息 + str nameOff //str 用来表示类型名称字符串在编译后二进制文件中某个 section + //的偏移量 + //由链接器负责填充 + ptrToThis typeOff //ptrToThis 用来表示类型元信息的指针在编译后二进制文件中某个 + //section 的偏移量 + //由链接器负责填充 +} + +``` + +- size 为该类型所占用的字节数量。 +- kind 表示类型的种类,如 bool、int、float、string、struct、interface 等。 +- str 表示类型的名字信息,它是一个 nameOff(int32) 类型,通过这个 nameOff,可以找到类型的名字字符串 + +_type 包含所有类型的共同元信息,编译器和运行时可以根据该元信息解析具体类型、类型名存放位置、类型的 Hash 值等基本信息。 + +这里需要说明一下:_type 里面的 nameOff 和 typeOff 最终是由链接器负责确定和填充的,它们都是一个偏移量(offset),类型的名称和类型元信息实际上存放在连接后可执行文件的某个段(section)里,这两个值是相对于段内的偏移量,运行时提供两个转换查找函数。 + +### extras + +如果是一些比较特殊的数据类型,可能还会对_type结构体进行扩展,记录更多的信息,我们可以称之为 extras。extras 对于基础类型(如 bool,int, float 等)是 size 为 0 的,它为复杂的类型提供了一些额外信息。例如为 struct 类型提供 structtype,为 slice 类型提供 slicetype 等信息。 + +``` +type arraytype struct { + typ _type + elem *_type + slice *_type + len uintptr +} + +type chantype struct { + typ _type + elem *_type + dir uintptr +} + +type slicetype struct { + typ _type + elem *_type +} + +type functype struct { + typ _type + inCount uint16 + outCount uint16 +} + +type ptrtype struct { + typ _type + elem *_type +} + +type structtype struct { + typ _type + pkgPath name + fields []structfield +} + +type structfield struct { + name name + typ *_type + offsetAnon uintptr +} + +type name struct { + bytes *byte +} + +``` + +### uncommontype + +处理 extras 之外,还存在着 uncommon 字段的类型,ucom 对于基础类型也是 size 为 0 的,但是对于 type Binary int 这种定义或者是其它复杂类型来说,ucom 用来存储类型的函数列表等信息。 + +``` +type uncommontype struct { + pkgpath nameOff + mcount uint16 // number of methods + xcount uint16 // number of exported methods + moff uint32 // offset from this uncommontype to [mcount]method + _ uint32 // unused +} + +``` + +我们可以看看 golang 如何提取类型中的 uncommon 字段: + +``` +func (t *_type) uncommon() *uncommontype { + if t.tflag&tflagUncommon == 0 { + return nil + } + switch t.kind & kindMask { + case kindStruct: + type u struct { + structtype + u uncommontype + } + return &(*u)(unsafe.Pointer(t)).u + case kindPtr: + type u struct { + ptrtype + u uncommontype + } + return &(*u)(unsafe.Pointer(t)).u + case kindFunc: + type u struct { + functype + u uncommontype + } + return &(*u)(unsafe.Pointer(t)).u + case kindSlice: + type u struct { + slicetype + u uncommontype + } + return &(*u)(unsafe.Pointer(t)).u + case kindArray: + type u struct { + arraytype + u uncommontype + } + return &(*u)(unsafe.Pointer(t)).u + case kindChan: + type u struct { + chantype + u uncommontype + } + return &(*u)(unsafe.Pointer(t)).u + case kindMap: + type u struct { + maptype + u uncommontype + } + return &(*u)(unsafe.Pointer(t)).u + case kindInterface: + type u struct { + interfacetype + u uncommontype + } + return &(*u)(unsafe.Pointer(t)).u + default: + type u struct { + _type + u uncommontype + } + return &(*u)(unsafe.Pointer(t)).u + } +} + +``` + +## eface 与 iface + +### eface + +不包含任何方法的 interface{} 类型在底层其实就是 eface 结构体,我们先来看 eface 结构体的组成: + + +``` +type eface struct { // 16 bytes + _type *_type + data unsafe.Pointer +} + +``` + +由于 interface{} 类型不包含任何方法,所以它的结构也相对来说比较简单,只包含指向底层数据和类型的两个指针,从这里的结构我们也就能够推断出: 任意的类型都可以转换成 interface{} 类型。 + +### iface + +另一个用于表示接口 interface 类型的结构体就是 iface 了,在这个结构体中也有指向原始数据的指针 data,在这个结构体中更重要的其实是 itab 类型的 tab 字段。 + +``` +type iface struct { // 16 bytes + tab *itab + data unsafe.Pointer +} + +``` + +### itab 结构体 + +itab 结构体是接口类型的核心组成部分,每一个 itab 都占 32 字节的空间。 + +_type 实际上是 iface 实际的对象类型。 + +itab 结构体中还包含另一个表示接口类型的 interfacetype 字段,它就是一个对 _type 类型的简单封装,属于我们上面所说的 `_type` 的 extras 字段。 + +hash 字段其实是对 `_type.hash` 的拷贝,它会在从 interface 到具体类型的切换时用于快速判断目标类型和接口中类型是否一致;最后的 fun 数组其实是一个动态大小的数组,如果当前数组中内容为空就表示 `_type` 没有实现 inter 接口,虽然这是一个大小固定的数组,但是在使用时会直接通过指针获取其中的数据并不会检查数组的边界,所以该数组中保存的元素数量是不确定的。 + +``` +type itab struct { + inter *interfacetype + _type *_type + hash uint32 // copy of _type.hash. Used for type switches. + _ [4]byte + fun [1]uintptr // variable sized. fun[0]==0 means _type does not implement inter. +} + +type interfacetype struct { + typ _type + pkgpath name + mhdr []imethod +} +``` + + +## 接口的转换 + +### 指针类型 + +``` +package main + +type Duck interface { + Quack() +} + +type Cat struct { + Name string +} + +//go:noinline +func (c *Cat) Quack() { + println(c.Name + " meow") +} + +func main() { + var c Duck = &Cat{Name: "grooming"} + c.Quack() +} +``` + +将上述代码编译成汇编语言之后,我们删掉其中一些对理解接口原理无用的指令,只保留与赋值语句 var c Duck = &Cat{Name: "grooming"} 相关的代码,先来了解一下结构体指针被装到接口变量 c 的过程: + +``` +LEAQ type."".Cat(SB), AX +MOVQ AX, (SP) +CALL runtime.newobject(SB) +MOVQ 8(SP), DI +MOVQ $8, 8(DI) +LEAQ go.string."grooming"(SB), AX +MOVQ AX, (DI) +LEAQ go.itab.*"".Cat,"".Duck(SB), AX +TESTB AL, (AX) +MOVQ DI, (SP) + +``` + +这段代码的第一部分其实就是对 Cat 结构体的初始化,我们直接展示上述汇编语言对应的伪代码,帮助我们更快地理解这个过程: + +``` +LEAQ type."".Cat(SB), AX ;; AX = &type."".Cat +MOVQ AX, (SP) ;; SP = &type."".Cat +CALL runtime.newobject(SB) ;; SP + 8 = &Cat{} +MOVQ 8(SP), DI ;; DI = &Cat{} +MOVQ $8, 8(DI) ;; StringHeader(DI.Name).Len = 8 +LEAQ go.string."grooming"(SB), AX ;; AX = &"grooming" +MOVQ AX, (DI) ;; StringHeader(DI.Name).Data = &"grooming" + +``` + +- 获取 Cat 结构体类型指针并将其作为参数放到栈 SP 上; +- 通过 CALL 指定调用 runtime.newobject 函数,这个函数会以 Cat 结构体类型指针作为入参,分配一片新的内存空间并将指向这片内存空间的指针返回到 SP+8 上; +- SP+8 现在存储了一个指向 Cat 结构体的指针,我们将栈上的指针拷贝到寄存器 DI 上方便操作; +- 由于 Cat 中只包含一个字符串类型的 Name 变量,所以在这里会分别将字符串地址 &"grooming" 和字符串长度 8 设置到结构体上,最后三行汇编指令的作用就等价于 cat.Name = "grooming"; + +字符串在运行时的表示其实就是指针加上字符串长度,我们这里要看一下初始化之后的 Cat 结构体在内存中的表示是什么样的: + +![](img/cat.png) + +每一个 Cat 结构体在内存中的大小都是 16 字节,这是因为其中只包含一个字符串字段,而字符串在 Go 语言中总共占 16 字节,初始化 Cat 结构体之后就进入了将 *Cat 转换成 Duck 类型的过程了: + +``` +LEAQ go.itab.*"".Cat,"".Duck(SB), AX ;; AX = *itab(go.itab.*"".Cat,"".Duck) +MOVQ AX, (SP) ;; SP = AX +CALL "".(*Cat).Quack(SB) ;; SP.Quack() + +``` +Duck 作为一个包含方法的接口,它在底层就会使用 iface 结构体进行表示,iface 结构体包含两个字段,其中一个是指向数据的指针,另一个是表示接口和结构体关系的 tab 字段,我们已经通过上一段代码在栈上的 SP+8 初始化了 Cat 结构体指针,这段代码其实只是将编译期间生成的 itab 结构体指针复制到 SP 上: + +![](img/cat1.png) + +我们会发现 SP 和 SP+8 总共 16 个字节共同组成了 iface 结构体,栈上的这个 iface 结构体也就是 Quack 方法的第一个入参。 + +到这里已经完成了对 Cat 指针转换成 iface 结构体并调用 Quack 方法过程的分析,我们再重新回顾一下整个调用过程的汇编代码和伪代码,其中的大部分内容都是对 Cat 指针和 iface 的初始化,调用 Quack 方法时其实也只执行了一个汇编指令,调用的过程也没有经过动态派发的过程,这其实就是 Go 语言编译器帮我们做的优化了. + +### 结构体类型 + +``` +package main + +type Duck interface { + Quack() +} + +type Cat struct { + Name string +} + +//go:noinline +func (c Cat) Quack() { + println(c.Name + " meow") +} + +func main() { + var c Duck = Cat{Name: "grooming"} + c.Quack() +} + +``` + +编译上述的代码其实会得到如下所示的汇编指令,需要注意的是为了代码更容易理解和分析,这里的汇编指令依然经过了删减,不过不会影响具体的执行过程: + +``` +XORPS X0, X0 +MOVUPS X0, ""..autotmp_1+32(SP) +LEAQ go.string."grooming"(SB), AX +MOVQ AX, ""..autotmp_1+32(SP) +MOVQ $8, ""..autotmp_1+40(SP) +LEAQ go.itab."".Cat,"".Duck(SB), AX +MOVQ AX, (SP) +LEAQ ""..autotmp_1+32(SP), AX +MOVQ AX, 8(SP) +CALL runtime.convT2I(SB) +MOVQ 16(SP), AX +MOVQ 24(SP), CX +MOVQ 24(AX), AX +MOVQ CX, (SP) +CALL AX + +``` + +我们先来看一下上述汇编代码中用于初始化 Cat 结构体的部分: + +``` +XORPS X0, X0 ;; X0 = 0 +MOVUPS X0, ""..autotmp_1+32(SP) ;; StringHeader(SP+32).Data = 0 +LEAQ go.string."grooming"(SB), AX ;; AX = &"grooming" +MOVQ AX, ""..autotmp_1+32(SP) ;; StringHeader(SP+32).Data = AX +MOVQ $8, ""..autotmp_1+40(SP) ;; StringHeader(SP+32).Len =8 + +``` + +这段汇编指令的工作其实与上一节中的差不多,这里会在栈上占用 16 字节初始化 Cat 结构体,不过而上一节中的代码在堆上申请了 16 字节的内存空间,栈上只是一个指向 Cat 结构体的指针。 + +初始化了结构体就进入了类型转换的阶段,编译器会将 go.itab."".Cat,"".Duck 的地址和指向 Cat 结构体的指针一并传入 runtime.convT2I 函数: + +``` +LEAQ go.itab."".Cat,"".Duck(SB), AX ;; AX = &(go.itab."".Cat,"".Duck) +MOVQ AX, (SP) ;; SP = AX +LEAQ ""..autotmp_1+32(SP), AX ;; AX = &(SP+32) = &Cat{Name: "grooming"} +MOVQ AX, 8(SP) ;; SP + 8 = AX +CALL runtime.convT2I(SB) ;; runtime.convT2I(SP, SP+8) + +``` + +这个函数会获取 itab 中存储的类型,根据类型的大小申请一片内存空间并将 elem 指针中的内容拷贝到目标的内存空间中: + +``` +func convT2I(tab *itab, elem unsafe.Pointer) (i iface) { + t := tab._type + x := mallocgc(t.size, t, true) + typedmemmove(t, x, elem) + i.tab = tab + i.data = x + return +} + +``` + +convT2I 在函数的最后会返回一个 iface 结构体,其中包含 itab 指针和拷贝的 Cat 结构体,在当前函数返回值之后,main 函数的栈上就会包含以下的数据: + +![](img/cat3.png) + +注意有两个 iface 的 itab 结构,一个位于 SP 上,一个是 convT2I 函数返回的。 + +SP 和 SP+8 中存储的 itab 和 Cat 指针就是 runtime.convT2I 函数的入参,这个函数的返回值位于 SP+16,是一个占 16 字节内存空间的 iface 结构体,SP+32 存储的就是在栈上的 Cat 结构体,它会在 runtime.convT2I 执行的过程中被拷贝到堆上。 + +在最后,我们会通过以下的操作调用 Cat 实现的接口方法 Quack(): + +``` +MOVQ 16(SP), AX ;; AX = &(go.itab."".Cat,"".Duck) +MOVQ 24(SP), CX ;; CX = &Cat{Name: "grooming"} +MOVQ 24(AX), AX ;; AX = AX.fun[0] = Cat.Quack +MOVQ CX, (SP) ;; SP = CX +CALL AX ;; CX.Quack() + +``` + +这几个汇编指令中的大多数还是非常好理解的,其中的 MOVQ 24(AX), AX 应该是最重要的指令,它从 itab 结构体中取出 Cat.Quack 方法指针,作为 CALL 指令调用时的参数,第 24 字节是 itab.fun 字段开始的位置,由于 Duck 接口只包含一个方法,所以 itab.fun[0] 中存储的就是指向 Quack 的指针了。 + +### convI2I + +上面的 convT2I 略微简单,因为 itab 是编译期已经确定的全局符号,因此运行时只需把它赋值给新的 interface 变量即可。 + +但是 convI2I 是 interface 到 interface 的转化,这个就涉及到了 interface 函数的变化。 + +``` +func convI2I(inter *interfacetype, i iface) (r iface) { + tab := i.tab + if tab == nil { + return + } + if tab.inter == inter { + r.tab = tab + r.data = i.data + return + } + r.tab = getitab(inter, tab._type, false) + r.data = i.data + return +} + +``` + +函数中 inter 是想要转化成的接口类型,i 是现在变量的接口类型。我们可以见到,函数最关键的是 getitab 函数,它的参数一是想要转化为的接口,参数二是接口中数据的实际类型 _type。 + +可以看到,这个函数 + +``` +func getitab(inter *interfacetype, typ *_type, canfail bool) *itab { + var m *itab + + // First, look in the existing table to see if we can find the itab we need. + // This is by far the most common case, so do it without locks. + // Use atomic to ensure we see any previous writes done by the thread + // that updates the itabTable field (with atomic.Storep in itabAdd). + t := (*itabTableType)(atomic.Loadp(unsafe.Pointer(&itabTable))) + if m = t.find(inter, typ); m != nil { + goto finish + } + + // Not found. Grab the lock and try again. + lock(&itabLock) + if m = itabTable.find(inter, typ); m != nil { + unlock(&itabLock) + goto finish + } + + // Entry doesn't exist yet. Make a new entry & add it. + m = (*itab)(persistentalloc(unsafe.Sizeof(itab{})+uintptr(len(inter.mhdr)-1)*sys.PtrSize, 0, &memstats.other_sys)) + m.inter = inter + m._type = typ + m.init() + itabAdd(m) + unlock(&itabLock) +finish: + if m.fun[0] != 0 { + return m + } + if canfail { + return nil + } + // this can only happen if the conversion + // was already done once using the , ok form + // and we have a cached negative result. + // The cached result doesn't record which + // interface function was missing, so initialize + // the itab again to get the missing function name. + panic(&TypeAssertionError{concrete: typ, asserted: &inter.typ, missingMethod: m.init()}) +} + +``` + +- 先用t保存全局itabTable的地址,然后使用t.find去查找,这样是为了防止查找过程中,itabTable被替换导致查找错误。 +- 如果没找到,那么就会上锁,然后使用itabTable.find去查找,这样是因为在第一步查找的同时,另外一个协程写入,可能导致实际存在却查找不到,这时上锁避免itabTable被替换,然后直接在itaTable中查找。 +- 再没找到,说明确实没有,那么就根据接口类型、数据类型,去生成一个新的itab,然后插入到itabTable中,这里可能会导致hash表扩容,如果数据类型并没有实现接口,那么根据调用方式,该报错报错,该panic panic。 + +``` +func (t *itabTableType) find(inter *interfacetype, typ *_type) *itab { + // Implemented using quadratic probing. + // Probe sequence is h(i) = h0 + i*(i+1)/2 mod 2^k. + // We're guaranteed to hit all table entries using this probe sequence. + mask := t.size - 1 + h := itabHashFunc(inter, typ) & mask + for i := uintptr(1); ; i++ { + p := (**itab)(add(unsafe.Pointer(&t.entries), h*sys.PtrSize)) + // Use atomic read here so if we see m != nil, we also see + // the initializations of the fields of m. + // m := *p + m := (*itab)(atomic.Loadp(unsafe.Pointer(p))) + if m == nil { + return nil + } + if m.inter == inter && m._type == typ { + return m + } + h += i + h &= mask + } +} + +``` + +从注释我们可以看到,golang使用的开放地址探测法,用的是公式h(i) = h0 + i*(i+1)/2 mod 2^k,h0是根据接口类型和数据类型的hash字段算出来的。 + +#### itab.init + +如果实在找不到,那么就要生成一个新的 itab 了: + + +``` +func (m *itab) init() string { + inter := m.inter + typ := m._type + x := typ.uncommon() + + // both inter and typ have method sorted by name, + // and interface names are unique, + // so can iterate over both in lock step; + // the loop is O(ni+nt) not O(ni*nt). + ni := len(inter.mhdr) + nt := int(x.mcount) + xmhdr := (*[1 << 16]method)(add(unsafe.Pointer(x), uintptr(x.moff)))[:nt:nt] + j := 0 + methods := (*[1 << 16]unsafe.Pointer)(unsafe.Pointer(&m.fun[0]))[:ni:ni] + var fun0 unsafe.Pointer +imethods: + for k := 0; k < ni; k++ { + i := &inter.mhdr[k] + itype := inter.typ.typeOff(i.ityp) + name := inter.typ.nameOff(i.name) + iname := name.name() + ipkg := name.pkgPath() + if ipkg == "" { + ipkg = inter.pkgpath.name() + } + for ; j < nt; j++ { + t := &xmhdr[j] + tname := typ.nameOff(t.name) + if typ.typeOff(t.mtyp) == itype && tname.name() == iname { + pkgPath := tname.pkgPath() + if pkgPath == "" { + pkgPath = typ.nameOff(x.pkgpath).name() + } + if tname.isExported() || pkgPath == ipkg { + if m != nil { + ifn := typ.textOff(t.ifn) + if k == 0 { + fun0 = ifn // we'll set m.fun[0] at the end + } else { + methods[k] = ifn + } + } + continue imethods + } + } + } + // didn't find method + m.fun[0] = 0 + return iname + } + m.fun[0] = uintptr(fun0) + m.hash = typ.hash + return "" +} + +``` + +从这个方法可以看出来,任何类型的函数都是存放在 typ.uncommon 中的,距离 typ.uncommon 的 x.moff 的位置就是该类型的函数列表。 + +这个方法会检查interface和type的方法是否匹配,即type有没有实现interface。假如interface有n中方法,type有m中方法,那么匹配的时间复杂度是O(n x m),由于interface、type的方法都按字典序排,所以O(n+m)的时间复杂度可以匹配完。在检测的过程中,匹配上了,依次往fun字段写入type中对应方法的地址。如果有一个方法没有匹配上,那么就设置fun[0]为0,在外层调用会检查fun[0]==0,即type并没有实现interface。 + +#### itabAdd + +``` +func itabAdd(m *itab) { + // Bugs can lead to calling this while mallocing is set, + // typically because this is called while panicing. + // Crash reliably, rather than only when we need to grow + // the hash table. + if getg().m.mallocing != 0 { + throw("malloc deadlock") + } + + t := itabTable + if t.count >= 3*(t.size/4) { // 75% load factor + // Grow hash table. + // t2 = new(itabTableType) + some additional entries + // We lie and tell malloc we want pointer-free memory because + // all the pointed-to values are not in the heap. + t2 := (*itabTableType)(mallocgc((2+2*t.size)*sys.PtrSize, nil, true)) + t2.size = t.size * 2 + + // Copy over entries. + // Note: while copying, other threads may look for an itab and + // fail to find it. That's ok, they will then try to get the itab lock + // and as a consequence wait until this copying is complete. + iterate_itabs(t2.add) + if t2.count != t.count { + throw("mismatched count during itab table copy") + } + // Publish new hash table. Use an atomic write: see comment in getitab. + atomicstorep(unsafe.Pointer(&itabTable), unsafe.Pointer(t2)) + // Adopt the new table as our own. + t = itabTable + // Note: the old table can be GC'ed here. + } + t.add(m) +} + +``` + +可以看到,当hash表使用达到75%或以上时,就会进行扩容,容量是原来的2倍,申请完空间,就会把老表中的数据插入到新的hash表中。然后使itabTable指向新的表,最后把新的itab插入到新表中。 + +``` +func (t *itabTableType) add(m *itab) { + // See comment in find about the probe sequence. + // Insert new itab in the first empty spot in the probe sequence. + mask := t.size - 1 + h := itabHashFunc(m.inter, m._type) & mask + for i := uintptr(1); ; i++ { + p := (**itab)(add(unsafe.Pointer(&t.entries), h*sys.PtrSize)) + m2 := *p + if m2 == m { + // A given itab may be used in more than one module + // and thanks to the way global symbol resolution works, the + // pointed-to itab may already have been inserted into the + // global 'hash'. + return + } + if m2 == nil { + // Use atomic write here so if a reader sees m, it also + // sees the correctly initialized fields of m. + // NoWB is ok because m is not in heap memory. + // *p = m + atomic.StorepNoWB(unsafe.Pointer(p), unsafe.Pointer(m)) + t.count++ + return + } + h += i + h &= mask + } +} + +``` + +## 类型断言 + +### 类型与接口断言 + +``` +package main + +type Duck interface { + Quack() +} + +type Cat struct { + Name string +} + +//go:noinline +func (c *Cat) Quack() { + println(c.Name + " meow") +} + +func main() { + var c Duck = &Cat{Name: "grooming"} + switch c.(type) { + case *Cat: + cat := c.(*Cat) + cat.Quack() + } +} + +``` + +当我们编译了上述代码之后,会得到如下所示的汇编指令,这里截取了从创建结构体到执行 switch/case 结构的代码片段: + +``` +00000 TEXT "".main(SB), ABIInternal, $32-0 +... +00029 XORPS X0, X0 +00032 MOVUPS X0, ""..autotmp_4+8(SP) +00037 LEAQ go.string."grooming"(SB), AX +00044 MOVQ AX, ""..autotmp_4+8(SP) +00049 MOVQ $8, ""..autotmp_4+16(SP) +00058 CMPL go.itab.*"".Cat,"".Duck+16(SB), $593696792 +00068 JEQ 80 +00070 MOVQ 24(SP), BP +00075 ADDQ $32, SP +00079 RET +00080 LEAQ ""..autotmp_4+8(SP), AX +00085 MOVQ AX, (SP) +00089 CALL "".(*Cat).Quack(SB) +00094 JMP 70 + +``` +我们可以直接跳过初始化 Duck 变量的过程,从 0058 开始分析随后的汇编指令,需要注意的是 SP+8 ~ SP+24 16 个字节的位置存储了 Cat 结构体,Go 语言的编译器做了一些优化,所以我们没有看到 iface 结构体的构建过程,但是对于这里要介绍的类型断言和转换其实没有太多的影响: + +``` +00058 CMPL go.itab.*"".Cat,"".Duck+16(SB), $593696792 + ;; if (c.tab.hash != 593696792) { +00068 JEQ 80 ;; +00070 MOVQ 24(SP), BP ;; BP = SP+24 +00075 ADDQ $32, SP ;; SP += 32 +00079 RET ;; return + ;; } else { +00080 LEAQ ""..autotmp_4+8(SP), AX ;; AX = &Cat{Name: "grooming"} +00085 MOVQ AX, (SP) ;; SP = AX +00089 CALL "".(*Cat).Quack(SB) ;; SP.Quack() +00094 JMP 70 ;; ... + ;; BP = SP+24 + ;; SP += 32 + ;; return + ;; } + +``` + +switch/case 语句生成的汇编指令会将目标类型的 hash 与接口变量中的 itab.hash 进行比较,如果两者完全相等就会认为接口变量的具体类型是 Cat,这时就会进入 0080 所在的分支,开始类型转换的过程,我们会获取 SP+8 存储的 Cat 结构体指针、将其拷贝到 SP 上、调用 Quack 方法,最终恢复当前函数的堆栈后返回,不过如果接口中存在的具体类型不是 Cat,就会直接恢复栈指针并返回到调用方。 + +### 接口与接口断言 + +``` +func assertI2I(inter *interfacetype, i iface) (r iface) { + tab := i.tab + if tab == nil { + // explicit conversions require non-nil interface value. + panic(&TypeAssertionError{nil, nil, &inter.typ, ""}) + } + if tab.inter == inter { + r.tab = tab + r.data = i.data + return + } + r.tab = getitab(inter, tab._type, false) + r.data = i.data + return +} + +func assertI2I2(inter *interfacetype, i iface) (r iface, b bool) { + tab := i.tab + if tab == nil { + return + } + if tab.inter != inter { + tab = getitab(inter, tab._type, true) + if tab == nil { + return + } + } + r.tab = tab + r.data = i.data + b = true + return +} + +func assertE2I(inter *interfacetype, e eface) (r iface) { + t := e._type + if t == nil { + // explicit conversions require non-nil interface value. + panic(&TypeAssertionError{nil, nil, &inter.typ, ""}) + } + r.tab = getitab(inter, t, false) + r.data = e.data + return +} + +func assertE2I2(inter *interfacetype, e eface) (r iface, b bool) { + t := e._type + if t == nil { + return + } + tab := getitab(inter, t, true) + if tab == nil { + return + } + r.tab = tab + r.data = e.data + b = true + return +} + +``` diff --git "a/Go panic \345\222\214 recover.md" "b/Go panic \345\222\214 recover.md" new file mode 100644 index 0000000..a2146a3 --- /dev/null +++ "b/Go panic \345\222\214 recover.md" @@ -0,0 +1,238 @@ +# Go panic 和 recover + +## 概述 + +在具体介绍和分析 Go 语言中的 panic 和 recover 的实现原理之前,我们首先需要对它们有一些基本的了解;panic 和 recover 两个关键字其实都是 Go 语言中的内置函数,panic 能够改变程序的控制流,当一个函数调用执行 panic 时,它会立刻停止执行函数中其他的代码,而是会运行其中的 defer 函数,执行成功后会返回到调用方。 + +对于上层调用方来说,调用导致 panic 的函数其实与直接调用 panic 类似,所以也会执行所有的 defer 函数并返回到它的调用方,这个过程会一直进行直到当前 Goroutine 的调用栈中不包含任何的函数,这时整个程序才会崩溃,这个『恐慌过程』不仅会被显式的调用触发,还会由于运行期间发生错误而触发。 + +然而 panic 导致的『恐慌』状态其实可以被 defer 中的 recover 中止,recover 是一个只在 defer 中能够发挥作用的函数,在正常的控制流程中,调用 recover 会直接返回 nil 并且没有任何的作用,但是如果当前的 Goroutine 发生了『恐慌』,recover 其实就能够捕获到 panic 抛出的错误并阻止『恐慌』的继续传播。 + +``` +func main() { + defer println("in main") + go func() { + defer println("in goroutine") + panic("") + }() + + println("in main...") + + time.Sleep(1 * time.Second) +} + +// in main... +// in goroutine +// panic: +// ... + +``` + +当我们运行这段代码时,其实会发现 main 函数中的 defer 语句并没有执行,执行的其实只有 Goroutine 中的 defer,这其实就印证了 Go 语言在发生 panic 时只会执行当前协程中的 defer 函数,这一点从 上一节 的源代码中也有所体现。 + +另一个例子就不止涉及 panic 和 defer 关键字了,我们可以看一下 recover 是如何让当前函数重新『走向正轨』的: + +``` +func main() { + defer println("in main") + go func() { + defer println("in goroutine") + defer func() { + if err := recover(); err != nil { + fmt.Println(err) + } + }() + panic("G panic") + }() + + println("in main...") + + time.Sleep(1 * time.Second) +} + +in main... +G panic +in goroutine +in main + +``` + +从这个例子中我们可以看到,recover 函数其实只是阻止了当前程序的崩溃,但是当前控制流中的其他 defer 函数还会正常执行。 + +## 实现原理 + +### 数据结构 + +panic 在 Golang 中其实是由一个数据结构表示的,每当我们调用一次 panic 函数都会创建一个如下所示的数据结构存储相关的信息: + +``` +type _panic struct { + argp unsafe.Pointer + arg interface{} + link *_panic + recovered bool + aborted bool +} +``` + +- argp 是指向 defer 调用时参数的指针; +- arg 是调用 panic 时传入的参数; +- link 指向了更早调用的 _panic 结构; +- recovered 表示当前 _panic 是否被 recover 恢复; +- aborted 表示当前的 panic 是否被强行终止; + +从数据结构中的 link 字段我们就可以推测出以下的结论 — panic 函数可以被连续多次调用,它们之间通过 link 的关联形成一个链表。 + +### 崩溃 + +首先了解一下没有被 recover 的 panic 函数是如何终止整个程序的,我们来看一下 gopanic 函数的实现 + +``` +func gopanic(e interface{}) { + gp := getg() + // ... + var p _panic + p.arg = e + p.link = gp._panic + gp._panic = (*_panic)(noescape(unsafe.Pointer(&p))) + + for { + d := gp._defer + if d == nil { + break + } + + d._panic = (*_panic)(noescape(unsafe.Pointer(&p))) + + p.argp = unsafe.Pointer(getargp(0)) + reflectcall(nil, unsafe.Pointer(d.fn), deferArgs(d), uint32(d.siz), uint32(d.siz)) + p.argp = nil + + d._panic = nil + d.fn = nil + gp._defer = d.link + + pc := d.pc + sp := unsafe.Pointer(d.sp) + freedefer(d) + if p.recovered { + // ... + } + } + + fatalpanic(gp._panic) + *(*int)(nil) = 0 +} + +``` + +我们暂时省略了 recover 相关的代码,省略后的 gopanic 函数执行过程包含以下几个步骤: + +- 获取当前 panic 调用所在的 Goroutine 协程; +- 创建并初始化一个 _panic 结构体; +- 从当前 Goroutine 中的链表获取一个 _defer 结构体; +- 如果当前 _defer 存在,调用 reflectcall 执行 _defer 中的代码; +- 将下一位的 _defer 结构设置到 Goroutine 上并回到 3; +- 调用 fatalpanic 中止整个程序; + +fatalpanic 函数在中止整个程序之前可能就会通过 printpanics 打印出全部的 panic 消息以及调用时传入的参数: + +``` +func fatalpanic(msgs *_panic) { + pc := getcallerpc() + sp := getcallersp() + gp := getg() + var docrash bool + systemstack(func() { + if startpanic_m() && msgs != nil { + atomic.Xadd(&runningPanicDefers, -1) + + printpanics(msgs) + } + docrash = dopanic_m(gp, pc, sp) + }) + + if docrash { + crash() + } + + systemstack(func() { + exit(2) + }) + + *(*int)(nil) = 0 // not reached +} + +``` + +在 fatalpanic 函数的最后会通过 exit 退出当前程序并返回错误码 2,不同的操作系统其实对 exit 函数有着不同的实现,其实最终都执行了 exit 系统调用来退出程序。 + +### 恢复 + +到了这里我们已经掌握了 panic 退出程序的过程,但是一个 panic 的程序也可能会被 defer 中的关键字 recover 恢复,在这时我们就回到 recover 关键字对应函数 gorecover 的实现了: + +``` +func gorecover(argp uintptr) interface{} { + p := gp._panic + if p != nil && !p.recovered && argp == uintptr(p.argp) { + p.recovered = true + return p.arg + } + return nil +} + +``` + +这个函数的实现其实非常简单,它其实就是会修改 panic 结构体的 recovered 字段,当前函数的调用其实都发生在 gopanic 期间,我们重新回顾一下这段方法的实现: + +``` +func gopanic(e interface{}) { + // ... + + for { + // reflectcall + + pc := d.pc + sp := unsafe.Pointer(d.sp) + + // ... + if p.recovered { + gp._panic = p.link + for gp._panic != nil && gp._panic.aborted { + gp._panic = gp._panic.link + } + if gp._panic == nil { + gp.sig = 0 + } + gp.sigcode0 = uintptr(sp) + gp.sigcode1 = pc + mcall(recovery) + throw("recovery failed") + } + } + + fatalpanic(gp._panic) + *(*int)(nil) = 0 +} + +``` + +上述这段代码其实从 _defer 结构体中取出了程序计数器 pc 和栈指针 sp 并调用 recovery 方法进行调度,调度之前会准备好 sp、pc 以及函数的返回值: + +``` +func recovery(gp *g) { + sp := gp.sigcode0 + pc := gp.sigcode1 + + gp.sched.sp = sp + gp.sched.pc = pc + gp.sched.lr = 0 + gp.sched.ret = 1 + gogo(&gp.sched) +} + +``` + +这里的调度其实会将 deferproc 函数的返回值设置成 1,在这时编译器生成的代码就会帮助我们直接跳转到调用方函数 return 之前并进入 deferreturn 的执行过程. + +跳转到 deferreturn 函数之后,程序其实就从 panic 的过程中跳出来恢复了正常的执行逻辑,而 gorecover 函数也从 _panic 结构体中取出了调用 panic 时传入的 arg 参数。 \ No newline at end of file diff --git "a/Go \345\206\205\345\255\230\344\270\200\350\207\264\346\200\247\346\250\241\345\236\213.md" "b/Go \345\206\205\345\255\230\344\270\200\350\207\264\346\200\247\346\250\241\345\236\213.md" new file mode 100644 index 0000000..7a95111 --- /dev/null +++ "b/Go \345\206\205\345\255\230\344\270\200\350\207\264\346\200\247\346\250\241\345\236\213.md" @@ -0,0 +1,984 @@ +# Go 内存一致性模型 + +[TOC] + +## MESI 与 Cache Coherence + +### CPU 的多级缓存 + +计算机硬件的一些延迟。主要关注两个,L1 cache,0.5ns;内存,100ns。可见,平时我们认为的很快的内存,其实在CPU面前,还是非常慢的。想想一下,执行一条加法指令只要一个周期,但是我们这个加法的执行结果写到内存,却要等100个周期。这样的速度显然无法接受。 + +因此,我们有了Cache,并且是多级的Cache,现在的Intel CPU通常有3级cache,例如我自己的电脑上,L1 data cache 有32K,L1 instruction cache 是32K,L2和L3分别是256K和6144K。不同的架构中,Cache会有所区别,比如超线程的CPU中,L1Cache是独占的,L2是Core共享的。 + +anyway,cache其实缓解了内存访问的延迟问题。不过它也带来了另一个问题:一致性。 + +一个变量(一个内存位置)其实可以被多个Cache所共享。那么,当我们需要修改这个变量的时候,Cache要如何保持一致呢? + +理想情况下,原子地修改多个Cache,但多个CPU之间往往通过总线进行通信,不可能同时修改多个;所以其实要制造一种假象,看起来是原子地修改多个Cache,也就是让Cache看起来是强一致的。 + +### Cache Coherence——MESI + +基于总线通信去实现Cache的强一致,这个问题比较明确,目前用的比较多的应该是MESI协议,或者是一些优化的协议。基本思想是这样子的:一个Cache加载一个变量的时候,是Exclusive状态,当这个变量被第二个Cache加载,更改状态为Shared;这时候一个CPU要修改变量, 就把状态改为Modified,并且Invalidate其他的Cache,其他的Cache再去读这个变量,达到一致。MESI协议大致是这样子,但是状态转换要比这个复杂的多。 + +缓存行有4种不同的状态: + +- 独占 Exclusive (E):缓存行只在当前缓存中,但是干净的(clean)--缓存数据同于主存数据。当别的缓存读取它时,状态变为共享;当前写数据时,变为已修改状态。 +- 共享 Shared (S):缓存行也存在于其它缓存中且是干净的。缓存行可以在任意时刻抛弃。 +无效Invalid (I) +- 已修改 Modified (M):缓存行是脏的(dirty),与主存的值不同。如果别的CPU内核要读主存这块数据,该缓存行必须回写到主存,状态变为共享(S). +- 缓存行是无效的 + +### MESI 的状态转移 + +处理器对缓存的请求,也就是 CPU 与 cache 之间的通讯: + +- PrRd: 处理器请求读一个缓存块 +- PrWr: 处理器请求写一个缓存块 + +总线对缓存的请求,也就是 cache 之间的通讯总线: + +- BusRd: 窥探器请求指出其他处理器请求 **读一个** 缓存块 +- BusRdX: 窥探器请求指出其他处理器请求 **写一个** 该处理器 **不拥有** 的缓存块 +- BusUpgr: 窥探器请求指出其他处理器请求 **写一个** 该处理器 **拥有** 的缓存块 +- Flush: 窥探器请求指出请求回写整个缓存到主存 +- FlushOpt: 窥探器请求指出整个缓存块被发到总线以发送给另外一个处理器(缓存到缓存的复制) + +![](img/mesi.png) + +![](img/mesi2.png) + +操作仅在缓存行是已修改或独占状态时可自由执行。如果在共享状态,其他缓存都要先把该缓存行置为无效,这种广播操作称作Request For Ownership (RFO). + +个人认为,图二中缺少 shared 状态下接受到 BusUpgr 的情况,这类情况和 BusRdx 其实是一致的,都是要转化为 Invalid 状态。 + +### MOESI、MESIF、RMW 与 LOCK 前缀指令 + +MESI 还有很多扩展协议。 + +常见的扩展包括“O”(Owned)状态,它和 E 状态类似,也是保证缓存间一致性的手段,但它直接共享脏段的内容,而不需要先把它们回写到内存中(“脏段共享”),由此产生了 MOSEI 协议。 + +MESIF 是指当多个处理器同时拥有某个 S 状态的缓存段的时候,只有被指定的那个处理器(对应的缓存段为 R 或 F 状态)才能对读操作做出回应,而不是每个处理器都能这么做。这种设计可以降低总线的数据流量。 + +#### RMW + +但是我们注意到一个问题,那就是当我们的 CPU0 cache 处于 Invalid(I) 的时候,我们想要执行 PrWr 的操作。按照协议我们会发出 BusRdX 信号,其他 CPU 会无效它们的副本。那么假如正好有一个 CPU1 的 cache 的状态是 Modified,会发生什么? + +按照协议,CPU1 会回写主存,并且转化为 Invalid 状态。CPU0 读到 CPU1 发来的新的内存值,然后更改为自己的新值。 + +我们发现,CPU1 缓存的值被 CPU0 覆盖了。 + +对于 Read-Modify-Write 类型的操作影响比较大,例如两个线程都执行 i++。假如 i 的初值为 0,当 RMW 执行 Read 操作的时候,CPU0 cache 还是 Shared 状态,等到 CPU 修改了寄存器,寄存器需要写入到 cache 的时候,CPU1 已经完成写入操作,CPU0 cache 状态已经变成了 Invalid,那么这个时候 CPU0 的 i 值 1 会覆盖掉 CPU1 的自增结果,导致两个 i++ 操作之后,结果还是 1。 + +例如,在 Load-Store 体系中,如果对一个非原子的内存中的变量a加1,则在Load-Store体系中,可能需要: + +``` +lw r1, a +addi r1, r1, 1 +sw a, r1 + +``` +当一个core执行这段代码的时候,另一个core也可能在执行相同的代码。导致尽管两个core分别对a加了1,最终存回到memory中的a仍然只加了1,而没有加2.虽然任何对齐于数据结构本身的 load 和 store 一般都是原子操作,因为 core 对于这种数据结构的 load 和 store 仅需要一条指令就可以完成,其他 core 没有机会观察到中间状态。但是这三个指令结合起来却不是原子的。 + +对于非 Load-Store 体系,例如 X86, 上面三个指令可能只需要一条指令就可以完成,但是这一条指令实际上 core 还是需要执行载入-更改-写回三步,任何一步都可能被打断。 + +在单处理器系统(UniProcessor,简称 UP)中,能够在单条指令中完成的操作都可以认为是原子操作,因为单核情况下,并发只能出现在中断上下文中,但是中断只能发生在指令与指令之间。 + +在多处理器系统(Symmetric Multi-Processor,简称 SMP)中情况有所不同,由于系统中有多个处理器在独立的运行,存在并行的可能,即使在能单条指令中完成的操作也可能受到干扰。 + +这个时候,就需要一种协调各个 CPU 操作的协议,让这个 RMW 成为一个原子操作,操作期间不会受多核 CPU 的影响。 + +#### LOCK 前缀 + +在所有的 X86 CPU 上都具有锁定一个特定内存地址的能力,当这个特定内存地址被锁定后,它就可以阻止其他的系统总线读取或修改这个内存地址。这种能力是通过 LOCK 指令前缀再加上下面的汇编指令来实现的。当使用 LOCK 指令前缀时,它会使 CPU 宣告一个 LOCK# 信号,这样就能确保在多处理器系统或多线程竞争的环境下互斥地使用这个内存地址。当指令执行完毕,这个锁定动作也就会消失。 + +能够和 LOCK 指令前缀一起使用的指令如下所示: + +> BT, BTS, BTR, BTC (mem, reg/imm) +> +> XCHG, XADD (reg, mem / mem, reg) +> +> ADD, OR, ADC, SBB (mem, reg/imm) +> +> AND, SUB, XOR (mem, reg/imm) +> +> NOT, NEG, INC, DEC (mem) +> + +注意:XCHG 和 XADD (以及所有以 'X' 开头的指令)都能够保证在多处理器系统下的原子操作,它们总会宣告一个 "LOCK#" 信号,而不管有没有 LOCK 前缀。 + +从Pentium 4,Intel Xeon及P6处理器开始,intel在原有总线锁的基础上做了一个很有意义的优化:如果要访问的内存区域(area of memory)在lock前缀指令执行期间已经在处理器内部的缓存中被锁定(即包含该内存区域的缓存行当前处于独占或以修改状态),并且该内存区域被完全包含在单个缓存行(cache line)中,那么处理器将直接执行该指令。由于在指令执行期间该缓存行会一直被锁定,其它处理器无法读/写该指令要访问的内存区域,因此能保证指令执行的原子性。这个操作过程叫做缓存锁定(cache locking),缓存锁定将大大降低lock前缀指令的执行开销,但是当多处理器之间的竞争程度很高或者指令访问的内存地址未对齐时,仍然会锁住总线。 + +假设两个core都持有相同地址对应cacheline,且各自cacheline 状态为S, 这时如果要想执行 LOCK 指令,成功修改内存值,就首先需要把S转为E或者M, 则需要向其它core invalidate 这个地址的cacheline,则两个core都会向ring bus 发出 invalidate这个操作, 那么在ringbus上就会根据特定的设计协议仲裁是core0,还是core1能赢得这个invalidate, 胜者完成操作, 失败者需要接受结果, invalidate自己对应的cacheline,再读取胜者修改后的值, 回到起点. + +除此之外,LOCK 还有禁止该指令与之前和之后的读和写指令重排序,把写缓冲区中的所有数据刷新到内存中的功能,这两个功能我们接下来详细再说。 + +### false sharing / true sharing + +#### true sharing + +true sharing 的概念比较好理解,在对全局变量或局部变量进行多线程修改时,就是一种形式的共享,而且非常字面意思,就是 true sharing。true sharing 带来的明显的问题,例如 RWMutex scales poorly 的官方 issue,即 RWMutex 的 RLock 会对 RWMutex 这个对象的 readerCount 原子加一。本质上就是一种 true sharing。 + +#### false sharing + +缓存系统中是以缓存行(cache line)为单位存储的。缓存行通常是 64 字节(译注:本文基于 64 字节,其他长度的如 32 字节等不适本文讨论的重点),并且它有效地引用主内存中的一块地址。一个 Java 的 long 类型是 8 字节,因此在一个缓存行中可以存 8 个 long 类型的变量。所以,如果你访问一个 long 数组,当数组中的一个值被加载到缓存中,它会额外加载另外 7 个,以致你能非常快地遍历这个数组。事实上,你可以非常快速的遍历在连续的内存块中分配的任意数据结构。而如果你在数据结构中的项在内存中不是彼此相邻的(如链表),你将得不到免费缓存加载所带来的优势,并且在这些数据结构中的每一个项都可能会出现缓存未命中。 + +如果存在这样的场景,有多个线程操作不同的成员变量,但是相同的缓存行,这个时候会发生什么?。没错,伪共享(False Sharing)问题就发生了!有张 Disruptor 项目的经典示例图,如下: + +![](img/falseshare.png) + +上图中,一个运行在处理器 core1上的线程想要更新变量 X 的值,同时另外一个运行在处理器 core2 上的线程想要更新变量 Y 的值。但是,这两个频繁改动的变量都处于同一条缓存行。两个线程就会轮番发送 RFO 消息,占得此缓存行的拥有权。当 core1 取得了拥有权开始更新 X,则 core2 对应的缓存行需要设为 I 状态。当 core2 取得了拥有权开始更新 Y,则 core1 对应的缓存行需要设为 I 状态(失效态)。轮番夺取拥有权不但带来大量的 RFO 消息,而且如果某个线程需要读此行数据时,L1 和 L2 缓存上都是失效数据,只有 L3 缓存上是同步好的数据。从前一篇我们知道,读 L3 的数据非常影响性能。更坏的情况是跨槽读取,L3 都要 miss,只能从内存上加载。 + +表面上 X 和 Y 都是被独立线程操作的,而且两操作之间也没有任何关系。只不过它们共享了一个缓存行,但所有竞争冲突都是来源于共享。 + +那么该如何做到呢?其实在我们注释的那行代码中就有答案,那就是缓存行填充(Padding) 。现在分析上面的例子,我们知道一条缓存行有 64 字节,而 Java 程序的对象头固定占 8 字节(32位系统)或 12 字节( 64 位系统默认开启压缩, 不开压缩为 16 字节),所以我们只需要填 6 个无用的长整型补上6*8=48字节,让不同的 VolatileLong 对象处于不同的缓存行,就避免了伪共享( 64 位系统超过缓存行的 64 字节也无所谓,只要保证不同线程不操作同一缓存行就可以)。 + +在 Go 的 runtime 中有不少例子,特别是那些 per-P 的结构,大多都有针对 false sharing 的优化: + +runtime/time.go + +``` +var timers [timersLen]struct { + timersBucket + + // The padding should eliminate false sharing + // between timersBucket values. + pad [cpu.CacheLinePadSize - unsafe.Sizeof(timersBucket{})%cpu.CacheLinePadSize]byte +} + +``` + +runtime/sema.go + +``` +var semtable [semTabSize]struct { + root semaRoot + pad [cpu.CacheLinePadSize - unsafe.Sizeof(semaRoot{})]byte +} + +``` + +## CPU 内存一致性模型 + +### store buffer + +看起来很美好的MESI协议,其实有一些问题。比如说,修改变量的时候,要发送一些Invalidate给远程的CPU,等到远程CPU返回一个ACK,才能进行下一步。 这一过程中如果远程的CPU比较繁忙,甚至会带来更大的延迟。并且如果有内存访问,会带来几百个周期的延迟。 + +那么有没有优化手段,能够并行访问内存?或者对内存操作乱序执行? + +这里用了一个称之为store buffer的结构,来对store操作进行优化。就Store操作来说,这结构所带来的效果就是,不需要等到Cache同步到所有CPU之后Store操作才返回,可能是写了本地的Store buffer就返回,什么时候所有 CPU 的 Invalidate 消息返回了再异步写进 cache line。显然,这个结果对于延迟的优化是十分明显的。 + +因而,无论什么时候 CPU 需要从 cache line 中读取,都需要先扫描它自己的 store buffer 来确认是否存在相同的 line,因为有可能当前 CPU 在这次操作之前曾经写入过 cache,但该数据还没有被刷入过 cache(之前的写操作还在 store buffer 中等待)。需要注意的是,虽然 CPU 可以读取其之前写入到 store buffer 中的值,但其它 CPU 并不能在该 CPU 将 store buffer 中的内容 flush 到 cache 之前看到这些值。即 store buffer 是不能跨核心访问的,CPU 核心看不到其它核心的 store buffer。 + +### Invalidate Queue + +为了处理 invalidation 消息,CPU 实现了 invalidate queue,借以处理新达到的 invalidate 请求,在这些请求到达时,可以马上进行响应,但可以不马上处理。取而代之的,invalidation 消息只是会被推进一个 invalidation 队列,并在之后尽快处理(但不是马上)。因此,CPU 可能并不知道在它 cache 里的某个 cache line 是 invalid 状态的,因为 invalidation 队列包含有收到但还没有处理的 invalidation 消息,CPU 在读取数据的时候,并不像 store buffer 那样提取读取 Invalidate Queue。 + +### CPU 内存一致性模型 + +目前有多种内存一致性模型: + +- 顺序存储模型(sequential consistency model) +- 完全存储定序(total store order) +- 部分存储定序(part store order) +- 宽松存储模型(relax memory order) + + +![](img/mesi3.jpg) + +#### 顺序存储模型 SC + +在顺序存储器模型里,MP(多核)会严格严格按照代码指令流来执行代码, 所以上面代码在主存里的访问顺序是: + +> S1 S2 L1 L2 + +通过上面的访问顺序我们可以看出来,虽然C1与C2的指令虽然在不同的CORE上运行,但是C1发出来的访问指令是顺序的,同时C2的指令也是顺序的。虽然这两个线程跑在不同的CPU上,但是在顺序存储模型上,其访问行为与UP(单核)上是一致的。 +我们最终看到r2的数据会是NEW,与期望的执行情况是一致的,所以在顺序存储模型上是不会出现内存访问乱序的情况. + +#### 完全存储定序 TSO + +这里我们之前所说的 store buffer 与 Invalidate Queue 开始登场,首先我们思考单核上的两条指令: + +``` +S1:store flag= set +S2:load r1=data +S3:store b=set +``` + +如果在顺序存储模型中,S1肯定会比S2先执行。但是如果在加入了store buffer之后,S1将指令放到了store buffer后会立刻返回,这个时候会立刻执行S2。S2是read指令,CPU必须等到数据读取到r1后才会继续执行。这样很可能S1的store flag=set指令还在store buffer上,而S2的load指令可能已经执行完(特别是data在cache上存在,而flag没在cache中的时候。这个时候CPU往往会先执行S2,这样可以减少等待时间) + +这里就可以看出再加入了store buffer之后,内存一致性模型就发生了改变。 +如果我们定义store buffer必须严格按照FIFO的次序将数据发送到主存(所谓的FIFO表示先进入store buffer的指令数据必须先于后面的指令数据写到存储器中),这样S3必须要在S1之后执行,CPU能够保证store指令的存储顺序,这种内存模型就叫做完全存储定序(TSO)。 + +![](img/mesi4.jpg) + +在SC模型里,C1与C2是严格按照顺序执行的 +代码可能的执行顺序如下: + +``` +S1 S2 L1 L2 +S1 L1 S2 L2 +S1 L1 L2 S2 +L1 L2 S1 S2 +L1 S1 S2 L2 +L1 S1 L2 S2 + +``` +由于SC会严格按照顺序进行,最终我们看到的结果是至少有一个CORE的r1值为NEW,或者都为NEW。 + +在TSO模型里,由于store buffer的存在,L1和S1的store指令会被先放到store buffer里面,然后CPU会继续执行后面的load指令。Store buffer中的数据可能还没有来得及往存储器中写,这个时候我们可能看到C1和C2的r1都为0的情况。 +所以,我们可以看到,在store buffer被引入之后,内存一致性模型已经发生了变化(从SC模型变为了TSO模型),会出现store-load乱序的情况,这就造成了代码执行逻辑与我们预先设想不相同的情况。而且随着内存一致性模型越宽松(通过允许更多形式的乱序读写访问),这种情况会越剧烈,会给多线程编程带来很大的挑战。 + +这个就是所谓的 Store-Load 乱序,x86 唯一的乱序就是这个了。 + +#### 部分存储定序 PSO + +芯片设计人员并不满足TSO带来的性能提升,于是他们在TSO模型的基础上继续放宽内存访问限制,允许CPU以非FIFO来处理store buffer缓冲区中的指令。CPU只保证地址相关指令在store buffer中才会以FIFO的形式进行处理,而其他的则可以乱序处理,所以这被称为部分存储定序(PSO)。 + +那我们继续分析下面的代码 + +![](img/mesi3.jpg) + +S1与S2是地址无关的store指令,cpu执行的时候都会将其推到store buffer中。如果这个时候flag在C1的cahe中存在,那么CPU会优先将S2的store执行完,然后等data缓存到C1的cache之后,再执行store data=NEW指令。 + +这个时候可能的执行顺序: + +``` +S2 L1 L2 S1 +``` + +这样在C1将data设置为NEW之前,C2已经执行完,r2最终的结果会为0,而不是我们期望的NEW,这样PSO带来的store-store乱序将会对我们的代码逻辑造成致命影响。 + +从这里可以看到,store-store乱序的时候就会将我们的多线程代码完全击溃。所以在PSO内存模型的架构上编程的时候,要特别注意这些问题。 + +#### 宽松内存模型 RMO + +丧心病狂的芯片研发人员为了榨取更多的性能,在PSO的模型的基础上,更进一步的放宽了内存一致性模型,不仅允许store-load,store-store乱序。还进一步允许load-load,load-store乱序, 只要是地址无关的指令,在读写访问的时候都可以打乱所有load/store的顺序,这就是宽松内存模型(RMO)。 + +我们再看看上面分析过的代码 + +![](img/mesi3.jpg) + +在PSO模型里,由于S2可能会比S1先执行,从而会导致C2的r2寄存器获取到的data值为0。在RMO模型里,不仅会出现PSO的store-store乱序,C2本身执行指令的时候,由于L1与L2是地址无关的,所以L2可能先比L1执行,这样即使C1没有出现store-store乱序,C2本身的load-load乱序也会导致我们看到的r2为0。从上面的分析可以看出,RMO内存模型里乱序出现的可能性会非常大,这是一种乱序随可见的内存一致性模型。 + + +### 内存屏障 + +芯片设计人员为了尽可能的榨取CPU的性能,引入了乱序的内存一致性模型,这些内存模型在多线程的情况下很可能引起软件逻辑问题。为了解决在有些一致性模型上可能出现的内存访问乱序问题,芯片设计人员提供给了内存屏障指令,用来解决这些问题。 +内存屏障的最根本的作用就是提供一个机制,要求CPU在这个时候必须以顺序存储一致性模型的方式来处理load与store指令,这样才不会出现内存访问不一致的情况。 + +对于TSO和PSO模型,内存屏障只需要在store-load/store-store时需要(写内存屏障),最简单的一种方式就是内存屏障指令必须保证store buffer数据全部被清空的时候才继续往后面执行,这样就能保证其与SC模型的执行顺序一致。 +而对于RMO,在PSO的基础上又引入了load-load与load-store乱序。RMO的读内存屏障就要保证前面的load指令必须先于后面的load/store指令先执行,不允许将其访问提前执行。 + +我们继续看下面的例子: + +![](img/mesi3.jpg) + +例如C1执行S1与S2的时候,我们在S1与S2之间加上写屏障指令,要求C1按照顺序存储模型来进行store的执行,而在C2端的L1与L2之间加入读内存屏障,要求C2也按照顺序存储模型来进行load操作,这样就能够实现内存数据的一致性,从而解决乱序的问题。 + +#### barrier + +从上面来看,barrier 有四种: + +- LoadLoad 阻止不相关的 Load 操作发生重排 +- LoadStore 阻止 Store 被重排到 Load 之前 +- StoreLoad 阻止 Load 被重排到 Store 之前 +- StoreStore 阻止 Store 被重排到 Store 之前 + +#### sfence/lfence/mfence/lock + +Intel为此提供三种内存屏障指令: + +- sfence ,实现Store Barrior 会将store buffer中缓存的修改刷入L1 cache中,使得其他cpu核可以观察到这些修改,而且之后的写操作不会被调度到之前,即sfence之前的写操作一定在sfence完成且全局可见; +- lfence ,实现Load Barrior 会将invalidate queue失效,强制读取入L1 cache中,而且lfence之后的读操作不会被调度到之前,即lfence之前的读操作一定在lfence完成(并未规定全局可见性); +- mfence ,实现Full Barrior 同时刷新store buffer和invalidate queue,保证了mfence前后的读写操作的顺序,同时要求mfence之后写操作结果全局可见之前,mfence之前写操作结果全局可见; +- lock 用来修饰当前指令操作的内存只能由当前CPU使用,若指令不操作内存仍然由用,因为这个修饰会让指令操作本身原子化,而且自带Full Barrior效果;还有指令比如IO操作的指令、exch等原子交换的指令,任何带有lock前缀的指令以及CPUID等指令都有内存屏障的作用。 + +X86-64下仅支持一种指令重排:Store-Load ,即读操作可能会重排到写操作前面,同时不同线程的写操作并没有保证全局可见,要注意的是这个问题只能用mfence解决,不能靠组合sfence和lfence解决。 + +但是这不代表硬件实现上L/S FENCE是什么都不做. LFENCE永远会挡住时间较晚的Load不要提前完成. SFENCE挡住时间较早的Store不要滞后完成。 + +#### 其他乱序手段 + +但是,处理器领域其实还有很多的优化手段,流水线执行、乱序执行、预测执行等等,各种我们听过和没听过的优化,这个时候我们就应该使用内存屏障。 + +## 内存模型 与 Memory Consistency + +内存模型(Memory Model),它是系统和程序员之间的规范,它规定了存储器访问的行为,并影响到了性能。并且,Memory Model有多层,处理器规定、编译器规定、高级语。对于高级语言来说, 它通常需要支持跨平台,也就是说它会基于各种不同的内存模型,但是又要提供给程序员一个统一的内存模型,可以理解为一个适配器的角色。 + +### Acquire 与 Release语义 + +因为 store-load 可以被重排,所以x86不是顺序一致。但是因为其他三种读写顺序不能被重排,所以x86是 acquire/release 语义。 + +- 对于Acquire来说,保证Acquire后的读写操作不会发生在Acquire动作之前, 即 load-load, load-store 不能被重排 +- 对于Release来说,保证Release前的读写操作不会发生在Release动作之后, 即 load-store, store-store 不能被重排。 + +X86-64中Load读操作本身满足Acquire语义,Store写操作本身也是满足Release语义。但Store-Load操作间等于没有保护,因此仍需要靠 mfence 或 lock 等指令才可以满足到Synchronizes-with规则。 + +简单的说,就是 acquire 只禁止后面的代码不能够重排,但是 acquire 语句自己可以向前面走。release 语句只禁止前面的代码不能重排,因此 release 可以向后走。只有 mfence 才能阻止 acquire 与 release 语句的重排。 + +### synchronizes-with 与 happens-before + +``` +void write_x_then_y() +{ + x.store(true,std::memory_order_relaxed); + y.store(true,std::memory_order_release); +} +void read_y_then_x() +{ + while(!y.load(std::memory_order_acquire)); + if(x.load(std::memory_order_relaxed)){ + ++z; + } +} + +``` + +write_x_then_y()中的 y.store(true,std::memory_order_release);与read_y_then_x()的while(!y.load(std::memory_order_acquire));是一种synchronizes-with关系, + +y.store(true,std::memory_order_release);与x.load(std::memory_order_relaxed)是一种happens-before关系。 + +### C++ 六种 memory order + +对应于 CPU 的四种内存模型,语言层面 C++ 也定义了 6 中内存模型: + +#### Relaxed ordering + +Relaxed ordering: 在单个线程内,所有原子操作是顺序进行的。按照什么顺序?基本上就是代码顺序(sequenced-before)。这就是唯一的限制了!两个来自不同线程的原子操作是什么顺序?两个字:任意。 + +#### Release -- acquire + +Release -- acquire: 来自不同线程的两个原子操作顺序不一定?那怎么能限制一下它们的顺序?这就需要两个线程进行一下同步(synchronize-with)。同步什么呢?同步对一个变量的读写操作。线程 A 原子性地把值写入 x (release), 然后线程 B 原子性地读取 x 的值(acquire). 这样线程 B 保证读取到 x 的最新值。注意 release -- acquire 有个副作用:线程 A 中所有发生在 release x 之前的写操作,对在线程 B acquire x 之后的任何读操作都可见!本来 A, B 间读写操作顺序不定。这么一同步,在 x 这个点前后, A, B 线程之间有了个顺序关系,称作 inter-thread happens-before. + +``` +// write_x_then_y和read_y_then_x各自执行在一个线程中 +// x原子变量采用的是relaxed order, y原子变量采用的是acquire-release order +// 两个线程中的y原子存在synchronizes-with的关系,read_y_then_x的load与 +// write_x_then_y的y.store存在一种happens-before的关系 +// write_x_then_y的y.store执行后能保证read_y_then_x的x.load读到的x一定是true。 +// 虽然relaxed并不保证happens-before关系,但是在同一线程里,release会保证在其之前的原子 +// store操作都能被看见, acquire能保证通线程中的后续的load都能读到最新指。 +// 所以当y.load为true的时候,x肯定可以读到最新值。所以即使这里x用的是relaxed操作,所以其也能 +// 达到acquire-release的作用。 +// 具体为什么会这样,后续单独讲解 +void write_x_then_y() +{ + x.store(true,std::memory_order_relaxed); + y.store(true,std::memory_order_release); +} +void read_y_then_x() +{ + while(!y.load(std::memory_order_acquire)); + if(x.load(std::memory_order_relaxed)){ + ++z; + } +} + +``` + +#### Release -- consume + +Release -- consume: 我只想同步一个 x 的读写操作,结果把 release 之前的写操作都顺带同步了?如果我想避免这个额外开销怎么办?用 release -- consume 呗。同步还是一样的同步,这回副作用弱了点:在线程 B acquire x 之后的读操作中,有一些是依赖于 x 的值的读操作。管这些依赖于 x 的读操作叫 赖B读. 同理在线程 A 里面, release x 也有一些它所依赖的其他写操作,这些写操作自然发生在 release x 之前了。管这些写操作叫 赖A写. 现在这个副作用就是,只有 赖B读 能看见 赖A写. + +什么叫数据依赖(carries dependency) + +``` +S1. c = a + b; +S2. e = c + d; + +``` +S2 数据依赖于 S1,因为它需要 c 的值。 + +#### Sequential consistency + +Sequential consistency: 理解了前面的几个,顺序一致性就最好理解了。在前面 Release -- acquire 类似于 CPU 的 TSO 模型,它还是允许 Store-Load 这种重排的,但是对于顺序一致性模型,这种重排也是不允许的: + +``` +x = 0,y = 0; + +void write_x_then_read_y() +{ + x.store(true, std::memory_order_relaxed); + + if (! y.load(std::memory_order_acquire)) { + ++z; + }; +} + +void write_y_then_read_x() +{ + y.store(true, std::memory_order_release); + + if (! x.load(std::memory_order_relaxed)){ + ++z; + } +} +``` +这种代码默认了两个函数只能自增一次 z,不会同时进入 if 条件。但是对于 Store-Load 这种,很可能 store 操作被重排到 load 之后,那么临界区的 if 条件就会失效。 + +还有一种极端情况如下,它可以保证 x 与 y 在所有的线程中,显现的顺序是一致的。也就是说 x 与 y 一定是 (0,0)、(1,0)、(0,1)的一种,不可能在线程1中是 (1,0),而在线程2中是 (0,1), 其他的内存模型无法保证。 + +ps: Release -- acquire 的定义的确是不会保证,但是这个也不涉及 Store-load 重排。猜测可能与具体的 Release -- acquire 实现有关,不同的变量 x 与 y 同时被更新,即使没有发生 load-load 的指令重排,传递到各个 CPU 的顺序也会不同。 + +``` +std::atomic x = {false}; +std::atomic y = {false}; +std::atomic z = {0}; + +void write_x() +{ + x.store(true, std::memory_order_seq_cst); +} + +void write_y() +{ + y.store(true, std::memory_order_seq_cst); +} + +void read_x_then_y() +{ + while (!x.load(std::memory_order_seq_cst)) + ; + if (y.load(std::memory_order_seq_cst)) { + ++z; + } +} + +void read_y_then_x() +{ + while (!y.load(std::memory_order_seq_cst)) + ; + if (x.load(std::memory_order_seq_cst)) { + ++z; + } +} + +int main() +{ + std::thread a(write_x); + std::thread b(write_y); + std::thread c(read_x_then_y); + std::thread d(read_y_then_x); + a.join(); b.join(); c.join(); d.join(); + assert(z.load() != 0); // will never happen +} + +``` + +### Volatile 关键字 + +#### 易变性 + +- 非Volatile变量 + +![](img/volatile.png) + +b = a + 1;这条语句,对应的汇编指令是:lea ecx, [eax + 1]。由于变量a,在前一条语句a = fn(c)执行时,被缓存在了寄存器eax中,因此b = a + 1;语句,可以直接使用仍旧在寄存器eax中的a,来进行计算,对应的也就是汇编:[eax + 1]。 + +- Volatile变量 + +![](img/volatile1.png) + +与测试用例一唯一的不同之处,是变量a被设置为volatile属性,一个小小的变化,带来的是汇编代码上很大的变化。a = fn(c)执行后,寄存器ecx中的a,被写回内存:mov dword ptr [esp+0Ch], ecx。然后,在执行b = a + 1;语句时,变量a有重新被从内存中读取出来:mov eax, dword ptr [esp + 0Ch],而不再直接使用寄存器ecx中的内容。 + +#### 不可优化性 + +![](img/volatile3.png) + +在这个用例中,非volatile变量a,b,c全部被编译器优化掉了 (optimize out),因为编译器通过分析,发觉a,b,c三个变量是无用的,可以进行常量替换。最后的汇编代码相当简介,高效率。 + +![](img/volatile4.png) + +测试用例四,与测试用例三类似,不同之处在于,a,b,c三个变量,都是volatile变量。这个区别,反映到汇编语言中,就是三个变量仍旧存在,需要将三个变量从内存读入到寄存器之中,然后再调用printf()函数。 + +#### 编译顺序性 + +个线程(Thread1)在完成一些操作后,会修改这个变量。而另外一个线程(Thread2),则不断读取这个flag变量,由于flag变量被声明了volatile属性,因此编译器在编译时,并不会每次都从寄存器中读取此变量,同时也不会通过各种激进的优化,直接将if (flag == true)改写为if (false == true)。只要flag变量在Thread1中被修改,Thread2中就会读取到这个变化,进入if条件判断,然后进入if内部进行处理。在if条件的内部,由于flag == true,那么假设Thread1中的something操作一定已经完成了,在基于这个假设的基础上,继续进行下面的other things操作。 + +通过将flag变量声明为volatile属性,很好的利用了本文前面提到的C/C++ Volatile的两个特性:”易变”性;”不可优化”性。按理说,这是一个对于volatile关键词的很好应用,而且看到这里的朋友,也可以去检查检查自己的代码,我相信肯定会有这样的使用存在。 + +但是,这个多线程下看似对于C/C++ Volatile关键词完美的应用,实际上却是有大问题的。问题的关键,就在于前面标红的文字: + +由于flag = true,那么假设Thread1中的something操作一定已经完成了。flag == true,为什么能够推断出Thread1中的something一定完成了?其实既然我把这作为一个错误的用例,答案是一目了然的:这个推断不能成立,你不能假设看到flag == true后,flag = true;这条语句前面的something一定已经执行完成了。这就引出了C/C++ Volatile关键词的第三个特性:顺序性。 + +简单的说,Volatile 并不具备内存屏障的作用,没有 happens-before 的语义。 + +![](img/volatile5.png) + +虽然 Volatile 并不具备内存屏障的作用,但是它的确在编译期阻止了 Volatile 变量与 Volatile 的编译乱序,具体到 CPU 的内存模型上,是否还会乱序那就不是 Volatile 关键字可以控制的了。 + +### Volatile:Java增强 + +与C/C++的Volatile关键词类似,Java的Volatile也有这三个特性,但最大的不同在于:第三个特性,”顺序性”,Java的Volatile有很极大的增强,Java Volatile变量的操作,附带了Acquire与Release语义。 + +- 对于Java Volatile变量的写操作,带有Release语义,所有Volatile变量写操作之前的针对其他任何变量的读写操作,都不会被编译器、CPU优化后,乱序到Volatile变量的写操作之后执行。 + +- 对于Java Volatile变量的读操作,带有Acquire语义,所有Volatile变量读操作之后的针对其他任何变量的读写操作,都不会被编译器、CPU优化后,乱序到Volatile变量的读操作之前进行。 + +## Golang 内存模型 + +### 同步 + +#### 初始化 + +如果在一个goroutine所在的源码包p里面通过import命令导入了包q,那么q包里面go文件的初始化方法的执行会happens before 于包p里面的初始化方法执行 + +#### 创建goroutine + +go语句启动一个新的goroutine的动作 happen before 该新goroutine的运行 + +``` +package main + +import ( + "fmt" + "sync" +) + +var a string +var wg sync.WaitGroup + +func f() { + fmt.Print(a) + wg.Done() +} + +func hello() { + a = "hello, world" + go f() +} +func main() { + wg.Add(1) + + hello() + wg.Wait() + +} +``` + +如上代码调用hello方法后肯定会输出"hello,world",可能等hello方法执行完毕后才输出(由于调度的原因)。 + +#### 销毁goroutine + +一个goroutine的销毁操作并不能确保 happen before 程序中的任何事件,比如下面例子 + +``` +var a string + +func hello() { + go func() { a = "hello" }() + print(a) +} + +``` + +如上代码 goroutine内对变量a的赋值并没有加任何同步措施,所以并能不保证hello函数所在的goroutine对变量a的赋值可见。如果要确保一个goroutine对变量的修改对其他goroutine可见,必须使用一定的同步机制,比如锁、通道来建立对同一个变量读写的偏序关系。 + +#### 有缓冲 channel + +在有缓冲的通道时候向通道写入一个数据总是 happen before 这个数据被从通道中读取完成,如下例子: + +``` +package main + +import ( + "fmt" +) + +var c = make(chan int, 10) +var a string + +func f() { + a = "hello, world" //1 + c <- 0 //2 +} + +func main() { + go f() //3 + <-c //4 + fmt.Print(a) //5 +} + +``` + +如上代码运行后可以确保输出"hello, world",这里对变量a的写操作(1) happen before 向通道写入数据的操作(2),而向通道写入数据的操作(2)happen before 从通道读取数据完成的操作(4),而步骤(4)happen before 步骤(5)的打印输出。 + +另外关闭通道的操作 happen before 从通道接受0值(关闭通道后会向通道发送一个0值),修改上面代码(2)如下: + +``` +package main + +import ( + "fmt" +) + +var c = make(chan int, 10) +var a string + +func f() { + a = "hello, world" //1 + close(c) //2 +} + +func main() { + go f() //3 + <-c //4 + fmt.Print(a) //5 +} +``` +在有缓冲通道中通过向通道写入一个数据总是 happen before 这个数据被从通道中读取完成,这个happen before规则使多个goroutine中对共享变量的并发访问变成了可预见的串行化操作。 + +#### 无缓冲 channel + +对应无缓冲的通道来说从通道接受(获取叫做读取)元素 happen before 向通道发送(写入)数据完成,看下下面代码: + +``` +package main + +import ( + "fmt" +) + +var c = make(chan int) +var a string + +func f() { + a = "hello, world" //1 + <-c //2 +} + +func main() { + go f() //3 + c <- 0 //4 + fmt.Print(a) //5 +} + +``` + +如上代码运行也可保证输出"hello, world",注意改程序相比上一个片段,通道改为了无缓冲,并向通道发送数据与读取数据的步骤(2)(4)调换了位置。 + +在这里写入变量a的操作(1)happen before 从通道读取数据完毕的操作(2),而从通道读取数据的操作 happen before 向通道写入数据完毕的操作(4),而步骤(4) happen before 打印输出步骤(5)。 + +注:在无缓冲通道中从通道读取数据的操作 happen before 向通道写入数据完毕的操作,这个happen before规则使多个goroutine中对共享变量的并发访问变成了可预见的串行化操作。 + +如上代码如果换成有缓冲的通道,比如c = make(chan int, 1)则就不能保证一定会输出"hello, world"。 + +#### channel 规则抽象 + +从容量为C的通道接受第K个元素 happen before 向通道第k+C次写入完成,比如从容量为1的通道接受第3个元素 happen before 向通道第3+1次写入完成。 + +这个规则对有缓冲通道和无缓冲通道的情况都适用,有缓冲的通道可以实现信号量计数的功能,比如通道的容量可以认为是最大信号量的个数,通道内当前元素个数可以认为是剩余的信号量个数,向通道写入(发送)一个元素可以认为是获取一个信号量,从通道读取(接受)一个元素可以认为是释放一个信号量,所以有缓冲的通道可以作为限制并发数的一个通用手段: + +``` +package main + +import ( + "fmt" + "time" +) + +var limit = make(chan int, 3) + +func sayHello(index int){ + fmt.Println(index ) +} + +var work []func(int) +func main() { + + work := append(work,sayHello,sayHello,sayHello,sayHello,sayHello,sayHello) + + for i, w := range work { + go func(w func(int),index int) { + limit <- 1 + w(index) + <-limit + }(w,i) + } + + time.Sleep(time.Second * 10) +} + +``` + +如上代码main goroutine里面为work列表里面的每个方法的执行开启了一个单独的goroutine,这里有6个方法,正常情况下这7个goroutine可以并发运行,但是本程序使用缓存大小为3的通道来做并发控制,导致同时只有3个goroutine可以并发运行。 + +### 锁(locks) + +sync包实现了两个锁类型,分别为 sync.Mutex(互斥锁)和 sync.RWMutex(读写锁)。 + +对应任何sync.Mutex or sync.RWMutex类型的遍历I来说调用n次 l.Unlock() 操作 happen before 调用m次l.Lock()操作返回,其中n 按照其用途,span 面向内部管理,object 面向对象分配。 +> + +## arenas + +![](img/arenas.png) + +在旧的Go版本中,Go程序是采用预先保留连续的虚拟地址的方案,在64位的系统上,会预先保留512G的虚拟内存空间,但是不可增长,而当前版本中,虚拟内存的地址长度被设置为了48位,理论上可以支持2^48字节的内存使用。 + +在go1.12源码里, mheap_.arenas是一个二维数组[L1][L2]heapArena(heapArena存的是对应arena的元数据),维度以及arena本身的大小和寻址bit位数相关,每个arena的起始地址按对应大小对齐。申请的内存空间会放在一个heapArena数组里,用于应用程序内存分配。 + +Go的虚拟内存是由一组arena组成的,这一组arena组成我们所说的堆(mheap.arenas)。初始情况下mheap会映射一个arena,也就是64MB(不同系统的arena大小不同)。所以我们的程序的当前内存会按照需要小增量进行映射,并且每次以一个arena为单位。也就是说 heap 裡有許多個 arena,數量會隨著需求而慢慢增加或減少。 + +#### spans + +spans 数组是一个用于将内存地址映射成 MSpan 结构体的表,每个内存页都会对应到 spans 中的一个 MSpan 指针,通过 spans 就能够将地址映射到相应的 MSpan。具体做法,给定一个地址,可以通过(地址-基地址)/页大小得到页号,再通过spans[页号] 就得到了相应的 MSpan 结构体。前面说过,MSpan 就是若干连续的页。那么,一个多页的 MSpan 会占用 spans 数组中的多项,有多少页就会占用多少项。比如,可能 spans[502] 到 spans[505] 都指向同一个 MSpan,这个 MSpan 的 PageId 为502,npages 为4。 + +每个指针对应一页,所以spans区域的大小就是512GB/8KB*8B=512MB。除以8KB是计算arena区域的页数,而最后乘以8是计算spans区域所有指针的大小。 + +回收过程: 回收一个 MSpan 时,首先会查找它相邻的页的址址,再通过 spans 映射得到该页对应的 MSpan,如果 MSpan 的 state 是未使用,则可以将两者进行合并。最后会将这页或者合并后的页归还到 free[] 分配池或者是 large 中。 + +![](img/arenas4.jpg) + +#### bitmap + +bitmap区域标识arena区域哪些地址保存了对象,并且用4bit标志位表示对象是否包含指针、GC标记信息。bitmap中一个byte大小的内存对应arena区域中4个指针大小(指针大小为 8B )的内存,所以bitmap区域的大小是512GB/(4*8B)=16GB。 + +![](img/arenas2.jpg) + +![](img/arenas3.jpg) + +从上图其实还可以看到bitmap的高地址部分指向arena区域的低地址部分,也就是说bitmap的地址是由高地址向低地址增长的。 + +## MSpan + +### Size Class + +Go中内存管理的基本单元,是由一片连续的8KB的页组成的大块内存。注意,这里的页和操作系统本身的页并不是一回事,它一般是操作系统页大小的几倍。 + +每个mspan按照它自身的属性Size Class的大小分割成若干个object,属性Size Class决定object大小,其实也间接决定了 mspan 所占用的 page 页数,mspan 的规格大小。 + +mspan 只会分配给和 object 尺寸大小接近的对象,当然,对象的大小要小于 object 大小。还有一个概念:Span Class,它和Size Class的含义差不多 + +``` +Size_Class = Span_Class / 2 + +``` + +这是因为其实每个 Size Class有两个mspan,也就是有两个Span Class。其中一个分配给含有指针的对象,另一个分配给不含有指针的对象。这会给垃圾回收机制带来利好。 + +![](img/mspan.jpg) + +Go1.9.2里 mspan 的Size Class共有67种,每种mspan分割的object大小是 8*2 的倍数,这个是写死在代码里的: + +``` +const _NumSizeClasses = 67 + +var class_to_size = [_NumSizeClasses]uint16{0, 8, 16, 32, 48, 64, 80, 96, 112, 128, 144, 160, 176, 192, 208, 224, 240, 256, 288, 320, 352, 384, 416, 448, 480, 512, 576, 640, 704, 768, 896, 1024, 1152, 1280, 1408, 1536,1792, 2048, 2304, 2688, 3072, 3200, 3456, 4096, 4864, 5376, 6144, 6528, 6784, 6912, 8192, 9472, 9728, 10240, 10880, 12288, 13568, 14336, 16384, 18432, 19072, 20480, 21760, 24576, 27264, 28672, 32768} + +``` +根据mspan的Size Class可以得到它划分的object大小。 比如Size Class等于3,object大小就是32B。 32B大小的object可以存储对象大小范围在17B~32B的对象。而对于微小对象(小于16B),分配器会将其进行合并,将几个对象分配到同一个object中。 + +数组里最大的数是32768,也就是32KB,超过此大小就是大对象了,它会被特别对待,这个稍后会再介绍。顺便提一句,类型Size Class为0表示大对象,它实际上直接由堆内存分配,而小对象都要通过mspan来分配。 + +对于mspan来说,它的Size Class会决定它所能分到的页数,这也是写死在代码里的: + +``` +const _NumSizeClasses = 67 + +var class_to_allocnpages = [_NumSizeClasses]uint8{0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 2, 1, 2, 1, 2, 1, 3, 2, 3, 1, 3, 2, 3, 4, 5, 6, 1, 7, 6, 5, 4, 3, 5, 7, 2, 9, 7, 5, 8, 3, 10, 7, 4} + +``` + +比如当我们要申请一个object大小为32B的mspan的时候,在class_to_size里对应的索引是3,而索引3在class_to_allocnpages数组里对应的页数就是1。 + + +### mspan 数据结构 + +``` +type mspan struct { + // 該 mspan 的 span class + spanclass spanClass // size class and noscan (uint8) + + next *mspan // next span in list, or nil if none + prev *mspan // previous span in list, or nil if none + + startAddr uintptr // 該 mspan 第一個 byte 的起始位址,即是在 arena 的起始位址 + npages uintptr // 該 mspan 擁有多少 page + + // freeindex 用來指出該 mspan 要從何處開始掃描找出下一個可用的 object,其 index 為 0 至 nelems + // 每次 allocation 時,會從 freeindex 開始掃描,遇到 0 代表該 object 是可用的 + // freeindex 會被調整到該位置,這樣下次掃描便會從此次分配的 object 的後面開始掃描 + // 若 freeindex 等於 nelem,則代表該 mspan 已經沒有可用的 objects 了 + // freeindex 前的 object 都是已分配的,freeindex 之後的 object 可能分配了,也可能還未分配 + freeindex uintptr + nelems uintptr // 該 mspan 擁有多少 object + + //分配位图,每一位代表一个块是否已分配 + allocBits *gcBits + // 已分配块的个数 + allocCount uint16 + + + // sweep generation: + // if sweepgen == h->sweepgen - 2, the span needs sweeping + // if sweepgen == h->sweepgen - 1, the span is currently being swept + // if sweepgen == h->sweepgen, the span is swept and ready to use + // if sweepgen == h->sweepgen + 1, the span was cached before sweep began and is still cached, and needs sweeping + // if sweepgen == h->sweepgen + 3, the span was swept and then cached and is still cached + // h->sweepgen is incremented by 2 after every GC + sweepgen uint32 + + elemsize uintptr // computed from sizeclass or from npages +} + +``` + +当span内的所有内存块都被占用时,没有剩余空间继续分配对象,mcache会向mcentral申请1个span,mcache拿到span后继续分配对象。 + +![](img/span1.jpg) + +假设最左边第一个mspan的Size Class等于10,根据前面的class_to_size数组,得出这个msapn分割的object大小是144B,算出可分配的对象个数是8KB/144B=56.89个,取整56个,所以会有一些内存浪费掉了,Go的源码里有所有Size Class的mspan浪费的内存的大小;再根据class_to_allocnpages数组,得到这个mspan只由1个page组成;假设这个mspan是分配给无指针对象的,那么spanClass等于20。 + + + +### mspan 方法 + +从 mspan 中获取下一个空闲对象方法: + +``` +func (c *mcache) nextFree(spc spanClass) (v gclinkptr, s *mspan, shouldhelpgc bool) { + s = c.alloc[spc] + shouldhelpgc = false + freeIndex := s.nextFreeIndex() + if freeIndex == s.nelems { + // The span is full. + if uintptr(s.allocCount) != s.nelems { + println("runtime: s.allocCount=", s.allocCount, "s.nelems=", s.nelems) + throw("s.allocCount != s.nelems && freeIndex == s.nelems") + } + c.refill(spc) + shouldhelpgc = true + s = c.alloc[spc] + + freeIndex = s.nextFreeIndex() + } + + if freeIndex >= s.nelems { + throw("freeIndex is not valid") + } + + v = gclinkptr(freeIndex*s.elemsize + s.base()) + s.allocCount++ + if uintptr(s.allocCount) > s.nelems { + println("s.allocCount=", s.allocCount, "s.nelems=", s.nelems) + throw("s.allocCount > s.nelems") + } + return +} + +func (s *mspan) base() uintptr { + return s.startAddr +} + +``` + +## MHeap + +MHeap层次用于直接分配较大(>32kB)的内存空间,以及给MCentral和MCache等下层提供空间。它管理的基本单位是 MSpan。 + +``` +type mheap struct { + free mTreap // free spans + freelarge mTreap // free treap of length >= _MaxMHeapList + + arenas [1 << arenaL1Bits]*[1 << arenaL2Bits]*heapArena + + central [numSpanClasses]struct { + mcentral mcentral + pad [sys.CacheLineSize - unsafe.Sizeof(mcentral{})%sys.CacheLineSize]byte + } + + spanalloc fixalloc // allocator for span* + cachealloc fixalloc // allocator for mcache* + treapalloc fixalloc // allocator for treapNodes* + arenaHintAlloc fixalloc // allocator for arenaHints + ... +} + +type heapArena struct { + bitmap [heapArenaBitmapBytes]byte + + spans [pagesPerArena]*mspan +} + +type mSpanList struct { + first *mspan // first span in list, or nil if none + last *mspan // last span in list, or nil if none +} +``` + +- `free [_MaxMHeapList]mSpanList`: 这是一个 SpanList 数组,每个 SpanList 里面的 mspan 由 1 ~ 127 (_MaxMHeapList - 1) 个 page 组成。比如 free[3] 是由包含 3 个 page 的 mspan 组成的链表。free 表示的是 free list,也就是未分配的。对应的还有 busy list。 +- freelarge mSpanList: mspan 组成的链表,每个元素(也就是 mspan)的 page 个数大于 127。对应的还有 busylarge。 +- central [_NumSizeClasses]…: 这个就是 mcentral ,每种大小的块对应一个 mcentral。pad 可以认为是一个字节填充,为了避免伪共享(false sharing)问题的。 +- arenas: arena 是 Golang 中用于分配内存的连续虚拟地址区域。由 mheap 管理,堆上申请的所有内存都来自 arena。那么如何标志内存可用呢?操作系统的常见做法用两种:一种是用链表将所有的可用内存都串起来;另一种是使用位图来标志内存块是否可用。spans 记录 arena 区域页号(page number)和 mspan 的映射关系。 +- 下面几个 fixalloc 分别是各种固定数据结构的分配器,go 语言会事先创建一个链表,保存对应的数据结构,例如 mspan、treap 等等数据结构,等到 go runtime 需要创建对应对象的时候,可以立刻返回对象。 + +mheap中含有所有规格的mcentral,所以,当一个mcache从mcentral申请mspan时,只需要在独立的mcentral中使用锁,并不会影响申请其他规格的mspan。 + +分配过程: 如果能从free[]的分配池中分配,则从其中分配。如果发生切割则将剩余部分放回free[]中。比如要分配2页大小的 空间,从图上2号槽位开始寻找,直到4号槽位有可用的MSpan,则拿一个出来,切出两页,剩余的部分再放回2号槽位中。 否则从large链表中去分配,按BestFit算法去找一块可用空间。 + +![](img/mheap.jpg) + +### treap 结构 + +treap 本身是一棵二叉搜索树,用来快速查找含有 npages 的 mspan 对象,但在其中一般会有一个额外字段来保证二叉搜索树的结构同时满足小顶堆的性质。treap 是利用随机 priority 来解决二叉搜索树不平衡的问题,同时也为了解决 AVL 树过于复杂的问题,类似的结构还有跳表。 + +``` +//go:notinheap +type mTreap struct { + treap *treapNode + unscavHugePages uintptr // number of unscavenged huge pages in the treap +} + +//go:notinheap +type treapNode struct { + right *treapNode // all treapNodes > this treap node + left *treapNode // all treapNodes < this treap node + parent *treapNode // direct parent of this node, nil if root + key uintptr // base address of the span, used as primary sort key + span *mspan // span at base address key + maxPages uintptr // the maximum size of any span in this subtree, including the root + priority uint32 // random number used by treap algorithm to keep tree probabilistically balanced + types treapIterFilter // the types of spans available in this subtree +} + +``` + +几个字段的含义都比较简单: + +- right/left/parent 表示当前节点和树中其它节点的关系 +- npagesKey 表示该 mspan 中含有的内存页数,作为二叉搜索树的排序依据 +- spanKey 是 mspan 指针,其地址的值作为二叉搜索树的第二个排序依据,即页数相同的情况下,spanKey 大的会在当前节点的右边 +- priority 是随机生成的权重值,该权重值会被作为小顶堆的排序依据 + +早期的 mheap 的 free 属性是链表构成的,freelarge 才是用 treap,在 go 1.12 版本 free 转化为 treap 来存储,去掉了 freelarge。 + +## MCentral + +MCentral 层次是作为MCache和MHeap的连接。对上,它从MHeap中申请MSpan;对下,它将MSpan划分成各种小尺寸对 象,提供给MCache使用。 + +每个central保存一种特定大小的全局mspan列表,包括已分配出去的和未分配出去的。 每个mcentral对应一种mspan,而mspan的种类导致它分割的object大小不同。当工作线程的mcache中没有合适(也就是特定大小的)的mspan时就会从mcentral获取。 + +mcentral被所有的工作线程共同享有,存在多个Goroutine竞争的情况,因此会消耗锁资源。结构体定义: + +``` +type mcentral struct { + lock mutex + spanclass spanClass + nonempty mSpanList // list of spans with a free object, ie a nonempty free list + empty mSpanList // list of spans with no free objects (or cached in an mcache) + + nmalloc uint64 +} + +``` + +nmalloc 代表着 mcentral 中所有的 mspan 中对象的个数。 + +注意,每个MSpan只会分割成同种大小的对象。每个MCentral也是只含同种大小的对象。MCentral结构中,有一个 nonempty的MSpan链和一个empty的MSpan链,分别表示还有空间的MSpan和装满了对象的MSpan。 + +回收比分配复杂,因为涉及到合并。这里的合并是通过引用计数实现的。从MSpan中每划出 一个对象,则引用计数加一,每回收一个对象,则引用计数减一。如果减完之后引用计数为零了,则说明这整块的MSpan已 经没被使用了,可以将它归还给MHeap。 + +## MCache + +MCache层次跟MHeap层次非常像,也是一个分配池,对每个尺寸的类别都有一个空闲对象的单链表。 + +mcache与TCMalloc中的ThreadCache类似,mcache保存的是各种大小的Span,并按Span class分类,小对象直接从mcache分配内存,它起到了缓存的作用,并且可以无锁访问。但是mcache与ThreadCache也有不同点,TCMalloc中是每个线程1个ThreadCache,Go中是每个P拥有1个mcache。因为在Go程序中,当前最多有GOMAXPROCS个线程在运行,所以最多需要GOMAXPROCS个mcache就可以保证各线程对mcache的无锁访问,线程的运行又是与P绑定的,把mcache交给P刚刚好。 + +``` +type mcache struct { + next_sample uintptr // trigger heap sample after allocating this many bytes + local_scan uintptr // bytes of scannable heap allocated + + + tiny uintptr + tinyoffset uintptr + local_tinyallocs uintptr // number of tiny allocs not counted in other stats + + // The rest is not accessed on every malloc. + + alloc [numSpanClasses]*mspan // spans to allocate from, indexed by spanClass + + flushGen uint32 +} + +``` + +mcache用Span Classes作为索引管理多个用于分配的mspan,它包含所有规格的mspan。它是_NumSizeClasses的2倍,也就是67*2=134,为什么有一个两倍的关系,前面我们提到过:为了加速之后内存回收的速度,数组里一半的mspan中分配的对象不包含指针,另一半则包含指针。对于无指针对象的mspan在进行垃圾回收的时候无需进一步扫描它是否引用了其他活跃的对象。 + +tiny 是用于 tiny 对象的分配,tiny 指针指向当前 cache 中正在负责分配 tiny 对象的 mspan 首地址,tinyoffset 是当前在 mspan 中已占用的大小,local_tinyallocs 是已分配的 tiny 对象个数。 + +## 平台相关函数 + +### sysAlloc + +sysAlloc 从操作系统获取一大块已清零的内存,一般是 100 KB 或 1MB + +sysAlloc 返回 OS 对齐的内存,但是对于堆分配器来说可能需要以更大的单位进行对齐。 +因此 caller 需要小心地将 sysAlloc 获取到的内存重新进行对齐。 + +``` +func sysAlloc(n uintptr, sysStat *uint64) unsafe.Pointer { + p, err := mmap(nil, n, _PROT_READ|_PROT_WRITE, _MAP_ANON|_MAP_PRIVATE, -1, 0) + if err != 0 { + if err == _EACCES { + print("runtime: mmap: access denied\n") + exit(2) + } + if err == _EAGAIN { + print("runtime: mmap: too much locked memory (check 'ulimit -l').\n") + exit(2) + } + return nil + } + mSysStatInc(sysStat, n) + return p +} + +``` + +### sysUnused + +sysUnused 通知操作系统内存区域的内容已经没用了,可以移作它用。 + +``` +func sysUnused(v unsafe.Pointer, n uintptr) { + if physHugePageSize != 0 { + // If it's a large allocation, we want to leave huge + // pages enabled. Hence, we only adjust the huge page + // flag on the huge pages containing v and v+n-1, and + // only if those aren't aligned. + var head, tail uintptr + if uintptr(v)&(physHugePageSize-1) != 0 { + // Compute huge page containing v. + head = uintptr(v) &^ (physHugePageSize - 1) + } + if (uintptr(v)+n)&(physHugePageSize-1) != 0 { + // Compute huge page containing v+n-1. + tail = (uintptr(v) + n - 1) &^ (physHugePageSize - 1) + } + + // Note that madvise will return EINVAL if the flag is + // already set, which is quite likely. We ignore + // errors. + if head != 0 && head+physHugePageSize == tail { + // head and tail are different but adjacent, + // so do this in one call. + madvise(unsafe.Pointer(head), 2*physHugePageSize, _MADV_NOHUGEPAGE) + } else { + // Advise the huge pages containing v and v+n-1. + if head != 0 { + madvise(unsafe.Pointer(head), physHugePageSize, _MADV_NOHUGEPAGE) + } + if tail != 0 && tail != head { + madvise(unsafe.Pointer(tail), physHugePageSize, _MADV_NOHUGEPAGE) + } + } + } + + if uintptr(v)&(physPageSize-1) != 0 || n&(physPageSize-1) != 0 { + // madvise will round this to any physical page + // *covered* by this range, so an unaligned madvise + // will release more memory than intended. + throw("unaligned sysUnused") + } + + var advise uint32 + if debug.madvdontneed != 0 { + advise = _MADV_DONTNEED + } else { + advise = atomic.Load(&adviseUnused) + } + if errno := madvise(v, n, int32(advise)); advise == _MADV_FREE && errno != 0 { + // MADV_FREE was added in Linux 4.5. Fall back to MADV_DONTNEED if it is + // not supported. + atomic.Store(&adviseUnused, _MADV_DONTNEED) + madvise(v, n, _MADV_DONTNEED) + } +} +``` + +#### 透明大页 + +该函数用大部分是解决 linux 透明分页的 bug。 + +>透明大页: +> +HugePages是通过使用大页内存来取代传统的4kb内存页面,使得管理虚拟地址数变少,加快了从虚拟地址到物理地址的映射以及通过摒弃内存页面的换入换出以提高内存的整体性能。 +> +hugepage 优点是减轻TLB的压力,也就是降低了cpu cache可缓存的地址映射压力。由于使用了huge page,相同的内存大小情况下,管理的虚拟地址数量变少。TLB entry可以包含更多的地址空间,cpu的寻址能力相应的得到了增强。 +> +除此之外还可以降低page table负载,消除page table查找负载,减少缺页异常从而提高内存的整体性能。 + +Linux 的透明大页支持会将 pages 合并到大页,常规的页分配的物理内存可以通过 khugepaged 进程自动迁往透明大页内存。内核进程 khugepaged的作用是,扫描正在运行的进程,然后试图将使用的常规内存页转换到使用大页。在某些情况下,系统范围使用大页面,会导致应用分配更多的内存资源。例如应用程序MMAP了一大块内存,但是只涉及1个字节,在这种情况下,2M页面代替4K页的分配没有任何好处,这就是为什么禁用全系统大页面,而只针对MADV_HUGEPAGE区使用的原因。在 amd64 平台上,khugepaged 会将一个 4KB 的单页变成 2MB,从而将进程的 RSS 爆炸式地增长 512 倍。 + +在我们的这个函数中也会出现类似的现象,透明大页会把我们 DONTNEED 的效果也消除掉,内存即使被回收,也会因为透明大页的原因,被回收的区域重新被附近的透明大页填充,导致内存回涨。 + +所以为了规避这个问题,我们在释放堆上页时,会显式地禁用透明大页。 + +但是禁用透明大页之后又会造成另一个问题,linux 会将禁用透明大页的区域划分为一个单独的 VMA,假设我们有一个区域 100M,本来是一个整体 VMA 区域,这时候我们禁用其中 40k 透明大页,那么 VMA 区域会被分割为 3 部分。如果禁用的区域过多,很快就会达到 linux 的 VMAS 的上线 65530。 + +因此 go 进行了优化,每次禁用透明大页不会仅仅禁用 40k,而是直接禁用一整块透明大页 2M,这样就会大大减少了 VMA 碎片。 + +#### MADV_FREE 与 MADV_DONTNEED + +如果应用程序对一块内存后续不想使用,它可以使用MADV_DONTNEED标志告知内核,后续内核可以释放与之关联的资源。同时发生后续访问,也会成功,但会导致从底层映射文件重新加载内存内容,或者在没有底层文件的情况下从零填充页面进行映射。但是有一些应用程序(特别是内存分配器)会在短时间重用该内存范围,并且MADV_DONTNEED强制它们引发缺页中断,页面分配,页面清零等。 + +为了避免这种开销,其他操作系统如BSD支持MADV_FREE,它只是在需要时将页面标记为可用,并不会立即释放它们,从而可以重用内存而不会再次产生缺页错误的成本。此版本增加了对此标志的支持。 + +### sysUsed + +sysUsed 通知操作系统内存区域的内容又需要用了。 + +``` +func sysUsed(v unsafe.Pointer, n uintptr) { + sysHugePage(v, n) +} + +func sysHugePage(v unsafe.Pointer, n uintptr) { + if physHugePageSize != 0 { + // Round v up to a huge page boundary. + beg := (uintptr(v) + (physHugePageSize - 1)) &^ (physHugePageSize - 1) + // Round v+n down to a huge page boundary. + end := (uintptr(v) + n) &^ (physHugePageSize - 1) + + if beg < end { + madvise(unsafe.Pointer(beg), end-beg, _MADV_HUGEPAGE) + } + } +} + +``` + +### sysFree + +sysFree 无条件返回内存;只有当分配内存途中发生了 out-of-memory 错误时才会使用。 + +``` +func sysFree(v unsafe.Pointer, n uintptr, sysStat *uint64) { + mSysStatDec(sysStat, n) + munmap(v, n) +} + +``` + +### sysReserve + +sysReserve 会在不分配内存的情况下,保留一段地址空间。如果传给它的指针是非 nil,意思是 caller 想保留这段地址,但这种情况下,如果该段地址不可用时,sysReserve 依然可以选择另外的地址。在一些操作系统的某些 case 下,sysReserve 化合简单地检查这段地址空间是否可用同时并不会真地保留它。sysReserve 返回非空指针时,如果地址空间保留成功了会将 *reserved 设置为 true,只是检查而未保留的话会设置为 false。 + +NOTE: sysReserve 返回 系统对齐的内存,没有按堆分配器的更大对齐单位进行对齐,所以 caller 需要将通过 sysAlloc 获取到的内存进行重对齐。 + +``` +func sysReserve(v unsafe.Pointer, n uintptr) unsafe.Pointer { + p, err := mmap(v, n, _PROT_NONE, _MAP_ANON|_MAP_PRIVATE, -1, 0) + if err != 0 { + return nil + } + return p +} + +``` + +### sysMap + +sysMap 将之前保留的地址空间映射好以进行使用。 + +``` +func sysMap(v unsafe.Pointer, n uintptr, sysStat *uint64) { + mSysStatInc(sysStat, n) + + p, err := mmap(v, n, _PROT_READ|_PROT_WRITE, _MAP_ANON|_MAP_FIXED|_MAP_PRIVATE, -1, 0) + if err == _ENOMEM { + throw("runtime: out of memory") + } + if p != v || err != 0 { + throw("runtime: cannot map pages in arena address space") + } +} + +``` + +## 内存分配器初始化 + +### schedinit + +系统启动的流程为: + +- call osinit +- call schedinit +- make & queue new G +- call runtime·mstart + +其中内存的初始化就在 schedinit 中: + +``` +func schedinit() { + ... + + mallocinit() + mcommoninit(_g_.m) + + ... + + procs := ncpu + if n, ok := atoi32(gogetenv("GOMAXPROCS")); ok && n > 0 { + procs = n + } + if procresize(procs) != nil { + throw("unknown runnable goroutine during bootstrap") + } + + ... +} + +``` + + +### mallocinit + +``` +func mallocinit() { + ... + + // Initialize the heap. + mheap_.init() + _g_ := getg() + _g_.m.mcache = allocmcache() + + // Create initial arena growth hints. + if sys.PtrSize == 8 { + + for i := 0x7f; i >= 0; i-- { + var p uintptr + switch { + case GOARCH == "arm64" && GOOS == "darwin": + p = uintptr(i)<<40 | uintptrMask&(0x0013<<28) + case GOARCH == "arm64": + p = uintptr(i)<<40 | uintptrMask&(0x0040<<32) + case GOOS == "aix": + if i == 0 { + // We don't use addresses directly after 0x0A00000000000000 + // to avoid collisions with others mmaps done by non-go programs. + continue + } + p = uintptr(i)<<40 | uintptrMask&(0xa0<<52) + case raceenabled: + // The TSAN runtime requires the heap + // to be in the range [0x00c000000000, + // 0x00e000000000). + p = uintptr(i)<<32 | uintptrMask&(0x00c0<<32) + if p >= uintptrMask&0x00e000000000 { + continue + } + default: + p = uintptr(i)<<40 | uintptrMask&(0x00c0<<32) + } + hint := (*arenaHint)(mheap_.arenaHintAlloc.alloc()) + hint.addr = p + hint.next, mheap_.arenaHints = mheap_.arenaHints, hint + } + } + + ... +} + +``` + +该函数主要用于初始化 mheap_.arenaHints 变量,为以后 arena 区域内存分配定下虚拟内存起始地址. + +### mheap_.init + +`mheap_.init()` 函数主要是初始化 fixalloc 分配器,和初始化 central 属性: + +``` +func (h *mheap) init() { + h.treapalloc.init(unsafe.Sizeof(treapNode{}), nil, nil, &memstats.other_sys) + h.spanalloc.init(unsafe.Sizeof(mspan{}), recordspan, unsafe.Pointer(h), &memstats.mspan_sys) + h.cachealloc.init(unsafe.Sizeof(mcache{}), nil, nil, &memstats.mcache_sys) + h.specialfinalizeralloc.init(unsafe.Sizeof(specialfinalizer{}), nil, nil, &memstats.other_sys) + h.specialprofilealloc.init(unsafe.Sizeof(specialprofile{}), nil, nil, &memstats.other_sys) + h.arenaHintAlloc.init(unsafe.Sizeof(arenaHint{}), nil, nil, &memstats.other_sys) + + h.spanalloc.zero = false + + // h->mapcache needs no init + + for i := range h.central { + h.central[i].mcentral.init(spanClass(i)) + } +} + +``` + +### allocmcache + +allocmcache() 用于给当前协程分配 mcache,但是 mcache 中并未分配任何 mspan,只是给了一个 dummy mspan + +``` +var emptymspan mspan + +func allocmcache() *mcache { + var c *mcache + systemstack(func() { + lock(&mheap_.lock) + c = (*mcache)(mheap_.cachealloc.alloc()) + c.flushGen = mheap_.sweepgen + unlock(&mheap_.lock) + }) + for i := range c.alloc { + c.alloc[i] = &emptymspan + } + c.next_sample = nextSample() + return c +} + +``` + +### procresize + +mcache 是个 per-P 结构,在程序启动时,会初始化化 p,并且分配对应的 mcache。 + +``` +func procresize(nprocs int32) *p + ... + + // initialize new P's + for i := old; i < nprocs; i++ { + pp := allp[i] + if pp == nil { + pp = new(p) + } + pp.init(i) + atomicstorep(unsafe.Pointer(&allp[i]), unsafe.Pointer(pp)) + } + ... +} + +func (pp *p) init(id int32) { + pp.id = id + pp.status = _Pgcstop + pp.sudogcache = pp.sudogbuf[:0] + for i := range pp.deferpool { + pp.deferpool[i] = pp.deferpoolbuf[i][:0] + } + pp.wbBuf.reset() + if pp.mcache == nil { + if id == 0 { + if getg().m.mcache == nil { + throw("missing mcache?") + } + pp.mcache = getg().m.mcache // bootstrap + } else { + pp.mcache = allocmcache() + } + } +} +``` + + +## 内存分配流程 + +### mallocgc + +### tiny 对象分配 + +对于小于 16k 的对象,go 将会把这些 tiny 都嵌入到一个 16k 的对象中去。 + +分配的第一步是进行位对齐,如果 size 是 7bit,那么首先在 tinyoffset 的基础上向 8bit 对齐,然后才是放置当前对象的首地址。 + +如果放置当前对象之后,已经大于 16k,那么就要从 cache 的 mspan 中拿出下一个 16k 的对象,当然如果当前 mspan 的空闲内存不足了,cache 会通过 nextFreeFast 函数从 mcentral 中获取 mspan。 + +![](img/tiny1.png) + +如果没有足够空间,则申请新的,若必要修正tiny及tinyoffset的值 + +![](img/tiny2.png) + +``` +// new(type) 会被翻译为 newobject,但是也不一定,要看逃逸分析的结果 +// 编译前端和 SSA 后端都知道该函数的签名 +func newobject(typ *_type) unsafe.Pointer { + return mallocgc(typ.size, typ, true) +} + +func mallocgc(size uintptr, typ *_type, needzero bool) unsafe.Pointer { + ... + + shouldhelpgc := false + dataSize := size + c := gomcache() + var x unsafe.Pointer + noscan := typ == nil || typ.ptrdata == 0 + if size <= maxSmallSize { + if noscan && size < maxTinySize { + off := c.tinyoffset + // Align tiny pointer for required (conservative) alignment. + if size&7 == 0 { + off = round(off, 8) + } else if size&3 == 0 { + off = round(off, 4) + } else if size&1 == 0 { + off = round(off, 2) + } + if off+size <= maxTinySize && c.tiny != 0 { + // The object fits into existing tiny block. + x = unsafe.Pointer(c.tiny + off) + c.tinyoffset = off + size + c.local_tinyallocs++ + mp.mallocing = 0 + releasem(mp) + return x + } + // Allocate a new maxTinySize block. + span := c.alloc[tinySpanClass] + v := nextFreeFast(span) + if v == 0 { + v, _, shouldhelpgc = c.nextFree(tinySpanClass) + } + x = unsafe.Pointer(v) + (*[2]uint64)(x)[0] = 0 + (*[2]uint64)(x)[1] = 0 + // See if we need to replace the existing tiny block with the new one + // based on amount of remaining free space. + if size < c.tinyoffset || c.tiny == 0 { + c.tiny = uintptr(x) + c.tinyoffset = size + } + size = maxTinySize + } +``` + +### 正常对象分配 + +对于正常的对象,首先要从 size 转化为 sizeclass + +然后从 cache 中获取对应的 sizeclass 的 mspan 对象。 + + +``` + else { + var sizeclass uint8 + if size <= smallSizeMax-8 { + sizeclass = size_to_class8[(size+smallSizeDiv-1)/smallSizeDiv] + } else { + sizeclass = size_to_class128[(size-smallSizeMax+largeSizeDiv-1)/largeSizeDiv] + } + size = uintptr(class_to_size[sizeclass]) + spc := makeSpanClass(sizeclass, noscan) + span := c.alloc[spc] + v := nextFreeFast(span) + if v == 0 { + v, span, shouldhelpgc = c.nextFree(spc) + } + x = unsafe.Pointer(v) + if needzero && span.needzero != 0 { + memclrNoHeapPointers(unsafe.Pointer(v), size) + } + } + } +``` + +#### nextFreeFast + +span.allocCache 为一个64位的数值,每一个bit表示该对象块是否空闲,相当于一个位图,ctz64表示第一个出现1的位数,比如0x1280 返回的就是6,因为第一个出现1的bit为bit6。 + +freeindex 是 mspan 中已分配的对象个数,而 allocCache 是 freeindex 后面的 64 个对象的空闲状态。 + +如果 allocCache 没有空闲对象,那么返回 0: + +``` +func nextFreeFast(s *mspan) gclinkptr { + theBit := sys.Ctz64(s.allocCache) // Is there a free object in the allocCache? + if theBit < 64 { + result := s.freeindex + uintptr(theBit) + if result < s.nelems { + freeidx := result + 1 + if freeidx%64 == 0 && freeidx != s.nelems { + return 0 + } + s.allocCache >>= uint(theBit + 1) + s.freeindex = freeidx + s.allocCount++ + return gclinkptr(result*s.elemsize + s.base()) + } + } + return 0 +} + +func (s *mspan) base() uintptr { + return s.startAddr +} + +``` + +#### mcache.nextFree + +该函数首先通过 nextFreeIndex 函数来获取下一个空闲的对象,如果当前 mspan 已满,那么通过 c.refill 重新获取 mspan: + + +``` +func (c *mcache) nextFree(spc spanClass) (v gclinkptr, s *mspan, shouldhelpgc bool) { + s = c.alloc[spc] + shouldhelpgc = false + freeIndex := s.nextFreeIndex() + if freeIndex == s.nelems { + // The span is full. + if uintptr(s.allocCount) != s.nelems { + println("runtime: s.allocCount=", s.allocCount, "s.nelems=", s.nelems) + throw("s.allocCount != s.nelems && freeIndex == s.nelems") + } + c.refill(spc) + shouldhelpgc = true + s = c.alloc[spc] + + freeIndex = s.nextFreeIndex() + } + + v = gclinkptr(freeIndex*s.elemsize + s.base()) + s.allocCount++ + + return +} + +``` + +#### mspan.nextFreeIndex + +> +allocBits是一个bitmap,标记是否使用,通过allocBits已经可以达到O(1)的分配速度,但是go为了极限性能,对其做了一个缓存,allocCache,从freeindex开始,长度是64。 +> +初始状态,freeindex是0,allocCache全是1,allocBit是0。 +> +第一次分配,theBit是0,freeindex为1,allocCache为01111...。 +> +第二次分配,theBit还是0,freeinde是2,allocCache为001111...。 +> +... +> +在一个span清扫之后,情况会又些复杂。 freeindex会为0,不妨假设allocBit为1010....1010。全是1和0交替。 +> +第一次分配,theBit是1,freeindex是2,allocCache是001010...10。 +> +第二次分配,theBit是1,freeindex是4,allocCache是00001010..10。 ... + + +因此,当 s.allocCache 已经满位 64 的时候,需要通过 s.refillAllocCache 函数从 allocBits 中获取对象的空闲状态,首先将 sfreeindex 取整到 64 倍数,然后将地址传给 allocBits,并填充 allocCache 的位数。 + +``` +func (s *mspan) nextFreeIndex() uintptr { + sfreeindex := s.freeindex + snelems := s.nelems + if sfreeindex == snelems { + return sfreeindex + } + if sfreeindex > snelems { + throw("s.freeindex > s.nelems") + } + + aCache := s.allocCache + + bitIndex := sys.Ctz64(aCache) + for bitIndex == 64 { + // 找到下一个缓存的地址。这个是吧sfreeindex取向上64的倍数 + sfreeindex = (sfreeindex + 64) &^ (64 - 1) + if sfreeindex >= snelems { + s.freeindex = snelems + return snelems + } + whichByte := sfreeindex / 8 + // Refill s.allocCache with the next 64 alloc bits. + s.refillAllocCache(whichByte) + aCache = s.allocCache + bitIndex = sys.Ctz64(aCache) + // nothing available in cached bits + // grab the next 8 bytes and try again. + } + result := sfreeindex + uintptr(bitIndex) + if result >= snelems { + s.freeindex = snelems + return snelems + } + + s.allocCache >>= uint(bitIndex + 1) + sfreeindex = result + 1 + + if sfreeindex%64 == 0 && sfreeindex != snelems { + whichByte := sfreeindex / 8 + s.refillAllocCache(whichByte) + } + s.freeindex = sfreeindex + return result +} + +func (s *mspan) refillAllocCache(whichByte uintptr) { + bytes := (*[8]uint8)(unsafe.Pointer(s.allocBits.bytep(whichByte))) + aCache := uint64(0) + aCache |= uint64(bytes[0]) + aCache |= uint64(bytes[1]) << (1 * 8) + aCache |= uint64(bytes[2]) << (2 * 8) + aCache |= uint64(bytes[3]) << (3 * 8) + aCache |= uint64(bytes[4]) << (4 * 8) + aCache |= uint64(bytes[5]) << (5 * 8) + aCache |= uint64(bytes[6]) << (6 * 8) + aCache |= uint64(bytes[7]) << (7 * 8) + s.allocCache = ^aCache +} + +``` + +#### mcache.refill + +该函数首先改变了 mspan 的 sweepgen,然后通过 cacheSpan 函数从 central 获取一个 mspan: + +``` +func (c *mcache) refill(spc spanClass) { + // Return the current cached span to the central lists. + s := c.alloc[spc] + + if uintptr(s.allocCount) != s.nelems { + throw("refill of span with free space remaining") + } + if s != &emptymspan { + // Mark this span as no longer cached. + if s.sweepgen != mheap_.sweepgen+3 { + throw("bad sweepgen in refill") + } + atomic.Store(&s.sweepgen, mheap_.sweepgen) + } + + // Get a new cached span from the central lists. + s = mheap_.central[spc].mcentral.cacheSpan() + if s == nil { + throw("out of memory") + } + + if uintptr(s.allocCount) == s.nelems { + throw("span has no free space") + } + + // Indicate that this span is cached and prevent asynchronous + // sweeping in the next sweep phase. + s.sweepgen = mheap_.sweepgen + 3 + + c.alloc[spc] = s +} + +``` + +#### mcentral.cacheSpan + +cacheSpan 首先扫描 nonempty 链表,再扫描 empty 链表,如果都无法获取 mspan,那么将会进行 c.grow() 从 mheap 获取 mspan。 + +mheap_.sweepgen 是 heap 的 gc 周期,每次 gc,其 sweepgen 会加 2, + +那么 `s.sweepgen == sg-2` 代表相对于 heap 需要 gc 但是还未开始的 mspan,此时我们手动执行 sweep 进行 gc。 + +`s.sweepgen == sg-1` 是正在 gc 的 mspan,我们需要跳过。 + +否则,那就是不需要 sweep 的 mspan,直接拿出来用就可以了。 + +获取到 mspan 之后,需要更新 c.nmalloc, 重新计算 allocCache + +``` +func (c *mcentral) cacheSpan() *mspan { + ... + sg := mheap_.sweepgen +retry: + var s *mspan + for s = c.nonempty.first; s != nil; s = s.next { + if s.sweepgen == sg-2 && atomic.Cas(&s.sweepgen, sg-2, sg-1) { + c.nonempty.remove(s) + c.empty.insertBack(s) + unlock(&c.lock) + s.sweep(true) + goto havespan + } + if s.sweepgen == sg-1 { + // the span is being swept by background sweeper, skip + continue + } + // we have a nonempty span that does not require sweeping, allocate from it + c.nonempty.remove(s) + c.empty.insertBack(s) + unlock(&c.lock) + goto havespan + } + + for s = c.empty.first; s != nil; s = s.next { + if s.sweepgen == sg-2 && atomic.Cas(&s.sweepgen, sg-2, sg-1) { + // we have an empty span that requires sweeping, + // sweep it and see if we can free some space in it + c.empty.remove(s) + // swept spans are at the end of the list + c.empty.insertBack(s) + unlock(&c.lock) + s.sweep(true) + freeIndex := s.nextFreeIndex() + if freeIndex != s.nelems { + s.freeindex = freeIndex + goto havespan + } + lock(&c.lock) + // the span is still empty after sweep + // it is already in the empty list, so just retry + goto retry + } + if s.sweepgen == sg-1 { + // the span is being swept by background sweeper, skip + continue + } + // already swept empty span, + // all subsequent ones must also be either swept or in process of sweeping + break + } + + unlock(&c.lock) + + // Replenish central list if empty. + s = c.grow() + if s == nil { + return nil + } + lock(&c.lock) + c.empty.insertBack(s) + unlock(&c.lock) + +havespan: + n := int(s.nelems) - int(s.allocCount) + + atomic.Xadd64(&c.nmalloc, int64(n)) + usedBytes := uintptr(s.allocCount) * s.elemsize + atomic.Xadd64(&memstats.heap_live, int64(spanBytes)-int64(usedBytes)) + + if gcBlackenEnabled != 0 { + // heap_live changed. + gcController.revise() + } + freeByteBase := s.freeindex &^ (64 - 1) + whichByte := freeByteBase / 8 + // Init alloc bits cache. + s.refillAllocCache(whichByte) + + // Adjust the allocCache so that s.freeindex corresponds to the low bit in + // s.allocCache. + s.allocCache >>= s.freeindex % 64 + + return s +} + +``` + +#### mcentral.grow + +grow 函数主要计算 sizeclass 对应的 pages,然后向 mheap 申请 mspan: + +``` +func (c *mcentral) grow() *mspan { + npages := uintptr(class_to_allocnpages[c.spanclass.sizeclass()]) + size := uintptr(class_to_size[c.spanclass.sizeclass()]) + + s := mheap_.alloc(npages, c.spanclass, false, true) + if s == nil { + return nil + } + + // Use division by multiplication and shifts to quickly compute: + // n := (npages << _PageShift) / size + n := (npages << _PageShift) >> s.divShift * uintptr(s.divMul) >> s.divShift2 + s.limit = s.base() + size*n + heapBitsForAddr(s.base()).initSpan(s) + return s +} + +``` + +### mheap.alloc + +该函数主要调用 allocSpanLocked 用于从 mheap 获取 mspan + +拿到 mspan 之后,需要在对应的 arena 设置对应的 pageInUse 数组。 + +``` +func (h *mheap) alloc(npage uintptr, spanclass spanClass, large bool, needzero bool) *mspan { + // Don't do any operations that lock the heap on the G stack. + // It might trigger stack growth, and the stack growth code needs + // to be able to allocate heap. + var s *mspan + systemstack(func() { + s = h.alloc_m(npage, spanclass, large) + }) + + if s != nil { + if needzero && s.needzero != 0 { + memclrNoHeapPointers(unsafe.Pointer(s.base()), s.npages<<_PageShift) + } + s.needzero = 0 + } + return s +} + +func (h *mheap) alloc_m(npage uintptr, spanclass spanClass, large bool) *mspan { + _g_ := getg() + + // 为了防止过度的 heap 增长,在分配 n 页之前,需要 sweep 并取回至少 n 个页 + if h.sweepdone == 0 { + h.reclaim(npage) + } + + lock(&h.lock) + + s := h.allocSpanLocked(npage, &memstats.heap_inuse) + if s != nil { + // Record span info, because gc needs to be + // able to map interior pointer to containing span. + atomic.Store(&s.sweepgen, h.sweepgen) + h.sweepSpans[h.sweepgen/2%2].push(s) // Add to swept in-use list. + s.state = mSpanInUse + s.allocCount = 0 + s.spanclass = spanclass + if sizeclass := spanclass.sizeclass(); sizeclass == 0 { + s.elemsize = s.npages << _PageShift + s.divShift = 0 + s.divMul = 0 + s.divShift2 = 0 + s.baseMask = 0 + } else { + s.elemsize = uintptr(class_to_size[sizeclass]) + m := &class_to_divmagic[sizeclass] + s.divShift = m.shift + s.divMul = m.mul + s.divShift2 = m.shift2 + s.baseMask = m.baseMask + } + + // Mark in-use span in arena page bitmap. + arena, pageIdx, pageMask := pageIndexOf(s.base()) + arena.pageInUse[pageIdx] |= pageMask + + // update stats, sweep lists + h.pagesInUse += uint64(npage) + if large { + memstats.heap_objects++ + mheap_.largealloc += uint64(s.elemsize) + mheap_.nlargealloc++ + atomic.Xadd64(&memstats.heap_live, int64(npage<<_PageShift)) + } + } + + unlock(&h.lock) + return s +} + +``` + +#### mheap.allocSpanLocked + +allocSpanLocked 先会去 free 中寻找适当的 mspan + +如果寻找不到,那么会调用 grow 来从操作系统申请至少 64M 内存,再次尝试从 free 中获取 mspan + +如果拿到的 page 过大,还需要进行拆分 + +``` +func (h *mheap) allocSpanLocked(npage uintptr, stat *uint64) *mspan { + t := h.free.find(npage) + if t.valid() { + goto HaveSpan + } + if !h.grow(npage) { + return nil + } + t = h.free.find(npage) + if t.valid() { + goto HaveSpan + } + throw("grew heap, but no adequate free span found") + +HaveSpan: + s := t.span() + if s.state != mSpanFree { + throw("candidate mspan for allocation is not free") + } + + // First, subtract any memory that was released back to + // the OS from s. We will add back what's left if necessary. + memstats.heap_released -= uint64(s.released()) + + if s.npages == npage { + h.free.erase(t) + } else if s.npages > npage { + // Trim off the lower bits and make that our new span. + // Do this in-place since this operation does not + // affect the original span's location in the treap. + n := (*mspan)(h.spanalloc.alloc()) + h.free.mutate(t, func(s *mspan) { + n.init(s.base(), npage) + s.npages -= npage + s.startAddr = s.base() + npage*pageSize + h.setSpan(s.base()-1, n) + h.setSpan(s.base(), s) + h.setSpan(n.base(), n) + n.needzero = s.needzero + // n may not be big enough to actually be scavenged, but that's fine. + // We still want it to appear to be scavenged so that we can do the + // right bookkeeping later on in this function (i.e. sysUsed). + n.scavenged = s.scavenged + // Check if s is still scavenged. + if s.scavenged { + start, end := s.physPageBounds() + if start < end { + memstats.heap_released += uint64(end - start) + } else { + s.scavenged = false + } + } + }) + s = n + } else { + throw("candidate mspan for allocation is too small") + } + // "Unscavenge" s only AFTER splitting so that + // we only sysUsed whatever we actually need. + if s.scavenged { + sysUsed(unsafe.Pointer(s.base()), s.npages<<_PageShift) + s.scavenged = false + + s.state = mSpanManual + h.scavengeIfNeededLocked(s.npages * pageSize) + s.state = mSpanFree + } + + h.setSpans(s.base(), npage, s) + + *stat += uint64(npage << _PageShift) + memstats.heap_idle -= uint64(npage << _PageShift) + + if s.inList() { + throw("still in list") + } + return s +} + +``` + +#### mheap.grow + +当 free 内 mspan 不足的时候,就需要申请内存,建立新的 arena + +grow 函数对于新的 arena,初始化其 span 数组 + +值得注意的是,sysAlloc 函数返回的 size 是 heapArenaBytes 的整数倍,在 linux 中也就是 64M 的整数倍,相当于将新的 arena 的 size 返回。 + +而在下面新建的 s 变量,也是以这个 size 大小作为其 mspan 的大小,这样当它插入到 h.free 中后,整个 arena 的内存就都在 free 这个 treap 树当中了。 + +``` +func (h *mheap) grow(npage uintptr) bool { + ask := npage << _PageShift + v, size := h.sysAlloc(ask) + if v == nil { + print("runtime: out of memory: cannot allocate ", ask, "-byte block (", memstats.heap_sys, " in use)\n") + return false + } + + // Create a fake "in use" span and free it, so that the + // right accounting and coalescing happens. + s := (*mspan)(h.spanalloc.alloc()) + s.init(uintptr(v), size/pageSize) + h.setSpans(s.base(), s.npages, s) + s.state = mSpanFree + memstats.heap_idle += uint64(size) + // (*mheap).sysAlloc returns untouched/uncommitted memory. + s.scavenged = true + // s is always aligned to the heap arena size which is always > physPageSize, + // so its totally safe to just add directly to heap_released. Coalescing, + // if possible, will also always be correct in terms of accounting, because + // s.base() must be a physical page boundary. + memstats.heap_released += uint64(size) + h.coalesce(s) + h.free.insert(s) + return true +} + +``` + +#### mheap.sysAlloc + +heapArenaBytes 在 linux 中就是 64M + +对于 32 位系统,会首先从 h.arena 中获取内存 + +调用 sysReserve 获取新的一段内存 + +初始化 heapArena 各个参数,并更新 h.allArenas 数组 + +``` +func (h *mheap) sysAlloc(n uintptr) (v unsafe.Pointer, size uintptr) { + n = round(n, heapArenaBytes) + + // First, try the arena pre-reservation. + v = h.arena.alloc(n, heapArenaBytes, &memstats.heap_sys) + if v != nil { + size = n + goto mapped + } + + // Try to grow the heap at a hint address. + for h.arenaHints != nil { + hint := h.arenaHints + p := hint.addr + if hint.down { + p -= n + } + if p+n < p { + // We can't use this, so don't ask. + v = nil + } else if arenaIndex(p+n-1) >= 1< typ.size { + // Array allocation. If there are any + // pointers, GC has to scan to the last + // element. + if typ.ptrdata != 0 { + scanSize = dataSize - typ.size + typ.ptrdata + } + } else { + scanSize = typ.ptrdata + } + c.local_scan += scanSize + } + + // Ensure that the stores above that initialize x to + // type-safe memory and set the heap bits occur before + // the caller can make x observable to the garbage + // collector. Otherwise, on weakly ordered machines, + // the garbage collector could follow a pointer to x, + // but see uninitialized memory or stale heap bits. + publicationBarrier() + + // Allocate black during GC. + // All slots hold nil so no scanning is needed. + // This may be racing with GC so do it atomically if there can be + // a race marking the bit. + if gcphase != _GCoff { + gcmarknewobject(uintptr(x), size, scanSize) + } + + return x +} + + +func largeAlloc(size uintptr, needzero bool, noscan bool) *mspan { + // print("largeAlloc size=", size, "\n") + + if size+_PageSize < size { + throw("out of memory") + } + npages := size >> _PageShift + if size&_PageMask != 0 { + npages++ + } + + // Deduct credit for this span allocation and sweep if + // necessary. mHeap_Alloc will also sweep npages, so this only + // pays the debt down to npage pages. + deductSweepCredit(npages*_PageSize, npages) + + s := mheap_.alloc(npages, makeSpanClass(0, noscan), true, needzero) + if s == nil { + throw("out of memory") + } + s.limit = s.base() + size + heapBitsForAddr(s.base()).initSpan(s) + return s +} +``` + + +## Golang的协程栈管理 + +### 分段栈 + +Golang的协程栈是动态增长的,避免了栈溢出的问题。下面我们就来说明下它是如何增长的。 + +我们都知道协程的创建是很廉价的,每个协程创建的时候给的协程栈大小初始值是8KB。那么问题是协程是如何发现自己的栈资源不够用了呢? golang在每个函数入口处都会嵌入一段检测代码(注意,一个协程可能会执行多个函数的喔),当代码检测到栈资源不够用的时候,就会调用morestack函数进行扩容。 + +那么来到我们最关注的话题了,golang的协程栈是如何扩容的呢? 在golang1.5之前,golang的栈扩容策略一直都是分段栈的方式,在1.5的时候连续栈代替了分段站的方式,我们一一道来,这其实是两种不同的解决问题的思想,值得我们借鉴。 分段栈: 当调用morestack的时候,函数为协程分配一段新的内存栈,然后把协程栈的信息写入新栈的栈底的struct中,并且还包括了老栈的地址,这样就把两个栈联系到一起了,然后我们重启goroutine,从导致栈空间用光的那个函数开始执行。 以上的过程大概就是“分段栈”的思想。 + +我们来看看这里有几个问题: + +- 首先要明确一点,新旧栈的空间是不连续的,通过新栈的栈底的struct来跟老栈连接在一起。 + +- 新栈会一直用吗?不会的,在新栈的底部,golang插入了一个函数lessstack,这个函数是什么时候会执行呢?当我们执行完那个导致我们老栈用光的函数后,会回调这个lessstack函数,这个函数的作用就是查找新栈底部的那个struct,然后找到老栈的地址,使得我们的协程返回到老栈,然后再释放新栈,继续执行我们的协程即可。 + +- 在第二步中,看起来我们的分段栈的思想是,给协程栈一个可以伸缩的能力,需要的时候扩一下,用完即释放。这样看起来不浪费内存,很完美的样子,但是实际上在应用的过程中出现了“hot split”的问题。其实在我们分段栈中,新栈的释放会是一个高昂的代价,那么当你的一个循环体中,正好命中了栈分裂,那么就会出现以下的情况,每次进入循环体中,就会造成栈使用光,然后申请新栈,执行完循环体再释放栈,如此代价太大了。 + +### 连续栈 + +为了解决热分裂的问题,golang团队采用了另外一种思想,当要申请新栈的时候,直接把老栈释放掉,直接全部使用新栈即可,不用在释放掉新栈了。这个时候需要把老栈的所有内容都copy到新栈中去,所以我们叫它“栈拷贝”,又叫连续栈,因为我们的栈不再是两个不连续的栈连接的,而是一次申请一个新栈,直接把老栈释放掉。 + +我们这里说几个问题: + +- 我们看起来栈拷贝完全解决了分段栈的热分裂问题,那么新栈的大小是多少呢?新栈的大小是老栈的2倍,当你原先的栈不够用,我直接给你换个两倍大的,而且以后你使用的空间大小又变回原来的大小时,我也不再给你释放了,你还是用这个大的即可。这样带来的一个小问题就是可能会造成空间的浪费,但是带来的性能是可观的。 + +- 栈拷贝真的是那么容易吗?其实不然,我们知道Golang的栈上会存储变量,那么如果程序中有些指针指向了这些变量,会出现什么问题,我们把栈都换掉了,上面的那些变量的地址肯定也变了,这样那些指针岂不是无效了。 + +- 对于2遇到的问题,我们该如何解决呢?好在,只有栈上的指针才能指向栈上的变量,这一点很重要。那么我们需要知道栈上的哪些变量被指针指向了,在垃圾回收中,这些指针我们是可以获取到的,等到说垃圾回收的我们细说,ok,我们知道了这些指针,当我们要进行栈拷贝的时候,直接修改这些指针指向的位置即可。 + +- 很不幸,有些情况下我们是无法使用栈拷贝的,因为有些Go的运行时代码用的是C写的,所以我们无法获取到这些指针的位置,所以后来Go把很多runtime进行Golang化了。当函数是用C写的时候,只能继续使用分段栈的方式。 diff --git "a/Go \345\215\217\347\250\213\350\260\203\345\272\246\342\200\224\342\200\224PMG \350\260\203\345\272\246\347\273\206\350\212\202\345\210\206\346\236\220.md" "b/Go \345\215\217\347\250\213\350\260\203\345\272\246\342\200\224\342\200\224PMG \350\260\203\345\272\246\347\273\206\350\212\202\345\210\206\346\236\220.md" new file mode 100644 index 0000000..50d9734 --- /dev/null +++ "b/Go \345\215\217\347\250\213\350\260\203\345\272\246\342\200\224\342\200\224PMG \350\260\203\345\272\246\347\273\206\350\212\202\345\210\206\346\236\220.md" @@ -0,0 +1,2677 @@ +# Go 协程调度——PMG 调度细节分析 + +[TOC] + +## PMG 状态转移原理索引 + +由于 Golang 中的调度循环稍微有些复杂,各种状态转移眼花缭乱,有些函数会不止一次被调用,调用的作用还不相同,因此我们在开始的时候,制作一个索引图谱,更容易理解调度之间的关系。 + +### 程序初始化阶段 + +这个阶段很简单,现在仅仅只有 m0 这一个物理线程,也仅仅只有 g0 这一个负责上下文转换的协程。 + +- 程序第一步,开始调用 `runtime·newproc` 创建第一个真正的 `main Goroutine`:[main Goroutine 的起始](#toc_8) +- 第二步,`runtime·newproc` 的实际工作者 `newproc1` 利用 `malg` 函数新建一个 G 结构,并利用传来的参数初始化了 G 的上下文信息: [Goroutine 的创建 Null => _Gidle](#toc_9)、[Goroutine 转为可运行 _Gidle => _Grunnable](#toc_12) +- 第三步,`runtime·mstart` 物理线程进行初始化,最重要的就是 save 函数保存 g0 的 SP 地址:[m0 初始化阶段 idle](#toc_45) +- 第四步,`runtime·mstart` 调用 `schedule` 开始调度:[M 的调度 idle => spinning](#toc_48) +- 第五步,因为是在初始化阶段,所以 `schedule` 调度一定能够找到第二步放入队列的 G:[M 获取 G 成功开始执行 spinning => running](#toc_54) +- 第六步,那就开始进行 G 的状态转移:[G 的运行 _Grunnable => _Grunning](#toc_16) +- 第七步,现在真正的 main Goroutine 开始执行 m0 的入口函数 `runtime·main`: [m0 主线程的执行 running](#toc_64) +- 第八步,`runtime·main` 函数中最为关键的就是后台监控程序 `sysmon`, 这个时候 Golang 会 clone 一个独立物理线程脱离 PMG 来执行:[sysmon 后台线程的执行 running => syscall](#toc_68) +- 第九步,`runtime·main` 函数开始执行用户的代码 `main.main` 函数 + +至此,`Golang` 的程序初始化已完毕,m0 正在执行者用户写的 `main` 函数,还有一个独立的线程在执行着后台的监控程序 `sysmon`,`main Goroutine` 正在处于 `_Grunning` 状态中。 + +那么接下来,我们就要讨论运行用户代码的过程中,我们会遇到哪些情形,会因此触发哪些调度程序的运行。 + +### 程序运行中新建 Goroutine + +在 Golang 的程序里面,当然不可能只有一个 `main Goroutine` 在跑,那么当第一次新建一个非 `main Goroutine` 协程 G 的时候,runtime 是如何处理的呢? + +- 第一步,其实和初始化阶段相同,还是调用 `runtime·newproc`,创建一个新的 G 结构:[Goroutine 的创建 Null => _Gidle](#toc_9)、[Goroutine 转为可运行 _Gidle => _Grunnable](#toc_12) +- 第二步,这一步就和初始化阶段不同了,`newproc1` 函数调用 `wakeup` 函数使用 `newm` 创建一个新的物理线程来运行这个 G:[M 新建与唤醒 null/sleeping => spinninig](#toc_60),这个时候 `main Goroutine` 返回继续执行 `main` 函数代码,而这个线程 clone 建立之后,第一个运行的函数仍然是初始化函数 mstart: [mstart](#toc_46) +- 第三步,与初始化阶段相同,`mstart` 调用 `schedule` 开始调度:[M 的调度 idle => spinning](#toc_48) +- 第四步,与初始化阶段相同,`schedule` 调度一定能够找到放入队列的 G:[M 获取 G 成功开始执行 spinning => running](#toc_54) +- 第五步,与初始化阶段相同,开始在新的物理线程 M 中进行 G 的状态转移,开始执行用户的函数代码:[G 的运行 _Grunnable => _Grunning](#toc_16) +- 第六步,这里和初始化阶段不同。由于 m0 执行的是用户的 main 函数,因此它退出的时候就是 Go 程序结束的时刻。但是普通 m 物理线程中执行的协程代码 G 只是程序的一部分功能,它终将会结束。这时候,会调用 `runtime·goexit` 函数进行状态转移:[G 的退出 _Grunning => _Gidle](#toc_19) +- 第七步,M 物理线程的一个调度循环已经结束:[协程调度循环 cycle](#toc_67) +- 第八步,按照调度循环,物理线程 M 接下来会重新回到调度函数 `schedule`:[M 的调度 idle => spinning](#toc_48) +- 第九步,如果在第一步到第八步过程中,`main` 函数或者协程函数中,又创建了多个新的协程 G,那么 `schedule` 就可能会抢到一个可运行的 G:[M 获取 G 成功开始执行 spinning => running](#toc_54) +- 第十步,如果不幸没能够抢到可运行的 G,那么就会陷入睡眠,等待着 wakeup:[M 获取 G 失败 spinning => sleeping](#toc_56) + +至此,第一个非 `main Goroutine` 的协程生命周期和 M 状态转移已经结束。我们下面再谈谈对于正在运行中的 Golang 程序,新建一个协程会是什么流程? + +- 第一步,和上面步骤一相同。 +- 第二步,还是会调用 wakeup 函数:[M 新建与唤醒 null/sleeping => spinninig](#toc_60)。但是这里调用的结果已经不同。之前因为在初始阶段,因此 G 总是可以很快的被运行。但是随着时间的持续,创建的 G 越来愈多,这时候 wakeup 函数就会判断当前并发度 P 是否已经跑满,如果已经没有空闲的 P,那么就不会尝试唤醒或者新建 M。 +- 如果运气比较好,这时候恰好有空闲的 P,那么就尝试从空闲列表中取出一个已经陷入睡眠的 M,该 M 的来源是 [M 获取 G 失败 spinning => sleeping](#toc_56) 。如果空闲列表中没有 M,那么仍然使用 newm 创建一个新的物理线程 M。 +- 接下来的步骤相同。 + +### Goroutine 被动阻塞与唤醒、主动调度 + +Golang 的协程之所以可以并发十万百万,最关键的就是当遇到 network 或者 channel 的时候,runtime 会自动阻塞 Goroutine,调度其他的 G 来占据当前的物理线程 M。 + +Goroutine 的阻塞是通过 `gopark` 调用 `mcall`、`schedule` 来实现的:[协程 G 被动阻塞 _Grunning => _Gwaiting](#toc_24) + +当对应的 channel 数据可发送,或者有数据送达;或者当 network 数据发送完毕或者有数据可送达被 sysmon 监控到之后,runtime 自动将 G 恢复 G 的可运行状态:[协程 G 阻塞唤醒 _Gwaiting => _Grunnable](#toc_29) + +有时候,用户可以直接进行主动调度,让出自己的资源给其他的 G:[G 的主动调度 _Grunning => _Grunnable](#toc_33) + +### Goroutine 使用系统调用被阻塞 + +Goroutine 使用系统调用与被动阻塞是完全不同的,被动阻塞是由 runtime 控制的 G 上下文转换,本质上是多路复用来实现数据往来的监控,当数据未准备好的时候,先把 G 的上下文保存,然后执行其他的 G;当数据已经准备好的时候,runtime 再把 G 恢复回来。整个过程物理线程都是活跃的,代码都是向前不断的走的。 + +而系统调用不同,整个物理线程 M 已经沉入内核在执行系统调用。对于很多系统调用,例如读取文件等等,常常会使进程从 `TASK_RUNNING` 状态由于等待某些信号而转为 `TASK_INTERRUPTIBLE` 状态,这个物理线程会被内核调度程序调离 CPU。 + +此时,很有可能就出现了一个空闲的 CPU,如果什么都不做,内核可能会调度一些不太重要的任务,造成我们 Golang 的应用程序并发度不足,这个时候 runtime 就会创建了一个新的物理线程或者唤醒一个已经陷入沉睡的线程,试图让内核调度这个物理线程到这个空闲的 CPU 上,来执行 runnable 的 G 任务。 + +我们之前所说的,由 `runtime.main` 函数启动的,脱离 PMG 体系的物理线程 `sysmon` 开始起作用,它会监控 Golang 代码所有的系统调用,发现超时之后,会自动执行 P 与 M1 的剥离,设置 G 的状态为 `_Gsyscall` 并尝试新建或者唤醒其他 M2:[sysmon 后台线程的执行 running => syscall](#toc_68) + +当系统调用结束之后,M1 被操作系统内核重新调度回来执行,但是此时它已经被 runtime 剥夺了 P,这个时候它只能睡眠,G 被恢复为 `_Grunnable` 状态,剥离 G 与 M 的关系,并将 G 扔到全局队列中去。这个 M1 等待着其他线程的唤醒。[系统调用返回 syscall => running/sleeping](#toc_72) + +### Goroutine 运行时间过长被抢占 + +`sysmon` 函数不仅监控着系统调用,同时也在监控着所有的正在运行的 Goroutine,它会每隔一段时间就遍历所有的 P,查看当前在 P 上运行的 G 已运行的时间:[sysmon 后台线程的执行 running => syscall/_Grunnable](#toc_68) + +如果认为当前的 G 运行已经超时,runtime 会尝试设置抢占标志,在 G 调用函数的时候,会自动检测抢占标志,让出自己的控制权。[G 运行时间长被抢占 _Grunning => _Grunnable](#toc_36) + +这个功能也被用于 GC 过程中的 STW,G 让出自己的控制权之后,实际接下来执行的还是 `schedule` 调度函数,当调度函数发现 `gcwaiting` 为 true 的时候,会自动让当前的线程沉入睡眠。 + +至此,所有的 Golang 协程调度基本流程已经梳理完毕。 + + + +## Goroutine 的状态转移 + +我们之前说过,G的各种状态如下: + +- Gidle:G被创建但还未完全被初始化。 +- Grunnable:当前G为可运行的,正在等待被运行。 +- Grunning:当前G正在被运行。 +- Gsyscall:当前G正在被系统调用 +- Gwaiting:当前G正在因某个原因而等待 +- Gdead:当前G完成了运行 + +我们接下来就按照上面几个的状态转移来了解 Goroutine 的原理。 + +### main Goroutine 的起始 + +第一个 goroutine 就是用于执行用户 main 函数代码的 main Goroutine。 + +schedinit 完成调度系统初始化后,返回到 rt0_go 函数中开始调用 newproc() 创建一个新的 goroutine 用于执行mainPC 所对应的 runtime·main 函数, runtime.main 最终会调用我们写的 main.main 函数: + +``` + MOVQ $runtime·mainPC(SB), AX // entry,mainPC是runtime.main + PUSHQ AX // newproc的第二个参数入栈,也就是新的goroutine需要执行的函数 + PUSHQ $0 // newproc的第一个参数入栈,该参数表示runtime.main函数需要的参数大小,因为runtime.main没有参数,所以这里是0 + + CALL runtime·newproc(SB) // 创建main goroutine + POPQ AX + POPQ AX + + // start this M + CALL runtime·mstart(SB) // 主线程进入调度循环,运行刚刚创建的goroutine + CALL runtime·abort(SB)// mstart should never return // # 上面的mstart永远不应该返回的,如果返回了,一定是代码逻辑有问题,直接abort + RET + + DATA runtime·mainPC+0(SB)/8,$runtime·main(SB) + GLOBL runtime·mainPC(SB),RODATA,$8 +``` + +我们的重点就是 newproc 这个函数。 + + +### Goroutine 的创建 `Null => _Gidle` + +#### newproc + +newproc函数用于创建新的goroutine,它有两个参数,先说第二个参数fn,新创建出来的goroutine将从fn这个函数开始执行,而这个fn函数可能也会有参数,newproc的第一个参数正是fn函数的参数以字节为单位的大小。比如有如下go代码片段: + +``` +func start(a, b, c int64) { + ...... +} + +func main() { + go start(1, 2, 3) +} + +``` +编译器在编译上面的go语句时,就会把其替换为对newproc函数的调用,编译后的代码逻辑上等同于下面的伪代码 + +``` +func main() { + push 0x3 + push 0x2 + push 0x1 + runtime.newproc(24, start) +} + +``` +那为什么需要传递fn函数的参数大小给 newproc 函数呢?原因就在于 newproc 函数将创建一个新的 goroutine 来执行 fn函数,而这个新创建的 goroutine 与当前这个 goroutine 会使用不同的栈,因此就需要在创建 goroutine 的时候把 fn 需要用到的参数先从当前 goroutine 的栈上拷贝到新的 goroutine 的栈上之后才能让其开始执行,而 newproc 函数本身并不知道需要拷贝多少数据到新创建的 goroutine 的栈上去,所以需要用参数的方式指定拷贝多少数据。 + +了解完这些背景知识之后,下面我们开始分析 newproc 的代码。newproc 函数是对 newproc1 的一个包装,这里最重要的准备工作有两个,一个是获取fn函数第一个参数的地址(代码中的argp),另一个是使用systemstack函数切换到g0栈,当然,对于我们这个初始化场景来说现在本来就在g0栈,所以不需要切换,然而这个函数是通用的,在用户的goroutine中也会创建goroutine,这时就需要进行栈的切换。 + +堆栈的结构如下: + +![](img/PMG2.png) + +newproc 干的事情也比较简单: + +- 计算参数的地址 argp +- 获取调用端的地址(返回地址) pc +- 使用 systemstack 调用 newproc1 函数,也就是用 g0 的栈创建 g 对象 + +> +>getcallerpc 返回的是调用函数之后的那条程序指令的地址,即调用者 go func() {} 之后的那条指令的地址。这个变量仅仅用于调试追踪使用,因为 go func() {} 结束之后,也不会像函数那样还可以直接返回到调用的地方,而是会指向 goexit 的地址。 + +``` +func newproc(siz int32, fn *funcval) { + // add 是一个指针运算,跳过函数指针 + //函数调用参数入栈顺序是从右向左,而且栈是从高地址向低地址增长的 + //注意:argp指向fn函数的第一个参数,而不是newproc函数的参数 + //参数fn在栈上的地址+8的位置存放的是fn函数的第一个参数 + argp := add(unsafe.Pointer(&fn), sys.PtrSize) + gp := getg() + + // getcallerpc()返回一个地址,也就是调用newproc时由call指令压栈的函数返回地址, + // 对于我们现在这个场景来说,pc就是CALLruntime·newproc(SB)指令后面的 + // 就是上图中那个 return address 的地址。 + // 该 PC 仅仅用于调试,因为 G 并不会像是函数一样还要回去执行下面的代码 + pc := getcallerpc() + + // systemstack的作用是切换到g0栈执行作为参数的函数 + systemstack(func() { + newproc1(fn, (*uint8)(argp), siz, gp, pc) + }) +} + +// funcval 是一个变长结构,第一个成员是函数指针 +// 所以上面的 add 是跳过这个 fn +type funcval struct { + fn uintptr + // variable-size, fn-specific data here +} + +func newproc1(fn *funcval, argp *uint8, narg int32, callergp *g, callerpc uintptr) { + ... + + newg := gfget(_p_) // 从p的本地缓冲里获取一个没有使用的g,初始化时没有,返回 nil + if newg == nil { + //new一个g结构体对象,然后从堆上为其分配栈,并设置g的stack成员和两个stackgard成员 + newg = malg(_StackMin) + casgstatus(newg, _Gidle, _Gdead) //初始化g的状态为_Gdead + allgadd(newg) //放入全局变量allgs切片中 + } + + ... +} + +``` + +代码走到了 `newproc1` 之后,就要新建一个状态为 `_Gidle` 的初始 G 结构了,这部分主要由 `malg` 函数执行。 + +#### malg 创建一个初始 G 结构 + +`malg` 函数专门用于创建新的 G 结构体,其关键步骤在于为 G 分配栈,也就是 stack.lo 和 stack.hi + +stackalloc 会优先从 mcache 中的 stackcache 中分配,或者从 stackpool 内存池分配,如果内存不足就调用 mheap_.allocManual 函数填充。 + +如果申请的栈空间比较大,那么就直接从 mheap_.allocManual 函数分配内存。 + +``` +func malg(stacksize int32) *g { + newg := new(g) + if stacksize >= 0 { + stacksize = round2(_StackSystem + stacksize) + systemstack(func() { + newg.stack = stackalloc(uint32(stacksize)) + }) + newg.stackguard0 = newg.stack.lo + _StackGuard + newg.stackguard1 = ^uintptr(0) + } + return newg +} + +func stackalloc(n uint32) stack { + var v unsafe.Pointer + if n < _FixedStack<<_NumStackOrders && n < _StackCacheSize { + order := uint8(0) + n2 := n + for n2 > _FixedStack { + order++ + n2 >>= 1 + } + var x gclinkptr + c := thisg.m.mcache + if stackNoCache != 0 || c == nil || thisg.m.preemptoff != "" { + lock(&stackpoolmu) + x = stackpoolalloc(order) + unlock(&stackpoolmu) + } else { + x = c.stackcache[order].list + if x.ptr() == nil { + stackcacherefill(c, order) + x = c.stackcache[order].list + } + c.stackcache[order].list = x.ptr().next + c.stackcache[order].size -= uintptr(n) + } + v = unsafe.Pointer(x) + } else { + var s *mspan + npage := uintptr(n) >> _PageShift + log2npage := stacklog2(npage) + + // Try to get a stack from the large stack cache. + lock(&stackLarge.lock) + if !stackLarge.free[log2npage].isEmpty() { + s = stackLarge.free[log2npage].first + stackLarge.free[log2npage].remove(s) + } + unlock(&stackLarge.lock) + + if s == nil { + // Allocate a new stack from the heap. + s = mheap_.allocManual(npage, &memstats.stacks_inuse) + if s == nil { + throw("out of memory") + } + osStackAlloc(s) + s.elemsize = uintptr(n) + } + v = unsafe.Pointer(s.base()) + } + + return stack{uintptr(v), uintptr(v) + uintptr(n)} +} +``` + + +### Goroutine 转为可运行 `_Gidle => _Grunnable` + +一个空白的 G 结构是无法运行的,至少要把 `go func()` 的 `func` 函数地址、参数地址传给它,这个过程就是 G.shed 属性的赋值过程。 + +#### newproc1 进行 G 结构的赋值 + +newproc1的第二个参数argp是fn函数的第一个参数的地址,第三个参数是fn函数的参数以字节为单位的大小。 + +newproc1 的工作流程主要是初始化 newg.sched、newg.gopc、newg.startpc + +sched 是 gobuf 类型的属性,用于保存当前协程的栈上下文。 + +- 调用getg(汇编实现)获取当前的g +- 新建一个g策略: + - 调用 gfget函数,这里是复用优先策略 + - 首先从p的gfree获取回收的g,如果p.gfree链表为空,就从全局调度器sched里面的gfree链表里面steal 32个free的g给p.gfree。 + - 将p.gfree链表的head元素获取返回。 + - 如果获取不到freeg时调用malg()函数新建一个g, 初始的栈空间大小是2K。 +- 把参数复制到g的栈上 +- 把返回地址复制到g的栈上, 这里的返回地址是goexit, 表示调用完目标函数后会调用goexit +- 设置g的调度数据(sched) + - 设置sched.sp等于参数+返回地址后的rsp地址 + - 设置sched.pc等于目标函数的地址, 查看gostartcallfn和gostartcall + - 设置sched.g等于g +- 设置g的状态为待运行(_Grunnable) +- 调用runqput函数把g放到运行队列 +- 如果当前有空闲的P,但是没有自旋的M(nmspinning等于0),并且主函数已执行,则唤醒或新建一个M来调度一个P执行 + - 唤醒或新建一个M会通过调用wakep函数 + + +在 gostartcall 中把 newproc1 时设置到 buf.pc 中的 goexit 的函数地址放到了 goroutine 的栈顶,然后重新设置 buf.pc 为 goroutine 函数的位置。这样做的目的是为了在执行完任何 goroutine 的函数时,通过 RET 指令,都能从栈顶把 sp 保存的 goexit 的指令 pop 到 pc 寄存器,效果相当于任何 goroutine 执行函数执行完之后,都会去执行 runtime.goexit,完成一些清理工作后再进入 schedule。 + + +``` +func newproc1(fn *funcval, argp *uint8, narg int32, callergp *g, callerpc uintptr) { + + ... + + totalSize := 4*sys.RegSize + uintptr(siz) + sys.MinFrameSize // extra space in case of reads slightly beyond frame + totalSize += -totalSize & (sys.SpAlign - 1) // align to spAlign + sp := newg.stack.hi - totalSize // 调整 g 的栈顶置针 + + spArg := sp + if narg > 0 { + memmove(unsafe.Pointer(spArg), unsafe.Pointer(argp), uintptr(narg)) //把参数从执行newproc函数的栈(初始化时是g0栈)拷贝到新g的栈 + + ... + + // 把newg.sched结构体成员的所有成员设置为0 + memclrNoHeapPointers(unsafe.Pointer(&newg.sched), unsafe.Sizeof(newg.sched)) + newg.sched.sp = sp // 设置newg的sched成员,调度器需要依靠这些字段才能把goroutine调度到CPU上运行。 + newg.stktopsp = sp + + //newg.sched.pc表示当newg被调度起来运行时从这个地址开始执行指令 + //把pc设置成了goexit这个函数偏移1(sys.PCQuantum等于1)的位置, + //至于为什么要这么做需要等到分析完gostartcallfn函数才知道 + newg.sched.pc = funcPC(goexit) + sys.PCQuantum + + newg.sched.g = guintptr(unsafe.Pointer(newg)) + gostartcallfn(&newg.sched, fn) //调整sched成员和newg的栈 + + newg.gopc = callerpc //主要用于traceback + newg.ancestors = saveAncestors(callergp) + newg.startpc = fn.fn //设置newg的startpc为fn.fn,该成员主要用于函数调用栈的traceback和栈收缩;newg真正从哪里开始执行并不依赖于这个成员,而是sched.pc + + ... + //设置g的状态为_Grunnable,表示这个g代表的goroutine可以运行了 + casgstatus(newg, _Gdead, _Grunnable) + + //把newg放入_p_的运行队列,初始化的时候一定是p的本地运行队列,其它时候可能因为本地队列满了而放入全局队列 + runqput(_p_, newg, true) + + if atomic.Load(&sched.npidle) != 0 && atomic.Load(&sched.nmspinning) == 0 && mainStarted { + wakep() + } + ... +} + +func gostartcallfn(gobuf *gobuf, fv *funcval) { + var fn unsafe.Pointer + if fv != nil { + fn = unsafe.Pointer(fv.fn) + } else { + fn = unsafe.Pointer(funcPC(nilfunc)) + } + gostartcall(gobuf, fn, unsafe.Pointer(fv)) +} + +func gostartcall(buf *gobuf, fn, ctxt unsafe.Pointer) { + sp := buf.sp // newg的栈顶,目前newg栈上只有fn函数的参数,sp指向的是fn的第一参数 + if sys.RegSize > sys.PtrSize { + sp -= sys.PtrSize + *(*uintptr)(unsafe.Pointer(sp)) = 0 + } + + // 现将 SP 向下生长,留下空间 + sp -= sys.PtrSize + + //在栈上放入goexit+1的地址 + //这里在伪装fn是被goexit函数调用的,使得fn执行完后返回到goexit继续执行,从而完成清理工作 + *(*uintptr)(unsafe.Pointer(sp)) = buf.pc + + buf.sp = sp //重新设置newg的栈顶寄存器 + + //这里才真正让newg的ip寄存器指向fn函数,注意,这里只是在设置newg的一些信息,newg还未执行, + //等到newg被调度起来运行时,调度器会把buf.pc放入cpu的IP寄存器, + //从而使newg得以在cpu上真正的运行起来 + buf.pc = uintptr(fn) + buf.ctxt = ctxt +} + +``` + +#### runqput + +因为是放 runq 而不是直接执行,因而什么时候开始执行并不是用户代码能决定得了的。 + +- 首先随机把g放到p.runnext, 如果放到runnext则入队原来在runnext的g; +- 然后尝试把g放到P的local queue; +- 如果local queue(256 capacity)满了则调用runqputslow函数把g放到"全局运行队列"(操作全局 sched 时,需要获取全局 sched.lock 锁,全局锁争抢的开销较大,所以才称之为 slow + - runqputslow会把本地运行队列中一半的g放到全局运行队列, 这样下次就可以快速使用local queue. + +``` +func runqput(_p_ *p, gp *g, next bool) { + if randomizeScheduler && next && fastrand()%2 == 0 { + next = false + } + + if next { + //把gp放在_p_.runnext成员里, + //runnext成员中的goroutine会被优先调度起来运行 + retryNext: + oldnext := _p_.runnext + if !_p_.runnext.cas(oldnext, guintptr(unsafe.Pointer(gp))) { + // 有其它线程在操作runnext成员,需要重试 + goto retryNext + } + if oldnext == 0 {//原本runnext为nil,所以没任何事情可做了,直接返回 + return + } + // 把之前的 runnext 踢到正常的 runq 中 + gp = oldnext.ptr() + } + +retry: + //可能有其它线程正在并发修改runqhead成员,所以需要跟其它线程同步 + h := atomic.Load(&_p_.runqhead) + t := _p_.runqtail + + if t-h < uint32(len(_p_.runq)) { // 判断队列是否满了 + //队列还没有满,可以放入 + _p_.runq[t%uint32(len(_p_.runq))].set(gp) + + //虽然没有其它线程并发修改这个runqtail,但其它线程会并发读取该值以及p的runq成员 + //这里使用StoreRel是为了: + //1,原子写入runqtail + //2,防止编译器和CPU乱序,保证上一行代码对runq的修改发生在修改runqtail之前 + //3,可见行屏障,保证当前线程对运行队列的修改对其它线程立马可见 + atomic.Store(&_p_.runqtail, t+1) + return + } + + //p的本地运行队列已满,需要放入全局运行队列 + if runqputslow(_p_, gp, h, t) { + return + } + + // 队列没有满的话,上面的 put 操作会成功 + goto retry +} + +``` + +#### runqputslow + +``` +// 因为 slow,所以会一次性把本地队列里的多个 g (包含当前的这个) 放到全局队列 +// 只会被 g 的 owner P 执行 +func runqputslow(_p_ *p, gp *g, h, t uint32) bool { + var batch [len(_p_.runq)/2 + 1]*g + + // 先从本地队列抓一批 g + n := t - h + n = n / 2 + if n != uint32(len(_p_.runq)/2) { + throw("runqputslow: queue is not full") + } + for i := uint32(0); i < n; i++ { + batch[i] = _p_.runq[(h+i)%uint32(len(_p_.runq))].ptr() + } + if !atomic.Cas(&_p_.runqhead, h, h+n) { // cas-release, commits consume + //如果cas操作失败,说明已经有其它工作线程从_p_的本地运行队列偷走了一些goroutine,所以直接返回 + return false + } + batch[n] = gp + + if randomizeScheduler { + for i := uint32(1); i <= n; i++ { + j := fastrandn(i + 1) + batch[i], batch[j] = batch[j], batch[i] + } + } + + //全局运行队列是一个链表,这里首先把所有需要放入全局运行队列的g链接起来, + //减少后面对全局链表的锁住时间,从而降低锁冲突 + for i := uint32(0); i < n; i++ { + batch[i].schedlink.set(batch[i+1]) + } + + var q gQueue + q.head.set(batch[0]) + q.tail.set(batch[n]) + + // 将链表放到全局队列中 + lock(&sched.lock) + globrunqputbatch(&q, int32(n+1)) + unlock(&sched.lock) + + return true +} + +func globrunqputbatch(batch *gQueue, n int32) { + sched.runq.pushBackAll(*batch) + sched.runqsize += n + *batch = gQueue{} +} + +``` + +值的一提的是runqputslow函数并没有一开始就把全局运行队列锁住,而是等所有的准备工作做完之后才锁住全局运行队列,这是并发编程加锁的基本原则,需要尽量减小锁的粒度,降低锁冲突的概率。 + +之后,Goroutine 的创建就结束了 + +- 对于系统初始化阶段,G 里面的 func 是 runtime.main 函数,主线程 m0 接下来就执行 mstart 函数执行调度函数。详情可以看 M 的 mstart 函数。调度函数会把各个创建的 G 从队列中取出来,然后恢复 G 的上下文,执行 runtime.main 函数。 +- 对于普通的 `go func(){}`, systemstack 会从 g0 切换回当前的 G,当前的 G 就可以继续运行下面的代码了。 + +初始化过程中,Goroutine 的创建结束后,M 就会继续走 rt0_go 函数,下一个函数就是 mstart + +### G 的运行 `_Grunnable => _Grunning` + +当 M 被创建之后,或者执行结束一个 Goroutine 执行 mcall 切到 g0 后,就会执行 schedule 函数,获取一个可运行状态的 G,进而调用 execute 通过 gogo 转为普通 g 来执行一个 Goroutine。 + +#### execute + +比较简单,绑定 g 和 m,然后 gogo 执行绑定的 g 中的函数。 + +- 调用 getg 获取当前的g; +- 把 G(gp) 的状态由待运行(`_Grunnable`)改为运行中(`_Grunning`); +- 设置 G 的 `stackguard`, 栈空间不足时可以扩张; +- 增加P中记录的调度次数(对应上面的每61次优先获取一次全局运行队列); +- 设置 `g.m.curg = g`;设置 `gp.m = g.m`; +- 调用 `gogo` 函数 + + +``` +func execute(gp *g, inheritTime bool) { + _g_ := getg() + + casgstatus(gp, _Grunnable, _Grunning) + gp.waitsince = 0 + gp.preempt = false + gp.stackguard0 = gp.stack.lo + _StackGuard + if !inheritTime { + _g_.m.p.ptr().schedtick++ + } + _g_.m.curg = gp + gp.m = _g_.m + + gogo(&gp.sched) +} + +``` + +#### gogo + +gogo 的汇编代码我们在前面已经详细说过了,就是从 g0 切换到 g 栈空间,并执行 g 的用户代码。 + +### G 的退出 `_Grunning => _Gidle` + +#### runtime·goexit + +对于主进程 m0 来说,是永远不会走到这一步的,因为 m0 只执行用户的 main.main,用户的代码全部完成后,m0 即可直接退出,整个 Go 程序也就结束了。 + +对于一个普通的 M 而言,当 M 执行完一个 G 任务之后,会进入到 Goexit 中来,等待重新调度 + +``` +TEXT runtime·goexit(SB),NOSPLIT|NOFRAME|TOPFRAME,$0-0 + MOVD R0, R0 // NOP + BL runtime·goexit1(SB) // does not return + + +func goexit1() { + mcall(goexit0) +} + +``` + +mcall 函数专门用于切换到 m->g0 的栈上,然后调用 fn (g)函数。goexit0 用于重置 G,然后给 P 重复利用。从goexit调用的mcall的保存状态其实是多余的, 因为G已经结束了,其实并不需要保存 G 的上下文状态。 + +#### mcall(goexit0) + +goexit0函数调用时已经回到了g0的栈空间, 处理如下: + +- 把G的状态由运行中(`_Grunning`)改为已中止(`_Gdead`) +- 清空G的成员 +- 调用dropg函数解除M和G之间的关联 +- 调用gfput函数把G放到P的自由列表中, 下次创建G时可以复用 +- 调用 schedule 函数继续调度 + + +``` +func goexit0(gp *g) { + _g_ := getg() + + casgstatus(gp, _Grunning, _Gdead) + if isSystemGoroutine(gp, false) { + atomic.Xadd(&sched.ngsys, -1) + } + + //清空g保存的一些信息 + gp.m = nil + locked := gp.lockedm != 0 + gp.lockedm = 0 + _g_.m.lockedg = 0 + gp.paniconfault = false + gp._defer = nil // should be true already but just in case. + gp._panic = nil // non-nil for Goexit during panic. points at stack-allocated data. + gp.writebuf = nil + gp.waitreason = 0 + gp.param = nil + gp.labels = nil + gp.timer = nil + + //g->m = nil, m->currg = nil 解绑g和m之关系 + dropg() + + gfput(_g_.m.p.ptr(), gp) //g放入p的freeg队列,方便下次重用,免得再去申请内存,提高效率 + + schedule() +} + +``` + +到这里,一个简单的 Goroutine 的生命周期就结束了,但是 M 物理线程还不会结束,它会再次执行 schedule 进入下一个调度循环。 + +在这个 Goroutine 里面没有新建新的 G,也没有执行网络调用等等非阻塞,也没有执行读取文件之类的系统调用。接下来我们就要重点详细的了解遇到这些情况 golang 是如何处理的。 + +#### schedule + +可以执行 M 的调度函数了。 + +### `_Grunning` 过程中新建协程 G + +新建协程 G 和初始化过程基本相似,过程: + +- 利用 sysstack 函数切换到g0栈; +- 分配g结构体对象; +- 初始化g对应的栈信息,并把参数拷贝到新g的栈上; +- 设置好g的sched成员,该成员包括调度g时所必须pc, sp, bp等调度信息; +- 调用runqput函数把g放入运行队列; +- 尝试唤醒或新建一个 M 来执行 +- 返回 + +这里就可以看出来,和初始化阶段最大的不同就是可以唤醒一个 M 来执行,初始化阶段因为要求只能在 m0 线程执行,mainStarted 为 false,所以这个 if 条件无法执行,到了这里,终于可以执行 wakeup: + +``` +func newproc1(fn *funcval, argp *uint8, narg int32, callergp *g, callerpc uintptr) { + + ... + + if atomic.Load(&sched.npidle) != 0 && atomic.Load(&sched.nmspinning) == 0 && mainStarted { + wakep() + } + ... +} + +``` + +### 协程 G 被动阻塞 `_Grunning => _Gwaiting` + +我们以 channel 阻塞为例,讲述协程 G 被动阻塞的流程。 + +读取channel是通过调用runtime.chanrecv1函数来完成的,我们就从它开始分析,不过在分析过程中我们不会把精力放在对channel的操作上,而是分析这个过程中跟调度有关的细节。 + +``` +func chanrecv1(c *hchan, elem unsafe.Pointer) { + chanrecv(c, elem, true) +} + +// runtime/chan.go : 415 +func chanrecv(c *hchan, ep unsafe.Pointer, block bool) (selected, received bool) { + ...... + //省略部分的代码逻辑主要在判断读取操作是否可以立即完成,如果不能立即完成 + //就需要把g挂在channel c的读取队列上,然后调用goparkunlock函数阻塞此goroutine + goparkunlock(&c.lock, waitReasonChanReceive, traceEvGoBlockRecv, 3) + ...... +} + +``` + +chanrecv1直接调用chanrecv函数实现读取操作,chanrecv首先会判断channel是否有数据可读,如果有数据则直接读取并返回,但如果没有数据,则需要把当前goroutine挂入channel的读取队列之中并调用goparkunlock函数阻塞该goroutine. + +#### gopark + +``` +func goparkunlock(lock *mutex, reason waitReason, traceEv byte, traceskip int) { + gopark(parkunlock_c, unsafe.Pointer(lock), reason, traceEv, traceskip) +} + +func gopark(unlockf func(*g, unsafe.Pointer) bool, lock unsafe.Pointer, reason waitReason, traceEv byte, traceskip int) { + ...... + // can't do anything that might move the G between Ms here. + mcall(park_m) //切换到g0栈执行park_m函数 +} +``` + +goparkunlock函数直接调用gopark函数,gopark则调用mcall从当前main goroutine切换到g0去执行park_m函数 + +#### mcall(park_m) 函数 + +``` +func park_m(gp *g) { + _g_ := getg() + + casgstatus(gp, _Grunning, _Gwaiting) + dropg() //解除g和m之间的关系 + + ...... + + schedule() +} + +``` + +park_m首先把当前goroutine的状态设置为_Gwaiting(因为它正在等待其它goroutine往channel里面写数据),然后调用dropg函数解除g和m之间的关系,最后通过调用schedule函数进入调度循环. + +由此看来,park_m 的功能及其简单,仅仅把 G 设置为 _Gwaiting 即可。这个 G 会事先存放到 channel 的 sodoge 结构体中,等待着唤醒。 + +#### dropg + +这个函数专门用于将 M 与 G 的关系剥离: + +``` +func dropg() { + _g_ := getg() + + setMNoWB(&_g_.m.curg.m, nil) + setGNoWB(&_g_.m.curg, nil) +} + +``` + +#### schedule + +仍然去执行 M 的调度函数。 + +### 协程 G 阻塞唤醒 `_Gwaiting => _Grunnable` + +当 G 需要等待的数据已经返回后,runtime 会通过 sysmon 或者其他途径调用 goready 来设置 G 的 runnable 状态,并尝试唤醒其他的 P 来执行。 + +可以看到,编译器把对channel的发送操作翻译成了对runtime.chansend1函数的调用 + +``` +func chansend1(c *hchan, elem unsafe.Pointer) { + chansend(c, elem, true, getcallerpc()) +} + +// runtime/chan.go : 142 +func chansend(c *hchan, ep unsafe.Pointer, block bool, callerpc uintptr) bool { + ...... + if sg := c.recvq.dequeue(); sg != nil { + //可以直接发送数据给sg + send(c, sg, ep, func() { unlock(&c.lock) }, 3) + return true + } + ...... +} + +// runtime/chan.go : 269 +func send(c *hchan, sg *sudog, ep unsafe.Pointer, unlockf func(), skip int) { + ...... + goready(gp, skip+1) +} +``` + +channel发送和读取的流程类似,如果能够立即发送则立即发送并返回,如果不能立即发送则需要阻塞,在我们这个场景中,因为main goroutine此时此刻正挂在channel的读取队列上等待数据,所以这里直接调用send函数发送给main goroutine,send函数则调用goready函数切换到g0栈并调用ready函数来唤醒sg对应的goroutine,即正在等待读channel的main goroutine。 + +#### systemstack(ready) + +``` +func goready(gp *g, traceskip int) { + systemstack(func() { + ready(gp, traceskip, true) + }) +} + +func ready(gp *g, traceskip int, next bool) { + status := readgstatus(gp) + + _g_ := getg() + mp := acquirem() // disable preemption because it can be holding p in a local var + if status&^_Gscan != _Gwaiting { + dumpgstatus(gp) + throw("bad g->status in ready") + } + + + casgstatus(gp, _Gwaiting, _Grunnable) + runqput(_g_.m.p.ptr(), gp, next) + if atomic.Load(&sched.npidle) != 0 && atomic.Load(&sched.nmspinning) == 0 { + //有空闲的p而且没有正在偷取goroutine的工作线程,则需要唤醒p出来工作 + wakep() + } + releasem(mp) +} + +``` + +ready函数首先把需要唤醒的goroutine的状态设置为_Grunnable,然后把其放入运行队列之中等待调度器的调度。 + +如果条件适当,还可以唤醒或者新建 M。 + +#### injectglist + +除此之外,如果 `sysmon` 物理线程后台监控通过 `netpoll` 函数发现网络数据已经准备好之后,还会通过 `injectglist` 批量设置可运行状态: + +``` +func injectglist(glist *gList) { + if glist.empty() { + return + } + + lock(&sched.lock) + var n int + for n = 0; !glist.empty(); n++ { + gp := glist.pop() + casgstatus(gp, _Gwaiting, _Grunnable) + globrunqput(gp) + } + unlock(&sched.lock) + for ; n != 0 && sched.npidle != 0; n-- { + startm(nil, false) + } + *glist = gList{} +} + +``` + +#### wakeup 唤醒 M + +执行 M 的唤醒工作。 + +### G 的主动调度 `_Grunning => _Grunnable` + +#### Gosched + +主动调度完全是用户代码自己控制的,首先从主动调度的入口函数Gosched()开始分析。 + +``` +func Gosched() { + checkTimeouts() //amd64 linux平台空函数 + + //切换到当前m的g0栈执行gosched_m函数 + mcall(gosched_m) + //再次被调度起来则从这里开始继续运行 +} +``` + +#### mcall(gosched_m) => dropg/schedule + +``` +func gosched_m(gp *g) { + goschedImpl(gp) //我们这个场景:gp = g2 +} + +func goschedImpl(gp *g) { + ...... + casgstatus(gp, _Grunning, _Grunnable) + dropg() //设置当前m.curg = nil, gp.m = nil + lock(&sched.lock) + globrunqput(gp) //把gp放入sched的全局运行队列runq + unlock(&sched.lock) + + schedule() //进入新一轮调度 +} +``` + +### G 运行时间长被抢占 `_Grunning => _Grunnable` + +#### preemptone + +在 sysmon 后台线程的监控下,或者 GC 的 STW 影响下,G 会被 runtime 进行被动抢占。 + +通常的做法是调用 preemptone 函数将 g 的 stackguard0 设置为 stackPreempt,这样就可以在 G 调用函数的时候触发 morestack_noctxt 汇编检查,进而调用 newstack 实现抢占 + +``` +func preemptone(_p_ *p) bool { + mp := _p_.m.ptr() + if mp == nil || mp == getg().m { + return false + } + + //gp是被抢占的goroutine + gp := mp.curg + if gp == nil || gp == mp.g0 { + return false + } + + gp.preempt = true + + gp.stackguard0 = stackPreempt + return true +} +``` + +#### 响应抢占请求 + +``` +morestack_noctxt()->morestack()->newstack() + +``` + +因为我们并不知道什么地方会对抢占标志进行处理,从源代码中morestack函数的注释可以知道,该函数会被编译器自动插入到函数序言(prologue)中。我们以下面这个程序为例来做进一步的说明。 + +``` +package main + +import "fmt" + +func sum(a, b int) int { + a2 := a * a + b2 := b * b + c := a2 + b2 + + fmt.Println(c) + + return c +} + +func main() { + sum(1, 2) +} + +``` +为了看清楚编译器会把对morestack函数的调用插入到什么地方,我们用gdb来反汇编一下main函数: + +``` +=> 0x0000000000486a80 <+0>: mov %fs:0xfffffffffffffff8,%rcx + 0x0000000000486a89 <+9>: cmp 0x10(%rcx),%rsp + 0x0000000000486a8d <+13>: jbe 0x486abd + 0x0000000000486a8f <+15>: sub $0x20,%rsp + 0x0000000000486a93 <+19>: mov %rbp,0x18(%rsp) + 0x0000000000486a98 <+24>: lea 0x18(%rsp),%rbp + 0x0000000000486a9d <+29>: movq $0x1,(%rsp) + 0x0000000000486aa5 <+37>: movq $0x2,0x8(%rsp) + 0x0000000000486aae <+46>: callq 0x4869c0 + 0x0000000000486ab3 <+51>: mov 0x18(%rsp),%rbp + 0x0000000000486ab8 <+56>: add $0x20,%rsp + 0x0000000000486abc <+60>: retq + 0x0000000000486abd <+61>: callq 0x44ece0 + 0x0000000000486ac2 <+66>: jmp 0x486a80 + +``` + +#### runtime.morestack_noctxt + +在main函数的尾部我们看到了对runtime.morestack_noctxt函数的调用,往前我们可以看到,对runtime.morestack_noctxt的调用是通过main函数的第三条jbe指令跳转过来的。 + +``` +0x0000000000486a8d <+13>: jbe 0x486abd +...... +0x0000000000486abd <+61>: callq 0x44ece0 + +``` + +jbe是条件跳转指令,它依靠上一条指令的执行结果来判断是否需要跳转。这里的上一条指令是main函数的第二条指令,为了看清楚这里到底在干什么,我们把main函数的前三条指令都列出来 + +``` +0x0000000000486a80 <+0>: mov %fs:0xfffffffffffffff8,%rcx #main函数第一条指令,rcx = g +0x0000000000486a89 <+9>: cmp 0x10(%rcx),%rsp +0x0000000000486a8d <+13>: jbe 0x486abd + +``` + +第二章我们已经介绍过,go语言使用fs寄存器实现系统线程的本地存储(TLS),main函数的第一条指令就是从TLS中读取当前正在运行的g的指针并放入rcx寄存器,第二条指令的源操作数是间接寻址,从内存中读取相对于g偏移16这个地址中的内容到rsp寄存器,我们来看看g偏移16的地址是放的什么东西, + +``` +type g struct { + stack stack + stackguard0 uintptr + stackguard1 uintptr + ...... +} + +type stack struct { + lo uintptr //8 bytes + hi uintptr //8 bytes +} + +``` + +可以看到结构体g的第一个成员stack占16个字节(lo和hi各占8字节),所以g结构体变量的起始位置加偏移16就应该对应到stackguard0字段。因此main函数的第二条指令相当于在比较栈顶寄存器rsp的值是否比stackguard0的值小,如果rsp的值更小,说明当前g的栈要用完了,有溢出风险,需要扩栈,假设main goroutine被设置了抢占标志,那么rsp的值就会远远小于stackguard0,因为从上一节的分析我们知道sysmon监控线程在设置抢占标志时把需要被抢占的goroutine的stackguard0成员设置成了0xfffffffffffffade,而对于goroutine来说其rsp栈顶不可能这么大。因此stackguard0一旦被设置为抢占标记,代码将会跳转到 0x0000000000486abd 处执行call指令调用morestack_noctxt函数,该call指令会把紧跟call后面的一条指令的地址 0x0000000000486ac2 先压入堆栈,然后再跳转到morestack_noctxt函数去执行。 + +![](img/preem.jpg) + +#### morestack——类似 mcall + +morestack_noctxt函数使用JMP指令直接跳转到morestack继续执行,注意这里没有使用CALL指令调用morestack函数,所以rsp栈顶寄存器并没有发生发生变化,与上图一样还是指向存放返回地址的内存处。 + +morestack函数执行的流程类似于前面我们分析过的mcall函数,都是一去不复返的汇编调用。 + +首先保存调用morestack函数的goroutine(我们这个场景是main goroutine)的调度信息到对应的g结构的sched成员之中,然后切换到当前工作线程的g0栈继续执行newstack函数。morestack代码如下,跟mcall一样都是使用go汇编语言编写的,这些代码跟mcall和gogo的代码非常类似,所以这里就不再对其进行详细分析了,读者可以自行参考下面的注释理解 morestack 函数的实现机制。 + +``` +TEXT runtime·morestack_noctxt(SB),NOSPLIT,$0 + MOVL $0, DX + JMP runtime·morestack(SB) + +TEXT runtime·morestack(SB),NOSPLIT,$0-0 + ...... + get_tls(CX) + MOVQ g(CX), SI # SI = g(main goroutine对应的g结构体变量) + ...... + #SP栈顶寄存器现在指向的是morestack_noctxt函数的返回地址, + #所以下面这一条指令执行完成后AX = 0x0000000000486ac2 + MOVQ 0(SP), AX + + #下面两条指令给g.sched.PC和g.sched.g赋值,我们这个例子g.sched.PC被赋值为0x0000000000486ac2, + #也就是执行完morestack_noctxt函数之后应该返回去继续执行指令的地址。 + MOVQ AX, (g_sched+gobuf_pc)(SI) #g.sched.pc = 0x0000000000486ac2 + MOVQ SI, (g_sched+gobuf_g)(SI) #g.sched.g = g + + LEAQ 8(SP), AX #main函数在调用morestack_noctxt之前的rsp寄存器 + + #下面三条指令给g.sched.sp,g.sched.bp和g.sched.ctxt赋值 + MOVQ AX, (g_sched+gobuf_sp)(SI) + MOVQ BP, (g_sched+gobuf_bp)(SI) + MOVQ DX, (g_sched+gobuf_ctxt)(SI) + #上面几条指令把g的现场保存了起来,下面开始切换到g0运行 + + #切换到g0栈,并设置tls的g为g0 + #Call newstack on m->g0's stack. + MOVQ m_g0(BX), BX + MOVQ BX, g(CX) #设置TLS中的g为g0 + #把g0栈的栈顶寄存器的值恢复到CPU的寄存器,达到切换栈的目的,下面这一条指令执行之前, + #CPU还是使用的调用此函数的g的栈,执行之后CPU就开始使用g0的栈了 + MOVQ (g_sched+gobuf_sp)(BX), SP + CALL runtime·newstack(SB) + CALL runtime·abort(SB)// crash if newstack returns + RET +``` + +在切换到g0运行之前,当前goroutine的现场信息被保存到了对应的g结构体变量的sched成员之中.这样我们这个场景中的main goroutine下次被调度起来运行时,调度器就可以把g.sched.sp恢复到CPU的rsp寄存器完成栈的切换,然后把g.sched.PC恢复到rip寄存器,于是CPU继续执行callq morestack_noctxt 后面的 + +``` +0x0000000000486ac2 <+66>: jmp 0x486a80 + +``` + +#### newstack + +接下来我们继续看newstack函数,该函数主要有两个职责,一个是扩栈,另一个是响应sysmon提出的抢占请求. + +``` +func newstack() { + thisg := getg() // thisg = g0 + ...... + // 这行代码获取g0.m.curg,也就是需要扩栈或响应抢占的goroutine + // 对于我们这个例子gp = main goroutine + gp := thisg.m.curg + ...... + + //检查g.stackguard0是否被设置为stackPreempt + preempt := atomic.Loaduintptr(&gp.stackguard0) == stackPreempt + + if preempt { + //检查被抢占goroutine的状态 + if thisg.m.locks != 0 || thisg.m.mallocing != 0 || thisg.m.preemptoff != "" || thisg.m.p.ptr().status != _Prunning { + + //还原stackguard0为正常值,表示我们已经处理过抢占请求了 + gp.stackguard0 = gp.stack.lo + _StackGuard + + //不抢占,调用gogo继续运行当前这个g,不需要调用schedule函数去挑选另一个goroutine + gogo(&gp.sched) // never return + } + } + + //省略的代码做了些其它检查所以这里才有两个同样的判断 + + if preempt { + if gp == thisg.m.g0 { + throw("runtime: preempt g0") + } + if thisg.m.p == 0 && thisg.m.locks == 0 { + throw("runtime: g is running but p is not") + } + ...... + //下面开始响应抢占请求 + // Act like goroutine called runtime.Gosched. + //设置gp的状态,省略的代码在处理gc时把gp的状态修改成了_Gwaiting + casgstatus(gp, _Gwaiting, _Grunning) + + //调用gopreempt_m把gp切换出去 + gopreempt_m(gp) // never return + } + ...... + + casgstatus(gp, _Grunning, _Gcopystack) + + copystack(gp, newsize, true) + + casgstatus(gp, _Gcopystack, _Grunning) + gogo(&gp.sched) +} + +``` + +newstack函数首先检查g.stackguard0是否被设置为stackPreempt,如果是则表示sysmon已经发现我们运行得太久了并对我们发起了抢占请求。在做了一些基本的检查后如果当前goroutine可以被抢占则调用gopreempt_m函数完成调度。 + +``` +func gopreempt_m(gp *g) { + goschedImpl(gp) +} + +``` + +#### 抢占——goschedImpl + +gopreempt_m通过调用goschedImpl函数完成实际的调度切换工作,我们在前面主动调度一节已经详细分析过goschedImpl函数,该函数首先把gp的状态从_Grunning设置成_Grunnable,并通过dropg函数解除当前工作线程m和gp之间的关系,然后把gp放入全局队列等待被调度器调度,最后调用schedule()函数进入新一轮调度。 + +#### 无需抢占——gogo + +如果不需要抢占,那么利用 gogo 函数回到之前的函数,之前的函数就是执行callq morestack_noctxt 后面的 + +``` +0x0000000000486ac2 <+66>: jmp 0x486a80 + +``` + +函数完毕。 + +## M 的状态转移 + +M 的状态可以总结为下面几种: + +- 初始化阶段(idle) +- 自旋中(spinning): M正在从运行队列获取G, 这时候M会拥有一个P; +- 执行go代码中(running): M正在执行go代码, 这时候 M 会拥有一个P; +- 执行原生代码中(syscall): M正在执行原生代码或者阻塞的syscall, 这时M并不拥有P; +- 休眠中(sleeping): M发现无待运行的G时或者进行 STW GC 的时候会进入休眠,并添加到空闲 M 链表中, 这时M并不拥有P。 + +### m0 初始化阶段 `idle` + +m0 在执行 schedinit、newproc 之后,就开始执行 mstart 函数进行调度,试图执行 newproc 创建的 G 中的函数 runtime·main。 + +#### mstart + +mstart 是物理线程的入口函数: + +``` +func mstart() { + _g_ := getg() // 系统启动阶段这个时候仍然还是 g0 + + //对于启动过程来说,g0的stack.lo早已完成初始化,所以onStack = false + osStack := _g_.stack.lo == 0 + if osStack { + size := _g_.stack.hi + if size == 0 { + size = 8192 * sys.StackGuardMultiplier + } + _g_.stack.hi = uintptr(noescape(unsafe.Pointer(&size))) + _g_.stack.lo = _g_.stack.hi - size + 1024 + } + + _g_.stackguard0 = _g_.stack.lo + _StackGuard + _g_.stackguard1 = _g_.stackguard0 + + mstart1() // 由于调用 schedule 不会返回,所以下面的 mexit 在程序运行的时候不会执行。 + + ... + + mexit(osStack) +} + +func mstart1() { + _g_ := getg() + + if _g_ != _g_.m.g0 { + throw("bad runtime·mstart") + } + + //getcallerpc()获取mstart1执行完的返回地址 + //getcallersp()获取调用mstart1时的栈顶地址 + save(getcallerpc(), getcallersp()) + asminit() // 在AMD64 Linux平台中,这个函数什么也没做,是个空函数 + minit() // 与信号相关的初始化,目前不需要关心 + + if _g_.m == &m0 { // 启动时_g_.m是m0,所以会执行下面的mstartm0函数 + mstartm0() // 也是信号相关的初始化,现在我们不关注 + } + + if fn := _g_.m.mstartfn; fn != nil { // 初始化过程中fn == nil + fn() + } + + if _g_.m != &m0 { // m0已经绑定了allp[0],不是m0的话还没有p,所以需要获取一个p + acquirep(_g_.m.nextp.ptr()) + _g_.m.nextp = 0 + } + schedule() // schedule函数永远不会返回 +} + +``` + +mstart1首先调用save函数来保存g0的调度信息,save这一行代码非常重要,是我们理解调度循环的关键点之一。这里首先需要注意的是代码中的getcallerpc()返回的是mstart调用mstart1时被call指令压栈的返回地址,getcallersp()函数返回的是调用mstart1函数之前mstart函数的栈顶地址,其次需要看看save函数到底做了哪些重要工作。 + +#### save 函数保存上下文 + +``` +func save(pc, sp uintptr) { + _g_ := getg() + + _g_.sched.pc = pc + _g_.sched.sp = sp + _g_.sched.lr = 0 + _g_.sched.ret = 0 + _g_.sched.g = guintptr(unsafe.Pointer(_g_)) +} + +``` + +save函数保存了调度相关的所有信息,包括最为重要的当前正在运行的g的下一条指令的地址和栈顶地址,不管是对g0还是其它goroutine来说这些信息在调度过程中都是必不可少的。 + +为什么g0已经执行到mstart1这个函数了而且还会继续调用其它函数,但g0的调度信息中的pc和sp却要设置在mstart函数中?这是因为 g0 的 SP 就在这个时候被固定了,以后 mcall 或者 sysstack 函数跳转到 g0 运行时,都要从这个 SP 栈空间地址开始。 + +继续分析代码,save函数执行完成后,返回到mstart1继续其它跟m相关的一些初始化,完成这些初始化后则调用调度系统的核心函数schedule()完成goroutine的调度,之所以说它是核心,原因在于每次调度goroutine都是从schedule函数开始的。 + + +### M 的调度 `idle => spinning` + +#### schedule + +调度器调度一轮要执行的函数: 寻找一个 runnable 状态的 goroutine,并 execute 它。 + +大体逻辑如下: + +- 调用 runqget 函数来从 P 自己的 runnable G队列中得到一个可以执行的G; +- 如果1)失败,则调用 findrunnable 函数去寻找一个可以执行的G; +- 如果2)也没有得到可以执行的G,那么结束调度,从上次的现场继续执行。 +- 注意)//偶尔会先检查一次全局可运行队列,以确保公平性。否则,两个goroutine可以完全占用本地runqueue。 通过 schedtick计数 %61来保证 + +详细步骤如下: + +- 获取当前调度的g +- 如果当前的 m 已经被绑定到了一个阻塞的 G 上,那么就阻塞当前的 M,直到 G 可以运行。 +- 如果当前GC需要停止整个世界(STW), 那么必定会抢占所有正在运行的 G,因此会触发调度函数 schedule,这时候需要调用 gcstopm 休眠当前的 M +- 如果 M 拥有的 P 中指定了需要在安全点运行的函数(P.runSafePointFn), 则运行它; + + > 所谓的安全点运行的函数是 STW 过程中调用 forEachP 传入的闭包函数,正在运行的 P 代码需要执行这个安全点函数;对于那些 idle 状态的 P,forEachP 会立刻调用检查点函数;对于 syscall 状态的 P,立刻剥离 M,并且立刻执行检查点函数。 + +- 快速获取待运行的 G, 以下处理如果有一个获取成功后面就不会继续获取: + - 如果当前 GC 正在标记阶段, 则查找有没有待运行的 GC Worker, GC Worker也是一个G; + - 为了公平起见, 每61次调度从全局运行队列获取一次G, (一直从本地获取可能导致全局运行队列中的G不被运行); + - 从P的本地运行队列中获取G, 调用 runqget 函数。 +- 快速获取失败时, 调用 findrunnable 函数获取待运行的G, 会阻塞到获取成功为止: +- 成功获取到一个待运行的G; +- 如果 M 处于自旋状态, 调用 resetspinning:取消当前 M 的自旋状态,并且如果当前还有空闲的P, 但是无自旋的M(nmspinning等于0), 则唤醒或新建一个 M 来保障并发度; +- 如果抢到的 G 是用于 GC 工作的,如果这时候有空闲的 P,那么尝试唤醒 M 或新建 M 保障并发度。 +- 如果G要求回到指定的M,调用 startlockedm 函数把G和P交给该M, 自己进入休眠;从休眠唤醒后跳到 schedule 的顶部重试 +- 调用 execute 函数在当前M上执行G。 + + +``` +func schedule() { + _g_ := getg() + + if _g_.m.lockedg != 0 { + stoplockedm() + execute(_g_.m.lockedg.ptr(), false) // Never returns. + } + + ... + +top: + if sched.gcwaiting != 0 { + gcstopm() + goto top + } + + var gp *g + ... + + tryWakeP := false + if gp == nil && gcBlackenEnabled != 0 { + gp = gcController.findRunnableGCWorker(_g_.m.p.ptr()) + tryWakeP = tryWakeP || gp != nil + } + + if gp == nil { + // 每调度几次就检查一下全局的 runq 来确保公平 + // 否则两个 goroutine 就可以通过互相调用 + // 完全占用本地的 runq 了 + if _g_.m.p.ptr().schedtick%61 == 0 && sched.runqsize > 0 { + lock(&sched.lock) + gp = globrunqget(_g_.m.p.ptr(), 1) + unlock(&sched.lock) + } + } + + if gp == nil { + gp, inheritTime = runqget(_g_.m.p.ptr()) + if gp != nil && _g_.m.spinning { + throw("schedule: spinning with local work") + } + } + + if gp == nil { + gp, inheritTime = findrunnable() // blocks until work is available + } + + // 当前线程将要执行 goroutine,并且不会再进入 spinning 状态 + // 所以如果它被标记为 spinning,我们需要 reset 这个状态 + // 可能会重启一个新的 spinning 状态的 M + if _g_.m.spinning { + resetspinning() + } + + if tryWakeP { + if atomic.Load(&sched.npidle) != 0 && atomic.Load(&sched.nmspinning) == 0 { + wakep() + } + } + + if gp.lockedm != 0 { + startlockedm(gp) + goto top + } + + execute(gp, inheritTime) +} + +``` + +#### globrunqget 从全局运行队列中获取 + +从全局运行队列中获取可运行的goroutine是通过globrunqget函数来完成的,该函数的第一个参数是与当前工作线程绑定的p,第二个参数max表示最多可以从全局队列中拿多少个g到当前工作线程的本地运行队列中来。 + +``` +func globrunqget(_p_ *p, max int32) *g { + if sched.runqsize == 0 { //全局运行队列为空 + return nil + } + + //根据p的数量平分全局运行队列中的goroutines + n := sched.runqsize / gomaxprocs + 1 + if n > sched.runqsize { //上面计算n的方法可能导致n大于全局运行队列中的goroutine数量 + n = sched.runqsize + } + if max > 0 && n > max { + n = max //最多取max个goroutine + } + if n > int32(len(_p_.runq)) / 2 { + n = int32(len(_p_.runq)) / 2 //最多只能取本地队列容量的一半 + } + + sched.runqsize -= n + + //直接通过函数返回gp,其它的goroutines通过runqput放入本地运行队列 + gp := sched.runq.pop() //pop从全局运行队列的队列头取 + n-- + for ; n > 0; n-- { + gp1 := sched.runq.pop() //从全局运行队列中取出一个goroutine + runqput(_p_, gp1, false) //放入本地运行队列 + } + return gp +} +``` + +这段代码值得一提的是,计算应该从全局运行队列中拿走多少个goroutine时根据p的数量(gomaxprocs)做了负载均衡。 + +#### runqget 从工作线程本地运行队列中获取 + +工作线程的本地运行队列其实分为两个部分,一部分是由p的runq、runqhead和runqtail这三个成员组成的一个无锁循环队列,该队列最多可包含256个goroutine;另一部分是p的runnext成员,它是一个指向g结构体对象的指针,它最多只包含一个goroutine。 + +这里首先需要注意的是不管是从runnext还是从循环队列中拿取goroutine都使用了cas操作,这里的cas操作是必需的,因为可能有其他工作线程此时此刻也正在访问这两个成员,从这里偷取可运行的goroutine。 + +其次,代码中对runqhead的操作使用了atomic.LoadAcq和atomic.CasRel,它们分别提供了load-acquire和cas-release语义。 + + + +``` +func runqget(_p_ *p) (gp *g, inheritTime bool) { + // If there's a runnext, it's the next G to run. + //从runnext成员中获取goroutine + for { + //查看runnext成员是否为空,不为空则返回该goroutine + next := _p_.runnext + if next == 0 { + break + } + if _p_.runnext.cas(next, 0) { + return next.ptr(), true + } + } + + //从循环队列中获取goroutine + for { + h := atomic.LoadAcq(&_p_.runqhead) // load-acquire, synchronize with other consumers + t := _p_.runqtail + if t == h { + return nil, false + } + gp := _p_.runq[h%uint32(len(_p_.runq))].ptr() + if atomic.CasRel(&_p_.runqhead, h, h+1) { // cas-release, commits consume + return gp, false + } + } +} + +``` + +对于atomic.LoadAcq来说,其语义主要包含如下几条: + +- 原子读取,也就是说不管代码运行在哪种平台,保证在读取过程中不会有其它线程对该变量进行写入; + +- 位于atomic.LoadAcq之后的代码,对内存的读取和写入必须在atomic.LoadAcq读取完成后才能执行,编译器和CPU都不能打乱这个顺序; + +- 当前线程执行atomic.LoadAcq时可以读取到其它线程最近一次通过atomic.CasRel对同一个变量写入的值,与此同时,位于atomic.LoadAcq之后的代码,不管读取哪个内存地址中的值,都可以读取到其它线程中位于atomic.CasRel(对同一个变量操作)之前的代码最近一次对内存的写入。 + +对于atomic.CasRel来说,其语义主要包含如下几条: + +- 原子的执行比较并交换的操作; + +- 位于atomic.CasRel之前的代码,对内存的读取和写入必须在atomic.CasRel对内存的写入之前完成,编译器和CPU都不能打乱这个顺序; + +- 线程执行atomic.CasRel完成后其它线程通过atomic.LoadAcq读取同一个变量可以读到最新的值,与此同时,位于atomic.CasRel之前的代码对内存写入的值,可以被其它线程中位于atomic.LoadAcq(对同一个变量操作)之后的代码读取到。 + +我们可能会问,为什么读取p的runqtail成员不需要使用atomic.LoadAcq或atomic.load?因为这个无锁队列获取成员才会从 head 进行,入队列会从 tail 进行,而入队列的过程必然是当前 P 执行的,当前 P 的执行物理线程就是当前的 M,不存在并发问题,runqtail不会被其它线程修改,只会被当前工作线程修改,此时没有人修改它,所以也就不需要使用原子相关的操作。 + +> +>CAS操作与ABA问题 +> +>比如说一个线程one从内存位置V中取出A,这时候另一个线程two也从内存中取出A,并且two进行了一些操作变成了B,然后two又将V位置的数据变成A,这时候线程one进行CAS操作发现内存中仍然是A,然后one操作成功。尽管线程one的CAS操作成功,但是不代表这个过程就是没有问题的。 +>首先来看对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的条件。 + + +#### findrunnable + +找到一个可执行的 goroutine 来 execute 会尝试从其它的 P 那里偷 g,从全局队列中拿,或者 network 中 poll。 + +如果还是获取不到G, 就需要休眠M了,工作线程在放弃寻找可运行的goroutine而进入睡眠之前,会反复尝试从各个运行队列寻找需要运行的goroutine,可谓是尽心尽力了。 + +盗取过程用了两个嵌套for循环。内层循环实现了盗取逻辑,从代码可以看出盗取的实质就是遍历allp中的所有p,查看其运行队列是否有goroutine,如果有,则取其一半到当前工作线程的运行队列,然后从findrunnable返回,如果没有则继续遍历下一个p。但这里为了保证公平性,遍历allp时并不是固定的从allp[0]即第一个p开始,而是从随机位置上的p开始,而且遍历的顺序也随机化了,并不是现在访问了第i个p下一次就访问第i+1个p,而是使用了一种伪随机的方式遍历allp中的每个p,防止每次遍历时使用同样的顺序访问allp中的元素。 + + +详细步骤: + +- 再次检查当前GC是否在标记阶段, 在则查找有没有待运行的GC Worker, GC Worker也是一个G; +- 再次检查如果当前GC需要停止整个世界, 或者P指定了需要再安全点运行的函数 +- 快速获取待运行的G, 以下处理如果有一个获取成功后面就不会继续获取: + - 再次从P的本地运行队列中获取G, 调用runqget函数。 + - 再次检查全局运行队列中是否有G, 有则获取并返回; + - 非阻塞调用 netpoll 返回 goroutine 链表,用 schedlink 连接 +- 检查现有的正在自旋的 M 个数,超过一半不需要再自旋,并且直接跳到阻塞获取状态。 +- 设置 M 的自旋状态为 true,增加 sched.nmspinning 数量 +- 开始偷取其他 P 上的 G,成功则返回 + +如果上面几个步骤全都没用得到 G,那么开始进行 M 的阻塞获取状态: + +- 再次检查有没有待运行的GC Worker, 有则直接返回 +- 再次检查当前GC需要停止整个世界, 或者P指定了需要再安全点运行的函数,有则返回顶部重试 +- 再次检查全局运行队列中是否有G, 有则获取并返回; + +下面为了防止阻塞状态,开始剥离当前的 P 和 M 的关系,首先先设置 P 为空闲状态: + +- 释放M拥有的P, P会变为空闲(_Pidle)状态;把P添加到"空闲P链表"中; +- 让M离开自旋状态, 减少表示当前自旋中的M的数量的全局变量nmspinning这里的处理非常重要, 因为下面的操作很有可能造成 M 的阻塞与暂停,将 M 放入空闲链表中。 +- 扫描所有的 P,查看本地队列是否存在可运行的 G,由于本 P 已经放到空闲链表中,因此需要尝试绑定到空闲的 P,成功则开启自旋并返回顶部重试。 +- 再次检查有没有待运行的GC Worker, 有则尝试绑定空闲 P,恢复自旋状态并返回阻塞 +- 再次检查网络事件反应器是否有待运行的G, 这里对netpoll的调用会阻塞, 直到某个fd收到了事件; +- 如果最终还是获取不到G, 调用stopm休眠当前的M; +- 唤醒后跳到findrunnable的顶部重试。 + + +``` +func findrunnable() (gp *g, inheritTime bool) { + _g_ := getg() + +top: + _p_ := _g_.m.p.ptr() + if sched.gcwaiting != 0 { + gcstopm() + goto top + } + if _p_.runSafePointFn != 0 { + runSafePointFn() + } + + // 再次看一下本地运行队列是否有需要运行的goroutine + if gp, inheritTime := runqget(_p_); gp != nil { + return gp, inheritTime + } + + // 再看看全局运行队列是否有需要运行的goroutine + if sched.runqsize != 0 { + lock(&sched.lock) + gp := globrunqget(_p_, 0) + unlock(&sched.lock) + if gp != nil { + return gp, false + } + } + + // Poll network. + // netpoll 是我们执行 work-stealing 之前的一个优化 + // 如果没有任何的 netpoll 等待者,或者线程被阻塞在 netpoll 中,我们可以安全地跳过这段逻辑 + // 如果在阻塞的线程中存在任何逻辑上的竞争(e.g. 已经从 netpoll 中返回,但还没有设置 lastpoll) + // 该线程还是会将下面的 netpoll 阻塞住 + if netpollinited() && atomic.Load(&netpollWaiters) > 0 && atomic.Load64(&sched.lastpoll) != 0 { + if list := netpoll(false); !list.empty() { // 非阻塞 + // netpoll 返回 goroutine 链表,用 schedlink 连接 + gp := list.pop() + injectglist(&list) + casgstatus(gp, _Gwaiting, _Grunnable) + if trace.enabled { + traceGoUnpark(gp, 0) + } + return gp, false + } + } + + procs := uint32(gomaxprocs) + // 如果除了当前工作线程还在运行外,其它工作线程已经处于休眠中,那么也就不用去偷了,肯定没有 + if atomic.Load(&sched.npidle) == procs-1 { + // GOMAXPROCS=1 或者除了我们其它的 p 都是 idle + // 新的工作可能从 syscall/cgocall,网络或者定时器中来。 + // 上面这些任务都不会被放到本地的 runq,所有没有可以 stealing 的点 + goto stop + } + + // 如果 正在自旋的 M 的数量 * 2 >= 忙着的 P,那么阻塞 + // 这是为了 + // 当 GOMAXPROCS 远大于 1,但程序的并行度又很低的时候 + // 防止过量的 CPU 消耗 + if !_g_.m.spinning && 2*atomic.Load(&sched.nmspinning) >= procs-atomic.Load(&sched.npidle) { + goto stop + } + + // 启动自旋 + if !_g_.m.spinning { + _g_.m.spinning = true + atomic.Xadd(&sched.nmspinning, 1) + } + + //从其它p的本地运行队列盗取goroutine + for i := 0; i < 4; i++ { + for enum := stealOrder.start(fastrand()); !enum.done(); enum.next() { + stealRunNextG := i > 2 // first look for ready queues with more than 1 g + if gp := runqsteal(_p_, allp[enum.position()], stealRunNextG); gp != nil { + return gp, false + } + } + } + +stop: + + // 没有可以干的事情。如果我们正在 GC 的标记阶段,可以安全地扫描和加深对象的颜色, + // 这样可以进行空闲时间的标记,而不是直接放弃 P + if gcBlackenEnabled != 0 && _p_.gcBgMarkWorker != 0 && gcMarkWorkAvailable(_p_) { + _p_.gcMarkWorkerMode = gcMarkWorkerIdleMode + gp := _p_.gcBgMarkWorker.ptr() + casgstatus(gp, _Gwaiting, _Grunnable) + if trace.enabled { + traceGoUnpark(gp, 0) + } + return gp, false + } + + allpSnapshot := allp + + // return P and block + lock(&sched.lock) + if sched.gcwaiting != 0 || _p_.runSafePointFn != 0 { + unlock(&sched.lock) + goto top + } + + if sched.runqsize != 0 { + gp := globrunqget(_p_, 0) + unlock(&sched.lock) + return gp, false + } + + pidleput(_p_) + unlock(&sched.lock) + + wasSpinning := _g_.m.spinning + if _g_.m.spinning { + //m即将睡眠,状态不再是spinning + _g_.m.spinning = false + if int32(atomic.Xadd(&sched.nmspinning, -1)) < 0 { + throw("findrunnable: negative nmspinning") + } + } + + // // 休眠之前再看一下是否有工作要做 + // 再检查一下所有的 runq + for _, _p_ := range allpSnapshot { + if !runqempty(_p_) { + lock(&sched.lock) + _p_ = pidleget() + unlock(&sched.lock) + if _p_ != nil { + acquirep(_p_) + if wasSpinning { + _g_.m.spinning = true + atomic.Xadd(&sched.nmspinning, 1) + } + goto top + } + break + } + } + + // 再检查 gc 空闲 g + if gcBlackenEnabled != 0 && gcMarkWorkAvailable(nil) { + lock(&sched.lock) + _p_ = pidleget() + if _p_ != nil && _p_.gcBgMarkWorker == 0 { + pidleput(_p_) + _p_ = nil + } + unlock(&sched.lock) + if _p_ != nil { + acquirep(_p_) + if wasSpinning { + _g_.m.spinning = true + atomic.Xadd(&sched.nmspinning, 1) + } + // Go back to idle GC check. + goto stop + } + } + + // poll network + if netpollinited() && atomic.Load(&netpollWaiters) > 0 && atomic.Xchg64(&sched.lastpoll, 0) != 0 { + if _g_.m.p != 0 { + throw("findrunnable: netpoll with p") + } + if _g_.m.spinning { + throw("findrunnable: netpoll with spinning") + } + list := netpoll(true) // 阻塞到返回为止 + atomic.Store64(&sched.lastpoll, uint64(nanotime())) + if !list.empty() { + lock(&sched.lock) + _p_ = pidleget() + unlock(&sched.lock) + if _p_ != nil { + acquirep(_p_) + gp := list.pop() + injectglist(&list) + casgstatus(gp, _Gwaiting, _Grunnable) + + return gp, false + } + injectglist(&list) + } + } + stopm() + goto top +} + +``` + +#### runqsteal + +runqsteal 函数用于在 P2 中偷取一半的 G 任务: + + +``` +// Steal half of elements from local runnable queue of p2 +// and put onto local runnable queue of p. +// Returns one of the stolen elements (or nil if failed). +func runqsteal(_p_, p2 *p, stealRunNextG bool) *g { + t := _p_.runqtail + n := runqgrab(p2, &_p_.runq, t, stealRunNextG) + if n == 0 { + return nil + } + n-- + gp := _p_.runq[(t+n)%uint32(len(_p_.runq))].ptr() + if n == 0 { + return gp + } + h := atomic.LoadAcq(&_p_.runqhead) // load-acquire, synchronize with consumers + if t-h+n >= uint32(len(_p_.runq)) { + throw("runqsteal: runq overflow") + } + atomic.StoreRel(&_p_.runqtail, t+n) // store-release, makes the item available for consumption + return gp +} + +func runqgrab(_p_ *p, batch *[256]guintptr, batchHead uint32, stealRunNextG bool) uint32 { + for { + h := atomic.LoadAcq(&_p_.runqhead) // load-acquire, synchronize with other consumers + t := atomic.LoadAcq(&_p_.runqtail) // load-acquire, synchronize with the producer + n := t - h + n = n - n/2 + if n == 0 { + ... + } + if n > uint32(len(_p_.runq)/2) { // read inconsistent h and t + continue + } + for i := uint32(0); i < n; i++ { + g := _p_.runq[(h+i)%uint32(len(_p_.runq))] + batch[(batchHead+i)%uint32(len(batch))] = g + } + if atomic.CasRel(&_p_.runqhead, h, h+n) { // cas-release, commits consume + return n + } + } +} + +``` + +从计算过程来看n应该是runq队列中goroutine数量的一半,它的最大值不会超过队列容量的一半,但为什么这里的代码却偏偏要去判断n是否大于队列容量的一半呢?这里关键点在于读取runqhead和runqtail是两个操作而非一个原子操作,当我们读取runqhead之后但还未读取runqtail之前,如果有其它线程快速的在增加(这是完全有可能的,其它偷取者从队列中偷取goroutine会增加runqhead,而队列的所有者往队列中添加goroutine会增加runqtail)这两个值,则会导致我们读取出来的runqtail已经远远大于我们之前读取出来放在局部变量h里面的runqhead了,也就是代码注释中所说的h和t已经不一致了,所以这里需要这个if判断来检测异常情况。 + +### M 获取 G 成功开始执行 `spinning => running` + +#### execute + +`execute(gp, inheritTime)` 就会执行 G 的代码。 + +### M 获取 G 失败 `spinning => sleeping` + +#### stopm + +如果工作线程经过多次努力一直找不到需要运行的goroutine则调用stopm进入睡眠状态,等待被其它工作线程唤醒。 + +note是go runtime实现的一次性睡眠和唤醒机制,一个线程可以通过调用notesleep(note)进入睡眠状态,而另外一个线程则可以通过notewakeup(note)把其唤醒。note的底层实现机制跟操作系统相关,不同系统使用不同的机制,比如linux下使用的futex系统调用,而mac下则是使用的pthread_cond_t条件变量,note对这些底层机制做了一个抽象和封装,这种封装给扩展性带来了很大的好处,比如当睡眠和唤醒功能需要支持新平台时,只需要在note层增加对特定平台的支持即可,不需要修改上层的任何代码。 + +回到stopm,当从notesleep函数返回后,需要再次绑定一个p,然后返回到findrunnable函数继续重新寻找可运行的goroutine,一旦找到可运行的goroutine就会返回到schedule函数,并把找到的goroutine调度起来运行。 + +``` + +func stopm() { + _g_ := getg() + + lock(&sched.lock) + mput(_g_.m) //把m结构体对象放入sched.midle空闲队列 + unlock(&sched.lock) + + notesleep(&_g_.m.park) + + noteclear(&_g_.m.park) + + acquirep(_g_.m.nextp.ptr()) + _g_.m.nextp = 0 +} +``` +#### notesleep 睡眠 + +``` +func notesleep(n *note) { + gp := getg() + if gp != gp.m.g0 { + throw("notesleep not on g0") + } + ns := int64(-1) + + //使用循环,保证不是意外被唤醒 + for atomic.Load(key32(&n.key)) == 0 { + gp.m.blocked = true + futexsleep(key32(&n.key), 0, ns) + + gp.m.blocked = false + } +} +``` + +notesleep函数调用futexsleep进入睡眠,这里之所以需要用一个循环,是因为futexsleep有可能意外从睡眠中返回,所以从futexsleep函数返回后还需要检查note.key是否还是0,如果是0则表示并不是其它工作线程唤醒了我们,只是futexsleep意外返回了,需要再次调用futexsleep进入睡眠。 + +这里,futex系统调用为我们提供的功能为如果 uaddr == val 则进入睡眠,否则直接返回。顺便说一下,为什么futex系统调用需要第三个参数val,需要在内核判断 uaddr与val是否相等,而不能在用户态先判断它们是否相等,如果相等才进入内核睡眠岂不是更高效?原因在于判断 uaddr与val是否相等和进入睡眠这两个操作必须是一个原子操作,否则会存在一个竞态条件:如果不是原子操作,则当前线程在第一步判断完 uaddr与val相等之后进入睡眠之前的这一小段时间内,有另外一个线程通过唤醒操作把*uaddr的值修改了,这就会导致当前工作线程永远处于睡眠状态而无人唤醒它。而在用户态无法实现判断与进入睡眠这两步为一个原子操作,所以需要内核来为其实现原子操作。 + +我们知道线程一旦进入睡眠状态就停止了运行,那么如果后来又有可运行的 goroutine 需要工作线程去运行,正在睡眠的线程怎么知道有工作可做了呢? + +从前面的代码我们已经看到,stopm调用notesleep时给它传递的参数是m结构体的park成员,而m又早已通过mput放入了全局的milde空闲队列,这样其它运行着的线程一旦发现有更多的goroutine需要运行时就可以通过全局的m空闲队列找到处于睡眠状态的m,然后调用notewakeup(&m.park)将其唤醒 + +#### futexsleep + +``` +func futexsleep(addr *uint32, val uint32, ns int64) { + var ts timespec + + if ns < 0 { + //调用futex进入睡眠 + futex(unsafe.Pointer(addr), _FUTEX_WAIT_PRIVATE, val, nil, nil, 0) + return + } + + if sys.PtrSize == 8 { + ts.set_sec(ns / 1000000000) + ts.set_nsec(int32(ns % 1000000000)) + } else { + ts.tv_nsec = 0 + ts.set_sec(int64(timediv(ns, 1000000000, (*int32)(unsafe.Pointer(&ts.tv_nsec))))) + } + futex(unsafe.Pointer(addr), _FUTEX_WAIT_PRIVATE, val, unsafe.Pointer(&ts), nil, 0) +} + +``` + +### M 新建与唤醒 `null/sleeping => spinninig` + +#### wakeup 与 startm + +当新建 G 之后,或者 G 重新启动之后,会调用 wakep 函数尝试唤醒睡眠的 M: + +- 首先交换nmspinning到1, 成功再继续, 多个线程同时执行wakep函数只有一个会继续 +- 调用startm函数 + - 调用pidleget从"空闲P链表"获取一个空闲的P + - 如果无法获取空闲的 p,那么直接返回,并且取消 M 的自旋状态 + - 调用mget从"空闲M链表"获取一个空闲的M + - 如果没有空闲的M, 则调用newm新建一个M,如果设置了自旋选项,那么新建的线程的启动函数就是 mspinning + - 如果有空闲的正在睡眠的 M,那么唤醒它。 + +``` +func wakep() { + // be conservative about spinning threads + if !atomic.Cas(&sched.nmspinning, 0, 1) { + return + } + startm(nil, true) +} + +func startm(_p_ *p, spinning bool) { + lock(&sched.lock) + if _p_ == nil { // 没有指定p的话需要从p的空闲队列中获取一个p + _p_ = pidleget() // 从p的空闲队列中获取空闲p + if _p_ == nil { + unlock(&sched.lock) + if spinning { + // spinning为 true 表示进入这个函数之前已经对sched.nmspinning加了1,需要还原 + if int32(atomic.Xadd(&sched.nmspinning, -1)) < 0 { + throw("startm: negative nmspinning") + } + } + return // 没有空闲的p,直接返回 + } + } + + mp := mget() // 从m空闲队列中获取正处于睡眠之中的工作线程,所有处于睡眠状态的m都在此队列中 + unlock(&sched.lock) + if mp == nil { // 没有处于睡眠状态的工作线程 + var fn func() + if spinning { + // The caller incremented nmspinning, so set m.spinning in the new M. + fn = mspinning + } + newm(fn, _p_) // 创建新的工作线程 + return + } + + mp.spinning = spinning + mp.nextp.set(_p_) + notewakeup(&mp.park) +} +``` + +#### notewakeup 唤醒 + +``` +func notewakeup(n *note) { + old := atomic.Xchg(key32(&n.key), 1) + + futexwakeup(key32(&n.key), 1) +} + +``` + +#### newm 创建一个新的 M 物理线程 + +传入的 p 会被赋值给 m 的 nextp 成员,在 m 执行 schedule 时,会将 nextp 拿出来,进行之后真正的绑定操作(其实就是把 nextp 赋值为 nil,并把这个 nextp 赋值给 m.p,把 m 赋值给 p.m)。 + +最终会走到 linux 创建线程的系统调用 clone + +``` +func newm(fn func(), _p_ *p) { + mp := allocm(_p_, fn) + mp.nextp.set(_p_) + mp.sigmask = initSigmask + ... + newm1(mp) +} + +func newm1(mp *m) { + ... + execLock.rlock() // Prevent process clone. + newosproc(mp) + execLock.runlock() +} + +cloneFlags = _CLONE_VM | /* share memory */ + _CLONE_FS | /* share cwd, etc */ + _CLONE_FILES | /* share fd table */ + _CLONE_SIGHAND | /* share sig handler table */ + _CLONE_SYSVSEM | /* share SysV semaphore undo lists (see issue #20763) */ + _CLONE_THREAD /* revisit - okay for now */ + +func newosproc(mp *m) { + stk := unsafe.Pointer(mp.g0.stack.hi) + + var oset sigset + sigprocmask(_SIG_SETMASK, &sigset_all, &oset) + ret := clone(cloneFlags, stk, unsafe.Pointer(mp), unsafe.Pointer(mp.g0), unsafe.Pointer(funcPC(mstart))) + sigprocmask(_SIG_SETMASK, &oset, nil) +} +``` + +runtime·clone 系统调用汇编代码如下: + +``` +TEXT runtime·clone(SB),NOSPLIT,$0 + MOVL flags+0(FP), DI //系统调用的第一个参数 + MOVQ stk+8(FP), SI //系统调用的第二个参数 + MOVQ $0, DX //第三个参数 + MOVQ $0, R10 //第四个参数 + + // Copy mp, gp, fn off parent stack for use by child. + // Careful: Linux system call clobbers CX and R11. + MOVQ mp+16(FP), R8 + MOVQ gp+24(FP), R9 + MOVQ fn+32(FP), R12 + + MOVL $SYS_clone, AX + SYSCALL + +``` + +clone函数首先用了4条指令为clone系统调用准备参数,该系统调用一共需要四个参数,根据Linux系统调用约定,这四个参数需要分别放入rdi, rsi,rdx和r10寄存器中,这里最重要的是第一个参数和第二个参数,分别用来指定内核创建线程时需要的选项和新线程应该使用的栈。因为即将被创建的线程与当前线程共享同一个进程地址空间,所以这里必须为子线程指定其使用的栈,否则父子线程会共享同一个栈从而造成混乱,从上面的newosproc函数可以看出,新线程使用的栈为m.g0.stack.lo~m.g0.stack.hi这段内存,而这段内存是newm函数在创建m结构体对象时从进程的堆上分配而来的。 + +准备好系统调用的参数之后,还有另外一件很重的事情需要做,那就是把clone函数的其它几个参数(mp, gp和线程入口函数)保存到寄存器中,之所以需要在系统调用之前保存这几个参数,原因在于这几个参数目前还位于父线程的栈之中,而一旦通过系统调用把子线程创建出来之后,子线程将会使用我们在clone系统调用时给它指定的栈,所以这里需要把这几个参数先保存到寄存器,等子线程从系统调用返回后直接在寄存器中获取这几个参数。这里要注意的是虽然这个几个参数值保存在了父线程的寄存器之中,但创建子线程时,操作系统内核会把父线程的所有寄存器帮我们复制一份给子线程,所以当子线程开始运行时就能拿到父线程保存在寄存器中的值,从而拿到这几个参数。这些准备工作完成之后代码调用syscall指令进入内核,由内核帮助我们创建系统线程。 + +回到clone函数,下面代码的第一条指令就在判断系统调用的返回值,如果是子线程则跳转到后面的代码继续执行,如果是父线程,它创建子线程的任务已经完成,所以这里把返回值保存在栈上之后就直接执行ret指令返回到newosproc函数了。 + +``` + // In parent, return. + CMPQ AX, $0 #判断clone系统调用的返回值 + JEQ 3(PC) / #跳转到子线程部分 + MOVL AX, ret+40(FP) #父线程需要执行的指令 + RET #父线程需要执行的指令 +``` + +而对于子线程来说,还有很多初始化工作要做,下面是子线程需要继续执行的指令。 + +``` + # In child, on new stack. + #子线程需要继续执行的指令 + MOVQ SI, SP #设置CPU栈顶寄存器指向子线程的栈顶,这条指令看起来是多余的?内核应该已经把SP设置好了 + + # If g or m are nil, skip Go-related setup. + CMPQ R8, $0 # m,新创建的m结构体对象的地址,由父线程保存在R8寄存器中的值被复制到了子线程 + JEQ nog + CMPQ R9, $0 # g,m.g0的地址,由父线程保存在R9寄存器中的值被复制到了子线程 + JEQ nog + + # Initialize m->procid to Linux tid + MOVL $SYS_gettid, AX #通过gettid系统调用获取线程ID(tid) + SYSCALL + MOVQ AX, m_procid(R8) #m.procid = tid + + #Set FS to point at m->tls. + #新线程刚刚创建出来,还未设置线程本地存储,即m结构体对象还未与工作线程关联起来, + #下面的指令负责设置新线程的TLS,把m对象和工作线程关联起来 + LEAQ m_tls(R8), DI #取m.tls字段的地址 + CALL runtime·settls(SB) + + #In child, set up new stack + get_tls(CX) + MOVQ R8, g_m(R9) # g.m = m + MOVQ R9, g(CX) # tls.g = &m.g0 + CALL runtime·stackcheck(SB) + +nog: + # Call fn + CALL R12 #这里调用mstart函数 + ...... + +``` + + +### m0 主线程的执行 `running` + +现在已经从g0切换到了gp这个goroutine,对于我们这个场景来说,gp还是第一次被调度起来运行,它的入口函数是runtime.main,所以接下来CPU就开始执行 runtime.main 函数。 + +runtime.main函数主要工作流程如下: + +- 启动一个sysmon系统监控线程,该线程负责整个程序的gc、抢占调度以及netpoll等功能的监控,在抢占调度一章我们再继续分析sysmon是如何协助完成goroutine的抢占调度的; +- 执行runtime包的初始化; +- 执行main包以及main包import的所有包的初始化; +- 执行main.main函数; +- 从main.main函数返回后调用exit系统调用退出进程; + +``` +func main() { + g := getg() // g = main goroutine,不再是 g0 了 + + ...... + + if sys.PtrSize == 8 { // 64位系统上每个goroutine的栈最大可达1G + maxstacksize = 1000000000 + } else { + maxstacksize = 250000000 + } + + // 初始化未完成的时候,我们必须让第一个 G ,也就是 runtime.main 运行在 m0 上 + // 所以 newproc1 在初始化过程中,即使有空闲的 P 也不能通过 wakeup 来创建新的 M 来执行 runtime.main + // mainStarted 这个参数 true + // 标志着系统已经初始化,调度函数如果发现空闲的 P,可以 wakeup 其他线程来处理 G + mainStarted = true + if GOARCH != "wasm" { + //现在执行的是main goroutine,所以使用的是main goroutine的栈,需要切换到g0栈去执行newm() + systemstack(func() { + //创建监控线程,该线程独立于调度器,不需要跟p关联即可运行 + newm(sysmon, nil) + }) + } + + lockOSThread() + + ...... + + //调用runtime包的初始化函数,由编译器实现 + doInit(&runtime_inittask) // must be before defer + + gcenable() //开启垃圾回收器 + + ...... + + //main 包的初始化函数,也是由编译器实现,会递归的调用我们import进来的包的初始化函数 + main_init_done = make(chan bool) + doInit(&main_inittask) + close(main_init_done) + + ...... + + //调用main.main函数 + fn = main_main // make an indirect call, as the linker doesn't know the address of the main package when laying down the runtime + fn() + + ...... + + //进入系统调用,退出进程,可以看出main goroutine并未返回,而是直接进入系统调用退出进程了 + exit(0) + + //保护性代码,如果exit意外返回,下面的代码也会让该进程crash死掉 + for { + var x *int32 + *x = 0 + } +} + +``` + +从上述流程可以看出,runtime.main执行完main包的main函数之后就直接调用exit系统调用结束进程了,它并没有返回到调用它的函数(还记得是从哪里开始执行的runtime.main吗?),其实runtime.main是main goroutine的入口函数,并不是直接被调用的,而是在schedule()->execute()->gogo()这个调用链的gogo函数中用汇编代码直接跳转过来的,所以从这个角度来说,goroutine确实不应该返回,没有地方可返回啊!可是从前面的分析中我们得知,在创建goroutine的时候已经在其栈上放好了一个返回地址,伪造成goexit函数调用了goroutine的入口函数,这里怎么没有用到这个返回地址啊?其实那是为非main goroutine准备的,非main goroutine执行完成后就会返回到goexit继续执行,而main goroutine执行完成后整个进程就结束了,这是main goroutine与其它goroutine的一个区别。 + +#### lockOSThread G 绑定 M + +G 绑定 M 是通过设定 m.lockedg 来实现的: + +``` +func lockOSThread() { + getg().m.lockedInt++ + dolockOSThread() +} + +func dolockOSThread() { + if GOARCH == "wasm" { + return // no threads on wasm yet + } + _g_ := getg() + _g_.m.lockedg.set(_g_) + _g_.lockedm.set(_g_.m) +} + +``` + +那么在调度的时候,如果发现当前的 g 绑定 M 已经被其他 g2 绑定,那么就要执行 stoplockedm,让当前的 M 暂停执行,让出 P,等待着 G 重新运行。 + +可以看到,stoplockedm 与 stopm 非常相似,最大的不同在于 stopm 会把 M 放回到空闲队列中,而 stoplockedm 直接进入睡眠,等待着 G 来触发 startlockedm 的唤醒 + +startlockedm:当调度过程中最后拿到的 g 已经被绑定到特定的 M 上,那么就需要暂停当前的 M,并负责让出现有的 P,唤醒绑定的 M。 + +``` +func schedule() { + _g_ := getg() + + if _g_.m.lockedg != 0 { + stoplockedm() + execute(_g_.m.lockedg.ptr(), false) // Never returns. + } + + ... + + if gp.lockedm != 0 { + // Hands off own p to the locked m, + // then blocks waiting for a new p. + startlockedm(gp) + goto top + } +} + +func stoplockedm() { + _g_ := getg() + + if _g_.m.p != 0 { + // Schedule another M to run this p. + _p_ := releasep() + handoffp(_p_) + } + + notesleep(&_g_.m.park) + noteclear(&_g_.m.park) + status := readgstatus(_g_.m.lockedg.ptr()) + + if status&^_Gscan != _Grunnable { + print("runtime:stoplockedm: g is not Grunnable or Gscanrunnable\n") + dumpgstatus(_g_) + throw("stoplockedm: not runnable") + } + + acquirep(_g_.m.nextp.ptr()) + _g_.m.nextp = 0 +} + +func startlockedm(gp *g) { + _g_ := getg() + + mp := gp.lockedm.ptr() + if mp == _g_.m { + throw("startlockedm: locked to me") + } + if mp.nextp != 0 { + throw("startlockedm: m has p") + } + + _p_ := releasep() + mp.nextp.set(_p_) + notewakeup(&mp.park) + stopm() +} +``` + +#### Force Gc + +Force Gc 的原理在于 proc 模块的初始化时期,启动了一个协程,这个 forcegc.g 会循环执行,每次完成后休眠,直到被 sysmon 重新返回任务队列。 + +``` +func init() { + go forcegchelper() +} + +func forcegchelper() { + forcegc.g = getg() + for { + lock(&forcegc.lock) + if forcegc.idle != 0 { + throw("forcegc: phase error") + } + atomic.Store(&forcegc.idle, 1) + + // 休眠该 goroutine。 + // park 会暂停 goroutine,但不会放回待运行队列。 + goparkunlock(&forcegc.lock, waitReasonForceGGIdle, traceEvGoBlock, 1) + + // 唤醒后,执行强制垃圾回收。 + gcStart(gcTrigger{kind: gcTriggerTime, now: nanotime()}) + } +} + +``` + +### 协程调度循环 `cycle` + +所谓的调度循环实际上就是一直在执行下图中的 loop: + +``` +schedule()->execute()->gogo()->g2()->goexit()->goexit1()->mcall()->goexit0()->schedule() + +``` + +虽然g2()->goexit()->goexit1()->mcall()这几个函数是在g2的栈空间执行的,但剩下的函数都是在g0的栈空间执行的,那么问题就来了,在一个复杂的程序中,调度可能会进行无数次循环,也就是说会进行无数次没有返回的函数调用,大家都知道,每调用一次函数都会消耗一定的栈空间,而如果一直这样无返回的调用下去无论g0有多少栈空间终究是会耗尽的,那么这里是不是有问题?其实没有问题,关键点就在于,每次执行mcall切换到g0栈时都是切换到g0.sched.sp所指的固定位置,这之所以行得通,正是因为从schedule函数开始之后的一系列函数永远都不会返回,所以重用这些函数上一轮调度时所使用过的栈内存是没有问题的。 + +![](img/cycle.jpg) + + +### sysmon 后台线程的执行 `running => syscall/_Grunnable` + +sysmon 是在 runtime.main 中启动的,不过需要注意的是 sysmon 并不是在 m0 上执行的。因为: + +``` +systemstack(func() { + newm(sysmon, nil) +}) + +``` + +创建了新的 m,但这个 m 又与普通的线程不一样,因为不需要绑定 p 就可以执行。是与整个调度系统脱离的。 + +sysmon 内部是个死循环,第一轮回休眠20us,之后每次休眠时间倍增,最终每一轮都会休眠10ms。主要负责以下几件事情: + +- checkdead,检查是否所有 goroutine 都已经锁死,如果是的话,直接调用 runtime.throw,强制退出。这个操作只在启动的时候做一次 +- 将 netpoll 返回的结果注入到全局 sched 的任务队列 +- 收回因为 syscall 而长时间阻塞的 p,同时抢占那些执行时间过长的 g + +``` +func sysmon() { + lock(&sched.lock) + sched.nmsys++ + checkdead() + unlock(&sched.lock) + + lasttrace := int64(0) + idle := 0 // how many cycles in succession we had not wokeup somebody + delay := uint32(0) + for { + if idle == 0 { // 初始化时 20us sleep + delay = 20 + } else if idle > 50 { // start doubling the sleep after 1ms... + delay *= 2 + } + if delay > 10*1000 { // 最多到 10ms + delay = 10 * 1000 + } + usleep(delay) + ... + + // 如果 10ms 没有 poll 过 network,那么就 netpoll 一次 + lastpoll := int64(atomic.Load64(&sched.lastpoll)) + now := nanotime() + if netpollinited() && lastpoll != 0 && lastpoll+10*1000*1000 < now { + atomic.Cas64(&sched.lastpoll, uint64(lastpoll), uint64(now)) + list := netpoll(false) // 非阻塞 -- 返回一个 goroutine 的列表 + if !list.empty() { + incidlelocked(-1) + injectglist(&list) + incidlelocked(1) + } + } + + // 接收在 syscall 状态阻塞的 P + // 抢占长时间运行的 G + if retake(now) != 0 { + idle = 0 + } else { + idle++ + } + + // 检查是否需要 force GC(两分钟一次的) + if t := (gcTrigger{kind: gcTriggerTime, now: now}); t.test() && atomic.Load(&forcegc.idle) != 0 { + lock(&forcegc.lock) + forcegc.idle = 0 + var list gList + list.push(forcegc.g) + injectglist(&list) + unlock(&forcegc.lock) + } + } +} +``` + +#### retake 抢占 P + +根据retake函数的代码,只要满足下面三个条件中的任意一个就需要对处于_Psyscall 状态的p进行抢占: + +- p的运行队列里面有等待运行的goroutine。这用来保证当前p的本地运行队列中的goroutine得到及时的调度,因为该p对应的工作线程正处于系统调用之中,无法调度队列中goroutine,所以需要寻找另外一个工作线程来接管这个p从而达到调度这些goroutine的目的; +- 没有空闲的p。表示其它所有的p都已经与工作线程绑定且正忙于执行go代码,这说明系统比较繁忙,所以需要抢占当前正处于系统调用之中而实际上系统调用并不需要的这个p并把它分配给其它工作线程去调度其它goroutine。 +- 从上一次监控线程观察到p对应的m处于系统调用之中到现在已经超过10了毫秒。这表示只要系统调用超时,就对其抢占,而不管是否真的有goroutine需要调度,这样保证sysmon线程不至于觉得无事可做(sysmon线程会判断retake函数的返回值,如果为0,表示retake并未做任何抢占,所以会觉得没啥事情做)而休眠太长时间最终会降低sysmon监控的实时性。至于如何计算某一次系统调用时长可以参考上面代码及注释。 + +- 枚举所有的P: + - 如果P在系统调用中(_Psyscall) + - 符合条件则调用handoffp解除M和P之间的关联 + - 如果P在运行中(_Prunning), 且经过了一次sysmon循环并且G运行时间超过forcePreemptNS(10ms), 则抢占这个P + - 调用preemptone函数 + - 设置g.preempt = true;设置g.stackguard0 = stackPreempt + + +``` +func retake(now int64) uint32 { + n := 0 + + for i := 0; i < len(allp); i++ { // 遍历所有的P + _p_ := allp[i] + + pd := &_p_.sysmontick // _p_.sysmontick用于sysmon线程记录被监控p的系统调用时间和运行时间 + s := _p_.status + sysretake := false + if s == _Prunning || s == _Psyscall { + // 对于 _Psyscall 阶段,如果 sysmon 的 tick 不一致,那么就更新它 + // 是否进行 handoffp 还得看是否有等待运行的 G + t := int64(_p_.schedtick) + if int64(pd.schedtick) != t { + pd.schedtick = uint32(t) + pd.schedwhen = now + } else if pd.schedwhen+forcePreemptNS <= now { + // _p_.schedtick 每次进行调度都会递增 + // 因此如果 _p_.schedtick == pd.schedtick + // 那么只能说明这一个 sysmon 的 tick(至少 20us) 这个 P 都没有进行调度,我们应该抢占 + // 否则就跳过这个 p + // G 运行时间过长就抢占它 + preemptone(_p_) + // 如果处于 syscall, preemptone() 并不会起作用 + sysretake = true + } + } + + if s == _Psyscall { + // 原理同上 + t := int64(_p_.syscalltick) + if !sysretake && int64(pd.syscalltick) != t { + pd.syscalltick = uint32(t) + pd.syscallwhen = now + continue + } + + // 只要满足下面三个条件中的任意一个,则抢占该p,否则不抢占 + // 1. p的运行队列里面有等待运行的goroutine + // 2. 没有无所事事的p + // 3. 从上一次监控线程观察到p对应的m处于系统调用之中到现在已经不超过10了毫秒 + if runqempty(_p_) && atomic.Load(&sched.nmspinning)+atomic.Load(&sched.npidle) > 0 && pd.syscallwhen+10*1000*1000 > now { + continue + } + + // 将 P 和 M 剥离,不允许属于系统调用的 M 过长占据 cpu 资源 + if atomic.Cas(&_p_.status, s, _Pidle) { + n++ + _p_.syscalltick++ + handoffp(_p_) + } + incidlelocked(1) + lock(&allpLock) + } + } + + unlock(&allpLock) + return uint32(n) +} + +``` + +#### preemptone + +发现运行过长时间的 G,那么抢占它。 + +#### handoffp 剥离 P 与 M + +从handoffp的代码可以看出,在如下几种情况下则需要调用我们已经分析过的startm函数启动新的工作线程出来接管_p_: + +- _p_的本地运行队列或全局运行队列里面有待运行的goroutine; +- 需要帮助gc完成标记工作; +- 系统比较忙,所有其它_p_都在运行goroutine,需要帮忙; +- 所有其它P都已经处于空闲状态,如果需要监控网络连接读写事件,则需要启动新的m来poll网络连接。 + +``` +func handoffp(_p_ *p) { + + // 运行队列不为空,需要启动m来接管 + if !runqempty(_p_) || sched.runqsize != 0 { + startm(_p_, false) + return + } + + //有垃圾回收工作需要做,也需要启动m来接管 + if gcBlackenEnabled != 0 && gcMarkWorkAvailable(_p_) { + startm(_p_, false) + return + } + + //所有其它p都在运行goroutine,说明系统比较忙,需要启动m + if atomic.Load(&sched.nmspinning)+atomic.Load(&sched.npidle) == 0 && atomic.Cas(&sched.nmspinning, 0, 1) + { // TODO: fast atomic + startm(_p_, true) + return + } + + if sched.gcwaiting != 0 { //如果gc正在等待Stop The World + _p_.status = _Pgcstop + sched.stopwait-- + if sched.stopwait == 0 { + notewakeup(&sched.stopnote) + } + unlock(&sched.lock) + return + } + + if sched.runqsize != 0 { //全局运行队列有工作要做 + unlock(&sched.lock) + startm(_p_, false) + return + } + + //不能让所有的p都空闲下来,因为需要监控网络连接读写事件 + if sched.npidle == uint32(gomaxprocs-1) && atomic.Load64(&sched.lastpoll) != 0 { + unlock(&sched.lock) + startm(_p_, false) + return + } + pidleput(_p_) //无事可做,把p放入全局空闲队列 + unlock(&sched.lock) +} + +``` + +### 系统调用返回 `syscall => running/sleeping` + +对正在进行系统调用的goroutine的抢占实质上是剥夺与其对应的工作线程所绑定的p,虽然说处于系统调用之中的工作线程并不需要p,但一旦从操作系统内核返回到用户空间之后就必须绑定一个p才能运行go代码,那么,工作线程从系统调用返回之后如果发现它进入系统调用之前所使用的p被监控线程拿走了,该怎么办呢?接下来我们就来分析这个问题。 + +为了搞清楚工作线程从系统调用返回之后需要做哪些事情,我们需要找到相关的代码,怎么找代码呢?这里我们通过对一个使用了系统调用的程序的调试来寻找。 + +``` +package main + +import ( + "fmt" + "os" +) + +func main() { + fd, err := os.Open("./syscall.go") //一定会执行系统调用 + if err != nil { + fmt.Println(err) + } + + fd.Close() +} + +``` + +使用gdb跟踪调试上面这个程序可以发现,main函数调用的os.Open函数最终会调用到Syscall6函数,因为中间调用过程与我们分析目标没关系,所以我们直接从Syscall6函数开始分析。 + +``` +TEXT ·Syscall6(SB), NOSPLIT, $0-80 + CALL runtime·entersyscall(SB) + + #按照linux系统约定复制参数到寄存器并调用syscall指令进入内核 + MOVQ a1+8(FP), DI + MOVQ a2+16(FP), SI + MOVQ a3+24(FP), DX + MOVQ a4+32(FP), R10 + MOVQ a5+40(FP), R8 + MOVQ a6+48(FP), R9 + MOVQ trap+0(FP), AX#syscall entry,系统调用编号放入AX + SYSCALL #进入内核 + + #从内核返回,判断返回值,linux使用 -1 ~ -4095 作为错误码 + CMPQ AX, $0xfffffffffffff001 + JLS ok6 + + #系统调用返回错误,为Syscall6函数准备返回值 + MOVQ $-1, r1+56(FP) + MOVQ $0, r2+64(FP) + NEGQ AX + MOVQ AX, err+72(FP) + CALL runtime·exitsyscall(SB) + RET +ok6: #系统调用返回错误 + MOVQ AX, r1+56(FP) + MOVQ DX, r2+64(FP) + MOVQ $0, err+72(FP) + CALL runtime·exitsyscall(SB) + RET + +``` + +Syscall6函数主要依次干了如下3件事: + +- 调用runtime.entersyscall函数; +- 使用SYSCALL指令进入系统调用; +- 调用runtime.exitsyscall函数。 + +根据前面的分析和这段代码我们可以猜测,exitsyscall函数将会处理当前工作线程进入系统调用之前所拥有的p被监控线程抢占剥夺的情况。但这里怎么会有个entersyscall呢,它是干啥的?我们先来看看。 + +#### entersyscall函数 + +``` +func entersyscall() { + reentersyscall(getcallerpc(), getcallersp()) +} + +func reentersyscall(pc, sp uintptr) { + _g_ := getg() //执行系统调用的goroutine + + // Disable preemption because during this function g is in Gsyscall status, + // but can have inconsistent g->sched, do not let GC observe it. + _g_.m.locks++ + + // Entersyscall must not call any function that might split/grow the stack. + // (See details in comment above.) + // Catch calls that might, by replacing the stack guard with something that + // will trip any stack check and leaving a flag to tell newstack to die. + _g_.stackguard0 = stackPreempt + _g_.throwsplit = true + + // Leave SP around for GC and traceback. + save(pc, sp) //save函数分析过,用来保存g的现场信息,rsp, rbp, rip等等 + _g_.syscallsp = sp + _g_.syscallpc = pc + casgstatus(_g_, _Grunning, _Gsyscall) + ...... + _g_.m.syscalltick = _g_.m.p.ptr().syscalltick + _g_.sysblocktraced = true + _g_.m.mcache = nil + pp := _g_.m.p.ptr() + pp.m = 0 //p解除与m之间的绑定 + _g_.m.oldp.set(pp) //把p记录在oldp中,等从系统调用返回时,优先绑定这个p + _g_.m.p = 0 //m解除与p之间的绑定 + atomic.Store(&pp.status, _Psyscall) //修改当前p的状态,sysmon线程依赖状态实施抢占 + ..... + _g_.m.locks-- +} + +``` + +entersyscall函数直接调用了reentersyscall函数,reentersyscall首先把现场信息保存在当前g的sched成员中,然后解除m和p的绑定关系并设置p的状态为_Psyscall,前面我们已经看到sysmon监控线程需要依赖该状态实施抢占。 + +这里有几个问题需要澄清一下: + +- 有sysmon监控线程来抢占剥夺,为什么这里还需要主动解除m和p之间的绑定关系呢?原因主要在于这里主动解除m和p的绑定关系之后,sysmon线程就不需要通过加锁或cas操作来修改m.p成员从而解除m和p之间的关系; + +- 为什么要记录工作线程进入系统调用之前所绑定的p呢?因为记录下来可以让工作线程从系统调用返回之后快速找到一个可能可用的p,而不需要加锁从sched的pidle全局队列中去寻找空闲的p。 + +- 为什么要把进入系统调用之前所绑定的p搬到m的oldp中,而不是直接使用m的p成员?笔者第一次看到这里也有疑惑,于是翻看了github上的提交记录,从代码作者的提交注释来看,这里主要是从保持m的p成员清晰的语义方面考虑的,因为处于系统调用的m事实上并没有绑定p,所以如果记录在p成员中,p的语义并不够清晰明了。 + +看完进入系统调用之前调用的entersyscall函数后,我们再来看系统调用返回之后需要调用的exitsyscall函数。 + +#### exitsyscall函数 + +``` +func exitsyscall() { + _g_ := getg() + ...... + oldp := _g_.m.oldp.ptr() //进入系统调用之前所绑定的p + _g_.m.oldp = 0 + if exitsyscallfast(oldp) {//因为在进入系统调用之前已经解除了m和p之间的绑定,所以现在需要绑定p + //绑定成功,设置一些状态 + ...... + + // There's a cpu for us, so we can run. + _g_.m.p.ptr().syscalltick++ //系统调用完成,增加syscalltick计数,sysmon线程依靠它判断是否是同一次系统调用 + // We need to cas the status and scan before resuming... + //casgstatus函数会处理一些垃圾回收相关的事情,我们只需知道该函数重新把g设置成_Grunning状态即可 + casgstatus(_g_, _Gsyscall, _Grunning) + ...... + return + } + ...... + _g_.m.locks-- + + // Call the scheduler. + //没有绑定到p,调用mcall切换到g0栈执行exitsyscall0函数 + mcall(exitsyscall0) + ...... +} +``` + +因为在进入系统调用之前,工作线程调用entersyscall解除了m和p之间的绑定,现在已经从系统调用返回需要重新绑定一个p才能继续运行go代码,所以exitsyscall函数首先就调用exitsyscallfast去尝试绑定一个空闲的p,如果绑定成功则结束exitsyscall函数按函数调用链原路返回去执行其它用户代码,否则则调用mcall函数切换到g0栈执行exitsyscall0函数。下面先来看exitsyscallfast如何尝试绑定一个p,然后再去分析exitsyscall0函数。 + +#### exitsyscallfast 尝试绑定 P,成功立刻返回 + +exitsyscallfast首先尝试绑定进入系统调用之前所使用的p,如果绑定失败就需要调用exitsyscallfast_pidle去获取空闲的p来绑定。 + +``` +func exitsyscallfast(oldp *p) bool { + _g_ := getg() + ...... + // Try to re-acquire the last P. + //尝试快速绑定进入系统调用之前所使用的p + if oldp != nil && oldp.status == _Psyscall && atomic.Cas(&oldp.status, _Psyscall, _Pidle) { + //使用cas操作获取到p的使用权,所以之后的代码不需要使用锁就可以直接操作p + // There's a cpu for us, so we can run. + wirep(oldp) //绑定p + exitsyscallfast_reacquired() + return true + } + + // Try to get any other idle P. + if sched.pidle != 0 { + var ok bool + systemstack(func() { + ok = exitsyscallfast_pidle() //从全局队列中寻找空闲的p,需要加锁,比较慢 + ...... + }) + if ok { + return true + } + } + return false +} + +``` + +exitsyscallfast首先尝试快速绑定进入系统调用之前所使用的p,因为该p的状态目前还是_Psyscall,监控线程此时可能也正好准备操作这个p的状态,所以这里需要使用cas原子操作来修改状态,保证只有一个线程的cas能够成功,一旦cas操作成功,就表示当前线程获取到了p的使用权,这样当前线程的后续代码就可以直接操作该p了。具体到exitsyscallfast函数,一旦我们拿到p的使用权,就调用wirep把工作线程m和p关联起来,完成绑定工作。所谓的绑定其实就是设置m的p成员指向p和p的m成员指向m。 + +``` +func wirep(_p_ *p) { + _g_ := getg() + ...... + //相互赋值,绑定m和p + _g_.m.mcache = _p_.mcache + _g_.m.p.set(_p_) + _p_.m.set(_g_.m) + _p_.status = _Prunning +} + +``` + +exitsyscallfast函数如果绑定进入系统调用之前所使用的p失败,则调用exitsyscallfast_pidle从p的全局空闲队列中获取一个p出来绑定,注意这里使用了systemstack(func())函数来调用exitsyscallfast_pidle,systemstack(func())函数有一个func()类型的参数,该函数首先会把栈切换到g0栈,然后调用通过参数传递进来的函数(这里是一个闭包,包含了对exitsyscallfast_pidle函数的调用),最后再切换回原来的栈并返回,为什么这些代码需要在系统栈也就是g0的栈上执行呢?原则上来说,只要调用链上某个函数有nosplit这个编译器指示就需要在g0栈上去执行,因为有nosplit指示的话编译器就不会插入检查溢出的代码,这样在非g0栈上执行这些nosplit函数就有可能导致栈溢出,g0栈其实就是操作系统线程所使用的栈,它的空间比较大,不需要对runtime代码中的每个函数都做栈溢出检查,否则会严重影响效率。 + +为什么绑定进入系统调用之前所使用的p会失败呢?原因就在于这个p可能被sysmon监控线程拿走并绑定到其它工作线程,这部分内容我们已经在前面分析过了。 + +现在继续看exitsyscallfast_pidle函数,从代码可以看到从全局空闲队列获取p需要加锁,如果锁冲突比较严重的话,这个过程就很慢了,这也是为什么exitsyscallfast函数首先会去尝试绑定之前使用的p的原因。 + +``` +func exitsyscallfast_pidle() bool { + lock(&sched.lock) + _p_ := pidleget()//从全局空闲队列中获取p + if _p_ != nil && atomic.Load(&sched.sysmonwait) != 0 { + atomic.Store(&sched.sysmonwait, 0) + notewakeup(&sched.sysmonnote) + } + unlock(&sched.lock) + if _p_ != nil { + acquirep(_p_) + return true + } + return false +} + +``` + +#### 绑定失败 mcall(exitsyscall0) => dropg/stopm + +回到exitsyscall函数,如果exitsyscallfast绑定p失败,则调用mcall执行exitsyscall0函数,mcall我们已经见到过多次,所以这里只分析exitsyscall0函数。 + +``` +func exitsyscall0(gp *g) { + _g_ := getg() + + casgstatus(gp, _Gsyscall, _Grunnable) + + //当前工作线程没有绑定到p,所以需要解除m和g的关系 + dropg() + lock(&sched.lock) + var _p_ *p + if schedEnabled(_g_) { + _p_ = pidleget() //再次尝试获取空闲的p + } + if _p_ == nil { //还是没有空闲的p + globrunqput(gp) //把g放入全局运行队列 + } else if atomic.Load(&sched.sysmonwait) != 0 { + atomic.Store(&sched.sysmonwait, 0) + notewakeup(&sched.sysmonnote) + } + unlock(&sched.lock) + if _p_ != nil {//获取到了p + acquirep(_p_) //绑定p + //继续运行g + execute(gp, false) // Never returns. + } + if _g_.m.lockedg != 0 { + // Wait until another thread schedules gp and so m again. + stoplockedm() + execute(gp, false) // Never returns. + } + stopm() //当前工作线程进入睡眠,等待被其它线程唤醒 + + //从睡眠中被其它线程唤醒,执行schedule调度循环重新开始工作 + schedule() // Never returns. +} +``` + +因为工作线程没有绑定p是不能运行goroutine的,所以这里会再次尝试从全局空闲队列找一个p出来绑定,找到了就通过execute函数继续执行当前这个goroutine,如果找不到则把当前goroutine放入全局运行队列,由其它工作线程负责把它调度起来运行,自己则调用stopm函数进入睡眠状态。execute和stopm函数我们已经分析过,所以这里就不再重复。 + +至此,我们已经分析完工作线程从系统调用返回需要做到, diff --git "a/Go \345\215\217\347\250\213\350\260\203\345\272\246\342\200\224\342\200\224\345\237\272\346\234\254\345\216\237\347\220\206\344\270\216\345\210\235\345\247\213\345\214\226.md" "b/Go \345\215\217\347\250\213\350\260\203\345\272\246\342\200\224\342\200\224\345\237\272\346\234\254\345\216\237\347\220\206\344\270\216\345\210\235\345\247\213\345\214\226.md" new file mode 100644 index 0000000..4b2f437 --- /dev/null +++ "b/Go \345\215\217\347\250\213\350\260\203\345\272\246\342\200\224\342\200\224\345\237\272\346\234\254\345\216\237\347\220\206\344\270\216\345\210\235\345\247\213\345\214\226.md" @@ -0,0 +1,994 @@ +# Go 协程调度——基本原理与初始化 + + +[TOC] + + +## 调度原理 + +### 协程的意义 + +goroutine是Go语言实现的用户态线程,主要用来解决操作系统线程太“重”的问题,所谓的太重,主要表现在以下两个方面: + +- 创建和切换太重:操作系统线程的创建和切换都需要进入内核,而进入内核所消耗的性能代价比较高,开销较大; + +- 内存使用太重:一方面,为了尽量避免极端情况下操作系统线程栈的溢出,内核在创建操作系统线程时默认会为其分配一个较大的栈内存(虚拟地址空间,内核并不会一开始就分配这么多的物理内存),然而在绝大多数情况下,系统线程远远用不了这么多内存,这导致了浪费;另一方面,栈内存空间一旦创建和初始化完成之后其大小就不能再有变化,这决定了在某些特殊场景下系统线程栈还是有溢出的风险。 + +相对的,用户态的goroutine则轻量得多: + +- goroutine是用户态线程,其创建和切换都在用户代码中完成而无需进入操作系统内核,所以其开销要远远小于系统线程的创建和切换; +- goroutine启动时默认栈大小只有2k,这在多数情况下已经够用了,即使不够用,goroutine的栈也会自动扩大,同时,如果栈太大了过于浪费它还能自动收缩,这样既没有栈溢出的风险,也不会造成栈内存空间的大量浪费。 + +### 基本工作原理 + +goroutine建立在操作系统线程基础之上,它与操作系统线程之间实现了一个多对多(M:N)的两级线程模型。 + +这里的 M:N 是指M个goroutine运行在N个操作系统线程之上,内核负责对这N个操作系统线程进行调度,而这N个系统线程又负责对这M个goroutine进行调度和运行。 + +所谓的对goroutine的调度,是指程序代码按照一定的算法在适当的时候挑选出合适的goroutine并放到CPU上去运行的过程,这些负责对goroutine进行调度的程序代码我们称之为goroutine调度器。 + +用极度简化了的伪代码来描述goroutine调度器的工作流程大概是下面这个样子: + +``` +// 程序启动时的初始化代码 +...... +for i := 0; i < N; i++ { // 创建N个操作系统线程执行schedule函数 + create_os_thread(schedule) // 创建一个操作系统线程执行schedule函数 +} + +//schedule函数实现调度逻辑 +func schedule() { + for { //调度循环 + // 根据某种算法从M个goroutine中找出一个需要运行的goroutine + g := find_a_runnable_goroutine_from_M_goroutines() + run_g(g) // CPU运行该goroutine,直到需要调度其它goroutine才返回 + save_status_of_g(g) // 保存goroutine的状态,主要是寄存器的值 + } +} + +``` + +这段伪代码表达的意思是,程序运行起来之后创建了N个由内核调度的操作系统线程(为了方便描述,我们称这些系统线程为工作线程)去执行shedule函数,而schedule函数在一个调度循环中反复从M个goroutine中挑选出一个需要运行的goroutine并跳转到该goroutine去运行,直到需要调度其它goroutine时才返回到schedule函数中通过save_status_of_g保存刚刚正在运行的goroutine的状态然后再次去寻找下一个goroutine。 + +### PMG 协程并发模型 + +Go语言中支撑整个scheduler实现的主要有4个重要结构,分别是M、G、P、Sched。 + +- Sched结构就是调度器,它维护有存储M和G的队列以及调度器的一些状态信息等。 +- M结构是Machine,系统线程,它由操作系统管理的,goroutine就是跑在M之上的;M是一个很大的结构,里面维护小对象内存cache(mcache)、当前执行的goroutine、随机数发生器等等非常多的信息。 +- P结构是Processor,处理器,它的主要用途就是用来执行goroutine的,它维护了一个goroutine队列,即runqueue。Processor是让我们从N:1调度到M:N调度的重要部分。 +- G是goroutine实现的核心结构,它包含了栈,指令指针,以及其他对调度goroutine很重要的信息,例如其阻塞的channel。 + +> +>Processor 数量是在启动时被设置为环境变量GOMAXPROCS的值,或者通过运行时调用函数GOMAXPROCS()进行设置。Processor数量固定意味着任意时刻只有GOMAXPROCS个线程在运行go代码。 +> + +> +>M:Machine的简称,在linux平台上是用clone系统调用创建的,其与用linux pthread库创建出来的线程本质上是一样的,都是利用系统调用创建出来的OS线程实体。M的作用就是执行G中包装的并发任务。Go运行时系统中的调度器的主要职责就是将G公平合理的安排到多个M上去执行。 +> + +当正在运行的goroutine阻塞的时候,例如进行系统调用,会再创建一个系统线程(M1),当前的M线程放弃了它的Processor,P转到新的线程中去运行。这样就填补了这个进入系统调用的M的空缺,始终保证 有GOMAXPROCS个工作线程在干活了。 + +总结起来: + +在 Go 进程启动之后,Go 会尝试建立若干个 M,也就是若干个物理线程,接着: + +- 每个物理线程在建立之后,都要进入调度函数,一个M调度goroutine执行的过程是一个loop。 +- M会从P的local queue弹出一个Runable状态的goroutine来执行,如果P的local queue为空,就会执行work stealing;如果实在找不到就会自动去睡眠。 + +我们通过 go func()来创建一个goroutine; + +- 有两个存储goroutine的队列,一个是局部调度器P的local queue、一个是全局调度器数据模型schedt的global queue。 +- 新创建的goroutine会先保存在local queue,如果local queue已经满了就会保存在全局的global queue; +- 创建 G 之后,发现有闲置的 P 就会尝试唤醒物理线程。 + +这个时候,G 的创建就结束了。 + +- M 从睡眠状态被唤醒之后,就要绑定一个 P。一个 M 必须持有一个P,M 与 P 是1:1的关系。绑定失败还是要回去睡觉。绑定成功了就会回到调度函数,继续尝试获取一个可运行的 G。 +- 一切完美,直接运行 G 指定的代码。 +- 当 G 执行了非阻塞调用或者网络调用之后,调度程序会将 G 保存上下文并切出 M,M 会运行下一个 runable 的 G +- 当 G 获得了想要的数据后,sysmon 线程会将 G 放入队列当中,等待着调度运行。 +- 当 M 执行某一个 goroutine 时候如果发生了 syscall 或则其余阻塞操作。这种操作并不像非阻塞调用一样可以暂停 G,因为 M 物理线程大概率已经沉入内核,没有办法运行下一个 G,这个系统调用只能占用一个物理线程。但是这个时候 M 实际上可能只是等待内核的 IO 数据等等,并不会占用 CPU。 +- 这时候,sysmon 线程会检测到 M 已经阻塞,如果当前有一些 G 在执行,runtime 会把这个线程 M 从 P 中摘除(detach),然后再创建一个新的操作系统的线程(如果有空闲的线程可用就复用空闲线程),尝试让操作系统调度这个新的物理线程来占用这个 CPU,保障并发度; +- 当系统调用结束时候,这个 M 会尝试获取一个空闲的 P 执行。如果获取不到 P,那么这个线程 M 会 park 它自己(休眠),加入到空闲线程中,然后这个 goroutine 会被放入 schedt 的global queue。 +- 当 G 运行结束之后,M 再次回到调度程序,并尝试获取新的 G,无法获取那就接着去睡眠,等待着新的 G 来唤醒它。 + +简单的说,调度的本质是不断的监控各个线程的运行状况,如果发现某个线程已经阻塞了,那么就要唤醒一个已有的线程或者新建一个线程,尝试让操作系统调度这个物理线程跑满所有的 CPU。 + +### Go1.0 的调度 + +在 1.0 前的代码中,线程与M是直接对应的关系,这个解耦还是不够。 + +Go1.0中将M抽出来成为了一个结构体,startm函数是线 程的入口地址,而goroutine的入口地址是go表达式中的那个函数。总体上跟上面的结构差不多,进出系统调用的时候 goroutine会跟M一起进入到系统调用中,schedule中会匹配g和m,让空闲的m来运行g。如果检测到干活的数量少于 GOMAXPROCS并且没有空闲着的m,则会创建新的m来运行g。出系统调用的时候,如果已经有GOMAXPROCS个m在干 活了,则这个出系统调用的m会被挂起,它的g也会被挂到待运行的goroutine队列中。 + +在Go语言中m是machine的缩写,也就是机器的抽象。它被设计成了可以运行所有的G。比如说一个g开始在某个m上运行, 经过几次进出系统调用之后,可能运行它的m挂起了,其它的m会将它从队列中取出并继续运行。 + +每次调度都会涉及对g和m等队列的操作,这些全局的数据在多线程情况下使用就会涉及到大量的锁操作。在频繁的系统调用 中这将是一个很大的开销。为了减少系统调用开销,Go1.0在这里做了一些优化的。1.0版中,在它的Sched结构体中有一个 atomic字段,类型是一个volatile的无符32位整型。 + +具体地看: + +1. 单个全局锁(Sched.Lock)用来保护所有的goroutine相关的操作(创建,完成,调度等)。 +2. 不同的G在不同的M上并发运行时可能都需向系统申请资源(如堆内存),由于资源是全局的,将会由于资源竞争造成很多系统性能损耗, +3. 内存缓存MCache是每个M的。而当M阻塞后,相应的内存资源也被一起拿走了。 + +### Go1.1 的调度 + +Go1.1相对于1.0一个重要的改动就是重新调用了调度器。前面已经看到,老版本中的调度器实现是存在一些问题的。解决方 式是引入Processor的概念,并在Processors之上实现工作流窃取的调度器。 + +M代表OS线程。P代表Go代码执行时需要的资源。当M执行Go代码时,它需要关联一个P,当M为idle或者在系统调用中时, 它也需要P。有刚好GOMAXPROCS个P。所有的P被组织为一个数组,工作流窃取需要这个条件。GOMAXPROCS的改变涉 及到stop/start the world来resize数组P的大小。 + +gfree和grunnable从sched中移到P中。这样就解决了前面的单个全局锁保护用有goroutine的问题,由于goroutine现在被分到 每个P中,它们是P局部的goroutine,因此P只管去操作自己的goroutine就行了,不会与其它P上的goroutine冲突。全局的 grunnable队列也仍然是存在的,只有在P去访问全局grunnable队列时才涉及到加锁操作。mcache从M中移到P中。不过当前 还不彻底,在M中还是保留着mcache域的。 + +加入了P后,sched.atomic也从Sched结构体中去掉了。 + +当一个新的G创建或者现有的G变成runnable,它将一个runnable的goroutine推到当前的P。当P完成执行G,它将G从自己的 runnable goroutine中pop出去。如果链为空,P会随机从其它P中窃取一半的可运行的goroutine。 +当M创建一个新G的时候,必须保证有另一个M来执行这个G。类似的,当一个M进入到系统调用时,必须保证有另一个M来 执行G的代码。 + +2层自旋:关联了P的处于idle状态的的M自旋寻找新的G;没有关联P的M自旋等待可用的P。最多有GOMAXPROCS个自旋 的M。只要有第二类M时第一类M就不会阻塞。 + +### 抢占式调度 + + +Go在设计之初并没考虑将goroutine设计成抢占式的。用户负责让各个goroutine交互合作完成任务。一个goroutine只有在涉 及到加锁,读写通道或者主动让出CPU等操作时才会触发切换。 + +垃圾回收器是需要stop the world的。如果垃圾回收器想要运行了,那么它必须先通知其它的goroutine合作停下来,这会造成 较长时间的等待时间。考虑一种很极端的情况,所有的goroutine都停下来了,只有其中一个没有停,那么垃圾回收就会一直 等待着没有停的那一个。 +抢占式调度可以解决这种问题,在抢占式情况下,如果一个goroutine运行时间过长,它就会被剥夺运行权。 + +引入抢占式调度,会对最初的设计产生比较大的影响,Go还只是引入了一些很初级的抢占,并没有像操作系统调度那么复 杂,没有对goroutine分时间片,设置优先级等。 + +只有长时间阻塞于系统调用,或者运行了较长时间才会被抢占。runtime会在后台有一个检测线程,它会检测这些情况,并通 知goroutine执行调度。 + +目前并没有直接在后台的检测线程中做处理调度器相关逻辑,只是相当于给goroutine加了一个“标记”,然后在它进入函数时 才会触发调度。这么做应该是出于对现有代码的修改最小的考虑。 + +#### sysmon + +前面讲Go程序的初始化过程中有提到过,runtime开了一条后台线程,运行一个sysmon函数。这个函数会周期性地做epoll操 作,同时它还会检测每个P是否运行了较长时间。 + +如果检测到某个P状态处于Psyscall超过了一个sysmon的时间周期(20us),并且还有其它可运行的任务,则切换P。 + +如果检测到某个P的状态为Prunning,并且它已经运行了超过10ms,则会将P的当前的G的stackguard设置为 StackPreempt。这个操作其实是相当于加上一个标记,通知这个G在合适时机进行调度。 + +#### morestack 的修改 + +前面说的,将stackguard设置为StackPreempt实际上是一个比较trick的代码。我们知道Go会在每个函数入口处比较当前的栈 寄存器值和stackguard值来决定是否触发morestack函数。 + +将stackguard设置为StackPreempt作用是进入函数时必定触发morestack,然后在morestack中再引发调度。 + +所以,到目前为止Go的抢占式调度还是很初级的,比如一个goroutine运行了很久,但是它并没有调用另一个函数,则它不会 被抢占。当然,一个运行很久却不调用函数的代码并不是多数情况。 + +## 基本数据结构 + +### 结构体G + +系统线程对goroutine的调度与内核对系统线程的调度原理是一样的,实质都是通过保存和修改CPU寄存器的值来达到切换线程/goroutine的目的。 + +因此,为了实现对goroutine的调度,需要引入一个数据结构来保存CPU寄存器的值以及goroutine的其它一些状态信息,在Go语言调度器源代码中,这个数据结构是一个名叫g的结构体,它保存了goroutine的所有信息,该结构体的每一个实例对象都代表了一个goroutine,调度器代码可以通过g对象来对goroutine进行调度,当goroutine被调离CPU时,调度器代码负责把CPU寄存器的值保存在g对象的成员变量之中,当goroutine被调度起来运行时,调度器代码又负责把g对象的成员变量所保存的寄存器的值恢复到CPU的寄存器。 + + + +runtime在接到这样一个调用后,会先检查一下go函数及其参数的合法性,紧接着会试图从局部调度器P的自由G队列中(或者全局调度器的自由G队列)中获取一个可用的自由G。如果没有则新创建一个G。类似M和P,G在运行时系统中也有全局的G列表【runtime.allg】,那些新建的G会先放到这个全局的G列表中,其列表的作用也是集中放置了当前运行时系统中给所有的G的指针。在用自由G封装go的函数时,运行时系统都会对这个G重新做一次初始化。 + + + +``` +// stack 描述的是 Go 的执行栈,下界和上界分别为 [lo, hi] +// 如果从传统内存布局的角度来讲,Go 的栈实际上是分配在 C 语言中的堆区的 +// 所以才能比 ulimit -s 的 stack size 还要大(1GB) +type stack struct { + lo uintptr // 栈顶,指向内存低地址 + hi uintptr // 栈底,指向内存高地址 +} + +// g 的运行现场 +type gobuf struct { + sp uintptr // sp 寄存器 + pc uintptr // pc 寄存器 + g guintptr // g 指针,记录当前这个gobuf对象属于哪个goroutine + + // 保存系统调用的返回值,因为从系统调用返回之后如果p被其它工作线程抢占, + // 则这个goroutine会被放入全局运行队列被其它工作线程调度,其它线程需要知道系统调用的返回值。 + ret sys.Uintreg +} + + +type g struct { + // 简单数据结构,lo 和 hi 成员描述了栈的下界和上界内存地址 + stack stack + // 在函数的栈增长 prologue 中用 sp 寄存器和 stackguard0 来做比较 + // 如果 sp 比 stackguard0 小(因为栈向低地址方向增长),那么就触发栈拷贝和调度 + // 正常情况下 stackguard0 = stack.lo + StackGuard + // 不过 stackguard0 在需要进行调度时,会被修改为 StackPreempt + // 以触发抢占s + stackguard0 uintptr + // stackguard1 是在 C 栈增长 prologue 作对比的对象 + // 在 g0 和 gsignal 栈上,其值为 stack.lo+StackGuard + // 在其它的栈上这个值是 ~0(按 0 取反)以触发 morestack 调用(并 crash) + stackguard1 uintptr + + sched gobuf // goroutine 的现场,g的调度数据, 当g中断时会保存当前的pc和rsp等值到这里, 恢复运行时会使用这里的值 + + m *m // 当前与 g 绑定的 m + lockedm *m // g是否要求要回到这个M执行, 有的时候g中断了恢复会要求使用原来的M执行 + + gopc uintptr // 创建该 goroutine 的语句的指令地址 + startpc uintptr // goroutine 函数的指令地址 + + preempt bool // 抢占标记,这个为 true 时,stackguard0 是等于 stackpreempt 的 + + waitsince int64 // g 被阻塞之后的近似时间 + waitreason string // if status==Gwaiting + schedlink guintptr +} + +``` + +当 g 遇到阻塞,或需要等待的场景时,会被打包成 sudog 这样一个结构。一个 g 可能被打包为多个 sudog 分别挂在不同的等待队列上: + +``` +// sudog 代表在等待列表里的 g,比如向 channel 发送/接收内容时 +// 之所以需要 sudog 是因为 g 和同步对象之间的关系是多对多的 +// 一个 g 可能会在多个等待队列中,所以一个 g 可能被打包为多个 sudog +// 多个 g 也可以等待在同一个同步对象上 +// 因此对于一个同步对象就会有很多 sudog 了 +// sudog 是从一个特殊的池中进行分配的。用 acquireSudog 和 releaseSudog 来分配和释放 sudog +type sudog struct { + + // 之后的这些字段都是被该 g 所挂在的 channel 中的 hchan.lock 来保护的 + // shrinkstack depends on + // this for sudogs involved in channel ops. + g *g + + // isSelect 表示一个 g 是否正在参与 select 操作 + // 所以 g.selectDone 必须用 CAS 来操作,以胜出唤醒的竞争 + isSelect bool + next *sudog + prev *sudog + elem unsafe.Pointer // data element (may point to stack) + + // 下面这些字段则永远都不会被并发访问 + // 对于 channel 来说,waitlink 只会被 g 访问 + // 对于信号量来说,所有的字段,包括上面的那些字段都只在持有 semaRoot 锁时才可以访问 + acquiretime int64 + releasetime int64 + ticket uint32 + parent *sudog // semaRoot binary tree + waitlink *sudog // g.waiting list or semaRoot + waittail *sudog // semaRoot + c *hchan // channel +} + +``` + +G的各种状态如下: + +- Gidle:G被创建但还未完全被初始化。 +- Grunnable:当前G为可运行的,正在等待被运行。 +- Grunning:当前G正在被运行。 +- Gsyscall:当前G正在被系统调用 +- Gwaiting:当前G正在因某个原因而等待 +- Gdead:当前G完成了运行 + +G退出系统调用的过程非常复杂:runtime先会尝试获取空闲局部调度器P并直接运行当前G,如果没有就会把当前G转成Grunnable状态并放置入全局调度器的global queue。 + +最后,已经是Gdead状态的G是可以被重新初始化并使用的(从自由G队列取出来重新初始化使用)。而对比进入Pdead状态的P等待的命运只有被销毁。处于Gdead的G会被放置到本地P或者调度器的自由G列表中。 + +### 结构体 M + +Go调度器源代码中有一个用来代表工作线程的m结构体,每个工作线程都有唯一的一个m结构体的实例对象与之对应,m结构体对象除了记录着工作线程的诸如栈的起止位置、当前正在执行的goroutine以及是否空闲等等状态信息之外,还通过指针维持着与p结构体的实例对象之间的绑定关系。 + +这里也是截取结构体M中的部分域。和G类似,M中也有alllink域将所有的M放在allm链表中。lockedg是某些情况下,G锁定 在这个M中运行而不会切换到其它M中去。M中还有一个MCache,是当前M的内存的缓存。M也和G一样有一个常驻寄存器 变量,代表当前的M。同时存在多个M,表示同时存在多个物理线程。 + +结构体M中有两个G是需要关注一下的,一个是curg,代表结构体M当前绑定的结构体G。另一个是g0,是带有调度栈的 goroutine,这是一个比较特殊的goroutine。普通的goroutine的栈是在堆上分配的可增长的栈,而g0的栈是M对应的线程的 栈。所有调度相关的代码,会先切换到该goroutine的栈中再执行。 + +``` +type m struct { + g0 *g // 用来执行调度指令的 goroutine + curg *g // 当前运行的用户 goroutine + lockedg guintptr // 表示与当前M锁定的那个G。运行时系统会把 一个M 和一个G锁定,一旦锁定就只能双方相互作用,不接受第三者。 + + p puintptr // 指向当前与M关联的那个P。 + nextp puintptr // 用于暂存于当前M有潜在关联的P。 (预联)当M重新启动时,即用预联的这个P做关联 + + mcache *mcache // 本地缓存,实际上是 P,只是在 M 多加一份 + + alllink *m // 连接到所有的m链表的一个指针。 + freelink *m // on sched.freem + + spinning bool // m 失业了,正在积极寻找工作~表示当前M是否正在寻找G。 + blocked bool // m 正阻塞在 note 上 + park note // 没有goroutine需要运行时,工作线程睡眠在这个park成员上,,其它线程通过这个park唤醒该工作线程 + + // 通过TLS实现m结构体对象与工作线程之间的绑定 + tls [6]uintptr // thread-local storage (for x86 extern register) + mstartfn func() +} + +``` + +M也没有专门字段来维护状态,简单来说有一下几种状态: + +- 自旋中(spinning): M正在从运行队列获取G, 这时候M会拥有一个P; +- 执行go代码中: M正在执行go代码, 这时候M会拥有一个P; +- 执行原生代码中: M正在执行原生代码或者阻塞的syscall, 这时M并不拥有P; +- 休眠中: M发现无待运行的G时或者进行 STW GC 的时候会进入休眠,并添加到空闲 M 链表中, 这时M并不拥有P。 + +M本身是无状态的。M是否是空闲态仅以它是否存在于调度器的空闲M列表 【runtime.sched.midle】 中为依据。 + +runtime管辖的M会在GC任务执行的时候被停止,这时候系统会对M的属性做某些必要的重置并把M放置入全局调度器的空闲M列表。具体的调用逻辑可以查看上一篇 GC 文章。 + +单个Go程序所使用的M的最大数量是可以被设置的。在我们使用命令运行Go程序时候,有一个引导程序先会被启动的。在这个引导程序中会为Go程序的运行建立必要的环境。引导程序对M的数量进行初始化设置,默认最大值是10000 + +### 结构体P + +P是一个抽象的概念,并不代表一个具体的实体,抽象地表示M运行G所需要的资源。P并不代表CPU核心数,而是表示执行go代码的并发度。有一点需要注意的是,执行原生代码的时候并不受P数量的限制。同一时间只有一个线程(M)可以拥有P, P中的数据都是锁自由(lock free)的, 读写这些数据的效率会非常的高。 + +因为全局运行队列是每个工作线程都可以读写的,因此访问它需要加锁,然而在一个繁忙的系统中,加锁会导致严重的性能问题。于是,调度器又为每个工作线程引入了一个私有的局部goroutine运行队列,工作线程优先使用自己的局部运行队列,只有必要时才会去访问全局运行队列,这大大减少了锁冲突,提高了工作线程的并发性。在Go调度器源代码中,局部运行队列被包含在p结构体的实例对象之中,每一个运行着go代码的工作线程都会与一个p结构体的实例对象关联在一起。 + +MCache被移到了P中,但是在结构体M中也还保留着。在P中有一个 Grunnable的goroutine队列,这是一个P的局部队列。当P执行Go代码时,它会优先从自己的这个局部队列中取,这时可以不 用加锁,提高了并发度。如果发现这个队列空了,则去其它P的队列中拿一半过来,这样实现工作流窃取的调度。这种情况 下是需要给调用器加锁的。 + +``` +type p struct { + lock mutex + + m muintptr // 和相关联的 m 的反向指针,如果 p 是 idle 的话,那这个指针是 nil + + // runnable 状态的 goroutine。访问时是不加锁的 + runqhead uint32 + runqtail uint32 + runq [256]guintptr + + // runnext 非空时,代表的是一个 runnable 状态的 G, + // 这个 G 是被 当前 G 修改为 ready 状态的, + // 并且相比在 runq 中的 G 有更高的优先级 + // 如果当前 G 的还有剩余的可用时间,那么就应该运行这个 G + // 运行之后,该 G 会继承当前 G 的剩余时间 + runnext guintptr + + // G 结构体可复用的对象池 + gFree struct { + gList + n int32 + } + + mcache *mcache + + status uint32 // one of pidle/prunning/... + link puintptr // P 的链表,链接这下一个 P + + schedtick uint32 // 每次调用 schedule 时会加一 + syscalltick uint32 // 每次系统调用时加一 + sysmontick sysmontick // 上次 sysmon 观察到的 tick 时间 + + ... +} +``` + +和M不同,P是有状态机的(五种): + +- Pidel:当前P未和任何M关联 +- Prunning:当前P已经和某个M关联,M在执行某个G +- Psyscall:当前P中的被运行的那个G正在进行系统调用 +- Pgcstop:runtime正在进行GC(runtime会在gc时试图把全局P列表中的P都处于此种状态) +- Pdead:当前P已经不再被使用(在调用runtime.GOMAXPROCS减少P的数量时,多余的P就处于此状态) + +在对P初始化的时候就是Pgcstop的状态,但是这个状态保持时间很短,在初始化并填充P中的G队列之后,runtime会将其状态置为Pidle并放入调度器的空闲P列表【runtime.schedt.pidle】中 + +在runtime进行GC的时候,P都会被指定成Pgcstop。在GC结束后状态不会回复到GC前的状态,而是都统一直接转到了Pidle 【这意味着,他们都需要被重新调度】。 + +每个P中都有一个可运行G队列及自由G队列。自由G队列包含了很多已经完成的G,随着被运行完成的G的积攒到一定程度后,runtime会把其中的部分G转移到全局调度器的自由G队列 【runtime.sched.gfree】中。 + +当我们每次用 go关键字启用一个G的时候,首先都是尽可能复用已经执行完的G。具体过程如下:运行时系统都会先从P的自由G队列获取一个G来封装我们提供的函数 (go 关键字后面的函数) ,如果发现P中的自由G过少时,会从调度器的自由G队列中移一些G过来,只有连调度器的自由G列表都弹尽粮绝的时候,才会去创建新的G。 + + +### Sched + +要实现对goroutine的调度,仅仅有g结构体对象是不够的,至少还需要一个存放所有(可运行)goroutine的容器,便于工作线程寻找需要被调度起来运行的goroutine,于是Go调度器又引入了schedt结构体,一方面用来保存调度器自身的状态信息,另一方面它还拥有一个用来保存goroutine的运行队列。因为每个Go程序只有一个调度器,所以在每个Go程序中schedt结构体只有一个实例对象,该实例对象在源代码中被定义成了一个共享的全局变量,这样每个工作线程都可以访问它以及它所拥有的goroutine运行队列,我们称这个运行队列为全局运行队列。 + + + +``` +type schedt struct { + // 当修改 nmidle,nmidlelocked,nmsys,nmfreed 这些数值时 + // 需要记得调用 checkdead + + midle muintptr // idle m's waiting for work + nmidle int32 // 当前等待工作的空闲 m 计数 + nmidlelocked int32 // 当前等待工作的被 lock 的 m 计数 + mnext int64 // 当前预缴创建的 m 数,并且该值会作为下一个创建的 m 的 ID + maxmcount int32 // 允许创建的最大的 m 数量 + + ngsys uint32 // number of system goroutines; updated atomically + + pidle puintptr // 空闲 p's + npidle uint32 + nmspinning uint32 // See "Worker thread parking/unparking" comment in proc.go. + + // 全局的可运行 g 队列 + runqhead guintptr + runqtail guintptr + runqsize int32 + + // 被设置了 m.exited 标记之后的 m,这些 m 正在 freem 这个链表上等待被 free + // 链表用 m.freelink 字段进行链接 + freem *m +} + +``` + +### 重要的全局变量 + +``` +allgs []*g // 保存所有的g +allm *m // 所有的m构成的一个链表,包括下面的m0 +allp []*p // 保存所有的p,len(allp) == gomaxprocs + +ncpu int32 // 系统中cpu核的数量,程序启动时由runtime代码初始化 +gomaxprocs int32 // p的最大值,默认等于ncpu,但可以通过GOMAXPROCS修改 + +sched schedt // 调度器结构体对象,记录了调度器的工作状态 + +m0 m // 代表进程的主线程 +g0 g // m0的g0,也就是m0.g0 = &g0 + +``` + +### g/p/m 的关系 + +Go 实现了所谓的 M:N 模型,执行用户代码的 goroutine 可以认为都是对等的 goroutine。不考虑 g0 和 gsignal 的话,我们可以简单地认为调度就是将 m 绑定到 p,然后在 m 中不断循环执行调度函数(runtime.schedule),寻找可用的 g 来执行。 + +![](img/PMG3.png) + +## 主进程 m0 与调度初始化 + +### 程序入口 + +任何一个由编译型语言(不管是C,C++,go还是汇编语言)所编写的程序在被操作系统加载起来运行时都会顺序经过如下几个阶段: + +- 从磁盘上把可执行程序读入内存; +- 创建进程和主线程; +- 为主线程分配栈空间; +- 把由用户在命令行输入的参数拷贝到主线程的栈; +- 把主线程放入操作系统的运行队列等待被调度执起来运行 + +在主线程第一次被调度起来执行第一条指令之前,主线程的函数栈如下图所示: + +![](img/stackmap.png) + +在Linux命令行用 go build 编译hello.go,得到可执行程序hello,然后使用gdb调试,在gdb中我们首先使用 info files 命令找到程序入口(Entry point)地址为0x452270,然后用 b *0x452270 在0x452270地址处下个断点,gdb告诉我们这个入口对应的源代码为 runtime/rt0_linux_amd64.s 文件的第8行。 + +``` +bobo@ubuntu:~/study/go$ go build hello.go +bobo@ubuntu:~/study/go$ gdb hello +GNU gdb (GDB) 8.0.1 +(gdb) info files +Symbols from "/home/bobo/study/go/main". +Local exec file: +`/home/bobo/study/go/main', file type elf64-x86-64. +Entry point: 0x452270 +0x0000000000401000 - 0x0000000000486aac is .text +0x0000000000487000 - 0x00000000004d1a73 is .rodata +0x00000000004d1c20 - 0x00000000004d27f0 is .typelink +0x00000000004d27f0 - 0x00000000004d2838 is .itablink +0x00000000004d2838 - 0x00000000004d2838 is .gosymtab +0x00000000004d2840 - 0x00000000005426d9 is .gopclntab +0x0000000000543000 - 0x000000000054fa9c is .noptrdata +0x000000000054faa0 - 0x0000000000556790 is .data +0x00000000005567a0 - 0x0000000000571ef0 is .bss +0x0000000000571f00 - 0x0000000000574658 is .noptrbss +0x0000000000400f9c - 0x0000000000401000 is .note.go.buildid +(gdb) b *0x452270 +Breakpoint 1 at 0x452270: file /usr/local/go/src/runtime/rt0_linux_amd64.s, line 8. + +``` + +找到 runtime/rt0_linx_amd64.s 文件,该文件是用go汇编语言编写而成的源代码文件: + +``` +runtime/rt0_linx_amd64.s : 8 + +TEXT _rt0_amd64_linux(SB),NOSPLIT,$-8 + JMP _rt0_amd64(SB) + +``` + +上面第一行代码定义了_rt0_amd64_linux这个符号,并不是真正的CPU指令,第二行的JMP指令才是主线程的第一条指令,这条指令简单的跳转到(相当于go语言或c中的goto)_rt0_amd64 这个符号处继续执行,_rt0_amd64 这个符号的定义在runtime/asm_amd64.s 文件中: + +``` +runtime/asm_amd64.s : 14 + +TEXT _rt0_amd64(SB),NOSPLIT,$-8 + MOVQ 0(SP), DI// argc + LEAQ 8(SP), SI // argv + JMP runtime·rt0_go(SB) + +``` + +rt0_go函数完成了go程序启动时的所有初始化工作,因此这个函数比较长,也比较繁杂,但这里我们只关注与调度器相关的一些初始化,基本步骤是: + +- 调整 SP 寄存器的值,同时调整 argc、argv 参数的位置 +- 初始化 g0 栈空间 +- 初始化 m0 的 TLS 地址 +- 绑定 m0 与 g0 +- 初始化 m0 +- 初始化 allp + +### 调整 SP 位置 + +下面我们分段来看: + + +``` +TEXT runtime·rt0_go(SB),NOSPLIT,$0 + // copy arguments forward on an even stack + MOVQ DI, AX// AX = argc + MOVQ SI, BX// BX = argv + SUBQ $(4*8+7), SP// 2args 2auto + ANDQ $~15, SP //调整栈顶寄存器使其按16字节对齐 + MOVQ AX, 16(SP) //argc放在SP + 16字节处 + MOVQ BX, 24(SP) //argv放在SP + 24字节处 + +``` + +面的第4条指令用于调整栈顶寄存器的值使其按16字节对齐,也就是让栈顶寄存器SP指向的内存的地址为16的倍数,之所以要按16字节对齐,是因为CPU有一组SSE指令(与并行算法 SIMD 相关),这些指令中出现的内存地址必须是16的倍数,最后两条指令把argc和argv搬到新的位置。 + +### 初始化 g0 栈空间 + +继续看后面的代码,下面开始初始化全局变量g0,前面我们说过,g0的主要作用是提供一个栈供runtime代码执行,因此这里主要对g0的几个与栈有关的成员进行了初始化,从这里可以看出g0的栈大约有64K,地址范围为 SP - 64*1024 + 104 ~ SP。 + +``` +//下面这段代码从系统线程的栈空分出一部分当作g0的栈,然后初始化g0的栈信息和stackgard +MOVQ $runtime·g0(SB), DI //g0的地址放入DI寄存器 +LEAQ (-64*1024+104)(SP), BX //BX = SP - 64*1024 + 104 +MOVQ BX, g_stackguard0(DI) //g0.stackguard0 = SP - 64*1024 + 104 +MOVQ BX, g_stackguard1(DI) //g0.stackguard1 = SP - 64*1024 + 104 +MOVQ BX, (g_stack+stack_lo)(DI) //g0.stack.lo = SP - 64*1024 + 104 +MOVQ SP, (g_stack+stack_hi)(DI) //g0.stack.hi = SP + +``` + +运行完上面这几行指令后g0与栈之间的关系如下图所示: + +![](img/stackmap1.png) + +### 初始化 m0 TLS 地址 + +所谓的初始化 m0,最关键的就是将 m0.tls[1] 的地址赋给 FS 寄存器,FS 寄存器所指向的地址会被认为是 TLS 数据,操作系统在调度物理线程的时候会自动保存恢复 FS 寄存器的值。 + +这段代码首先调用 settls 函数初始化主线程的线程本地存储(TLS),目的是把 m0 与主线程关联在一起。设置了线程本地存储之后接下来的几条指令在于验证TLS功能是否正常,如果不正常则直接abort退出程序。 + +``` +//下面开始初始化tls(thread local storage,线程本地存储) +LEAQ runtime·m0+m_tls(SB), DI //DI = &m0.tls,取m0的tls成员的地址到DI寄存器 +CALL runtime·settls(SB) //调用settls设置线程本地存储,settls函数的参数在DI寄存器中 + +//验证settls是否可以正常工作,如果有问题则abort退出程序 +get_tls(BX) //获取fs段基地址并放入BX寄存器,其实就是m0.tls[1]的地址,get_tls的代码由编译器生成 +MOVQ $0x123, g(BX) //把整型常量0x123拷贝到fs段基地址偏移-8的内存位置,也就是m0.tls[0] = 0x123 +MOVQ runtime·m0+m_tls(SB), AX //AX = m0.tls[0] +CMPQ AX, $0x123 //检查m0.tls[0]的值是否是通过线程本地存储存入的0x123来验证tls功能是否正常 +JEQ 2(PC) +CALL runtime·abort(SB) //如果线程本地存储不能正常工作,退出程序 + +``` + +下面我们详细来详细看一下 settls 函数是如何实现线程私有全局变量的。 + +``` +// set tls base to DI +TEXT runtime·settls(SB),NOSPLIT,$32 +//...... +//DI寄存器中存放的是m.tls[0]的地址,m的tls成员是一个数组,读者如果忘记了可以回头看一下m结构体的定义 +//下面这一句代码把DI寄存器中的地址加8,为什么要+8呢,主要跟ELF可执行文件格式中的TLS实现的机制有关 +//执行下面这句指令之后DI寄存器中的存放的就是m.tls[1]的地址了 +ADDQ $8, DI // ELF wants to use -8(FS) + +//下面通过arch_prctl系统调用设置FS段基址 +MOVQ DI, SI //SI 存放arch_prctl系统调用的第二个参数,FS 寄存器的值 +MOVQ $0x1002, DI // arch_prctl的第一个参数:ARCH_SET_FS,说明需要设置 FS 寄存器的值。 +MOVQ$ SYS_arch_prctl, AX //系统调用编号 +SYSCALL + +CMPQ AX, $0xfffffffffffff001 +JLS2(PC) +MOVL$0xf1, 0xf1 // crash //系统调用失败直接crash +RET + +``` + +从代码可以看到,这里通过arch_prctl系统调用把m0.tls[1]的地址设置成了fs段的段基址。CPU中有个叫fs的段寄存器与之对应,而每个线程都有自己的一组CPU寄存器值,操作系统在把线程调离CPU运行时会帮我们把所有寄存器中的值保存在内存中,调度线程起来运行时又会从内存中把这些寄存器的值恢复到CPU,这样,在此之后,工作线程代码就可以通过fs寄存器来找到m.tls,可以参考上面初始化tls之后对tls功能验证的代码来理解这一过程。 + +### 绑定 m0 与 g0 + +代码首先把 g0 的地址放入主线程的线程本地存储中,然后通过 `m0.g0 = &g0;g0.m = &m0` 把 m0 和 g0 绑定在一起. + +``` +get_tls(BX) //获取fs段基址到BX寄存器 +LEAQ runtime·g0(SB), CX //CX = g0的地址 +MOVQ CX, g(BX) //把g0的地址保存在线程本地存储里面,也就是m0.tls[0]=&g0 +LEAQ runtime·m0(SB), AX //AX = m0的地址 + +//把m0和g0关联起来m0->g0 = g0,g0->m = m0 +MOVQCX, m_g0(AX) //m0.g0 = g0 +MOVQAX, g_m(CX) //g0.m = m0 + +``` + +这样,之后在主线程中通过get_tls可以获取到g0,通过g0的m成员又可以找到m0,于是这里就实现了m0和g0与主线程之间的关联。 + +从这里还可以看到,保存在主线程本地存储中的值是g0的地址,也就是说工作线程的私有全局变量其实是一个指向g的指针而不是指向m的指针,目前这个指针指向g0,表示代码正运行在g0栈。 + +### 初始化 m0 + +下面代码开始处理命令行参数,这部分我们不关心,所以跳过。命令行参数处理完成后调用osinit函数获取CPU核的数量并保存在全局变量ncpu之中,调度器初始化时需要知道当前系统有多少个CPU核。 + +``` +//准备调用args函数,前面四条指令把参数放在栈上 +MOVL 16(SP), AX // AX = argc +MOVL AX, 0(SP) // argc放在栈顶 +MOVQ 24(SP), AX // AX = argv +MOVQ AX, 8(SP) // argv放在SP + 8的位置 +CALL runtime·args(SB) //处理操作系统传递过来的参数和env,不需要关心 + +//对于linx来说,osinit唯一功能就是获取CPU的核数并放在global变量ncpu中, +//调度器初始化时需要知道当前系统有多少CPU核 +CALL runtime·osinit(SB) //执行的结果是全局变量 ncpu = CPU核数 +CALL runtime·schedinit(SB) //调度系统初始化 + +``` + +接下来继续看调度器是如何初始化的。 + +``` +func schedinit() { + _g_ := getg() // _g_ = &g0 + + ...... + + //设置最多启动10000个操作系统线程,也是最多10000个M + sched.maxmcount = 10000 + + ...... + + mcommoninit(_g_.m) //初始化m0,因为从前面的代码我们知道g0->m = &m0 + + ...... + + sched.lastpoll = uint64(nanotime()) + procs := ncpu //系统中有多少核,就创建和初始化多少个p结构体对象 + if n, ok := atoi32(gogetenv("GOMAXPROCS")); ok && n > 0 { + procs = n //如果环境变量指定了GOMAXPROCS,则创建指定数量的p + } + if procresize(procs) != nil {//创建和初始化全局变量allp + throw("unknown runnable goroutine during bootstrap") + } + + ...... +} + +``` + +schedinit 主要功能就是调用 mcommoninit 函数对 m0(g0.m) 进行必要的初始化,对m0初始化完成之后调用 procresize 初始化系统需要用到的 p 结构体对象。 + +``` +func mcommoninit(mp *m) { + _g_ := getg() + + ... + + //创建用于信号处理的gsignal,只是简单的从堆上分配一个g结构体对象,然后把栈设置好就返回了 + mpreinit(mp) + + //把m挂入全局链表allm之中 + mp.alllink = allm + atomicstorep(unsafe.Pointer(&allm), unsafe.Pointer(mp)) + + ... +} + +func mpreinit(mp *m) { + mp.gsignal = malg(32 * 1024) // Linux wants >= 2K + mp.gsignal.m = mp +} +``` + +接下来就是最关键的 procresize 函数了。 + +### 初始化 allp + +总结一下这个函数的主要流程: + +- 使用 make([]p, nprocs) 初始化全局变量 allp,即 allp = make([]*p, nprocs) +- 循环创建并初始化 nprocs 个 p 结构体对象并依次保存在 allp 切片之中 +- 把 m0 和 allp[0] 绑定在一起,即 m0.p = allp[0], allp[0].m = m0 +- 把除了 allp[0] 之外的所有p放入到全局变量 sched 的 pidle 空闲队列之中 + +``` +func procresize(nprocs int32) *p { + old := gomaxprocs //系统初始化时 gomaxprocs = 0 + + ... + + // Grow allp if necessary. + if nprocs > int32(len(allp)) { //初始化时 len(allp) == 0 + lock(&allpLock) + if nprocs <= int32(cap(allp)) { + allp = allp[:nprocs] + } else { //初始化时进入此分支,创建allp 切片 + nallp := make([]*p, nprocs) + + copy(nallp, allp[:cap(allp)]) + allp = nallp + } + unlock(&allpLock) + } + + for i := old; i < nprocs; i++ { + pp := allp[i] + if pp == nil { + pp = new(p) + } + pp.init(i) // 初始化 例如 pp.status = _Pgcstop + atomicstorep(unsafe.Pointer(&allp[i]), unsafe.Pointer(pp)) + } + + ... + + _g_ := getg() // _g_ = g0 + if _g_.m.p != 0 && _g_.m.p.ptr().id < nprocs {//初始化时m0->p还未初始化,所以不会执行这个分支 + + _g_.m.p.ptr().status = _Prunning + _g_.m.p.ptr().mcache.prepareForSweep() + } else {//初始化时执行这个分支 + // release the current P and acquire allp[0] + if _g_.m.p != 0 {//初始化时这里不执行 + _g_.m.p.ptr().m = 0 + } + _g_.m.p = 0 + _g_.m.mcache = nil + p := allp[0] + p.m = 0 + p.status = _Pidle + acquirep(p) //把p和m0关联起来,其实是这两个strct的成员相互赋值 + } + + + ... + var runnablePs *p + for i := nprocs - 1; i >= 0; i-- { + p := allp[i] + if _g_.m.p.ptr() == p { + continue + } + p.status = _Pidle + // 初始化时,所有 p 的 runq 都是空的,所以一定会走这个 if + if runqempty(p) { + // 将 p 放到全局调度器的 pidle 队列中 + pidleput(p) + } else { + ... + } + } + ... + +} + +func pidleput(_p_ *p) { + if !runqempty(_p_) { + throw("pidleput: P has non-empty run queue") + } + + // 简单链表操作,sched.pidle 存储着上一个 P,link 指向它就是指向下一个 P + _p_.link = sched.pidle + sched.pidle.set(_p_) + atomic.Xadd(&sched.npidle, 1) // TODO: fast atomic +} +``` + +## 通用函数解析 + +### runtime·get_tls + +通过上面的初始化过程,现在 FS 寄存器里面就是 m0.tls 的地址了,而在 Golang 的汇编代码中,FS 寄存器实际上是 TLS 虚拟寄存器。因此 get_tls 就是简单的讲 TLS 寄存器的地址赋给参数。g(r) 就是获取在 TLS 上存储的 G 结构体的方法。 + +``` +get_tls(CX) +MOVQ g(CX), BX; BX存器里面现在放的是当前g结构体对象的地址 + +#ifdef GOARCH_amd64 +#define get_tls(r) MOVQ TLS, r +#define g(r) 0(r)(TLS*1) +#endif + +``` + +### runtime·systemstack + +我们知道,每个 M 都有一个 g0,这个 g0 的栈空间使用的是 M 物理线程的栈空间,而不是其他 g 那样其实是 Golang 的堆空间分配的。这个 g0 没有任何的上下文信息,也就是 shed 属性都是置空的,它唯一的作用就是利用它的栈空间来执行一些函数。 + +systemstack 会切换当前的 g 到 g0, 并且使用 g0 的栈空间, 然后调用传入的函数, 再切换回原来的g和原来的栈空间。 +切换到 g0 后会假装返回地址是 mstart, 这样 traceback 的时候可以在 mstart 停止。 + +``` +TEXT runtime·systemstack_switch(SB), NOSPLIT, $0-0 + RET + +TEXT runtime·systemstack(SB), NOSPLIT, $0-8 + MOVQ fn+0(FP), DI // DI = fn + get_tls(CX) + MOVQ g(CX), AX // AX = g + MOVQ g_m(AX), BX // BX = m + + MOVQ m_g0(BX), DX // DX = g0 + + // 切换栈,保存上下文 + // 进入 systemstack 的 G PC 值都是这个 systemstack_switch,用于 traceback + MOVQ $runtime·systemstack_switch(SB), SI + MOVQ SI, (g_sched+gobuf_pc)(AX) + MOVQ SP, (g_sched+gobuf_sp)(AX) + MOVQ AX, (g_sched+gobuf_g)(AX) + MOVQ BP, (g_sched+gobuf_bp)(AX) + + // 切换到 g0 栈 + MOVQ DX, g(CX) + MOVQ (g_sched+gobuf_sp)(DX), BX + // 将 mstart 放到 SP 栈顶,用于 traceback + SUBQ $8, BX + MOVQ $runtime·mstart(SB), DX + MOVQ DX, 0(BX) + MOVQ BX, SP + + // 在 g0 调用函数 + MOVQ DI, DX + MOVQ 0(DI), DI + CALL DI + + // 切换回 g + get_tls(CX) + MOVQ g(CX), AX + MOVQ g_m(AX), BX + MOVQ m_curg(BX), AX + MOVQ AX, g(CX) + MOVQ (g_sched+gobuf_sp)(AX), SP + MOVQ $0, (g_sched+gobuf_sp)(AX) + RET + +``` + +### runtime·mcall + +mcall 与上面的 runtime·systemstack 非常相似,都是去 g0 栈空间执行新的函数。但是我们需要注意,runtime·systemstack 执行之后还会回来,其实就是执行个函数,当前 g 是不会被调度走的。 + +但是我们这里的 mcall 不一样,这个函数的基本用途就是调度,当前 g 在 mcall 保存了上下文之后,基本可以确定不会再回来了,因此这个 mcall 要特别小心的保存当前的 PC 与 SP 寄存器的值。 + +这个函数的第四句汇编有点难懂,得明白整个 golang 的函数调用规则才能明白。我们先用下面这个简单的例子: + +``` +func start(a, b, c int64) { + ...... +} + +func main() { + ... + + start(1, 2, 3) + + int a = 1 + + ... +} + +``` + +golang 中函数调用的布局: + +- 当前函数的局部变量 +- 被调用函数的参数 3 —— FP 寄存器地址 +- 被调用函数的参数 2 +- 被调用函数的参数 1 +- 调用函数下一个指令的地址,也就是 `int a = 1` 指令地址 —— SP 位置 +- 被调用函数的局部变量 + +>golang的函数栈的大小,不包含传入的参数和返回值,这两个部分由调用者管理,mcall 例子中,fn 是传入参数,这个 fn 是放在【调用mcall的函数】的函数栈里的。mcall除了一个传入参数fn之外,没有其他变量了,所以mcall的栈大小就是0。 + +>一个栈大小为0的函数,栈顶,也就是SP指向的位置,刚好就是存的调用mcall的函数的下一条指令,也就是这里所谓的 caller 的 PC。 + +>这个PC值为什么会出现在这里,那就是因为,这是 CALL 指令自动做的事情,也是 golang 编译器做的事情,调用某函数时,先把本函数的下一掉指令 push 进栈。 + +话说回来了,既然mcall不需要额外的栈大小了,前面说了,栈大小是0,所以,自然而然的,SP,存的东西就是调用者 caller 本函数的PC,也就是 mcall 的下一个指令。好,拿到了caller‘s PC,把他存到 gobuf_pc 里。 + +所以很有趣的是,SP 实际上是被调用函数运行完毕的返回地址,而 FP 这个参数起始地址才是 Goroutine 的栈顶地址。 + +处理如下: + +- 设置g.sched.pc等于当前的返回地址 +- 设置g.sched.sp等于寄存器rsp的值 +- 设置g.sched.g等于当前的g +- 设置g.sched.bp等于寄存器rbp的值 +- 切换TLS中当前的g等于m.g0 +- 设置寄存器rsp等于g0.sched.sp, 使用g0的栈空间 +- 设置第一个参数为原来的g +- 设置rdx寄存器为指向函数地址的指针(上下文) + +调用指定的函数, 不会返回。 + +回到g0的栈空间这个步骤非常重要, 因为这个时候 g 已经中断, 继续使用g的栈空间且其他M唤醒了这个g将会产生灾难性的后果。 +G在中断或者结束后都会通过mcall回到g0的栈空间继续调度, 从goexit调用的mcall的保存状态其实是多余的, 因为G已经结束了。 + +``` +TEXT runtime·mcall(SB), NOSPLIT, $0-8 + + get_tls(CX) // 获取 TLS 的地址,存储到 CX 寄存器上 + MOVQ g(CX), AX // 将 TLS 上的 G 对象地址赋给 AX + MOVQ 0(SP), BX // 当前 G 的 SP 栈顶地址赋给 BX + MOVQ BX, (g_sched+gobuf_pc)(AX) // 见上文解释 + LEAQ fn+0(FP), BX // FP 是调用函数第一个参数地址,由于 plan9 是由调用者来保存参数地址的,所以这个地址是栈顶地址 + MOVQ BX, (g_sched+gobuf_sp)(AX) // 保存 SP 地址 + MOVQ AX, (g_sched+gobuf_g)(AX) + MOVQ BP, (g_sched+gobuf_bp)(AX) + + // switch to m->g0 & its stack, call fn + MOVQ g(CX), BX // 获取当前 G 对象,赋给 BX + MOVQ g_m(BX), BX // 获取 M 对象 + MOVQ m_g0(BX), SI // 获取 M 的 g0 对象地址 + + MOVQ SI, g(CX) // g = m->g0 // 替换 TLS 的 G 对象地址为 g0 地址 + + MOVQ (g_sched+gobuf_sp)(SI), SP // sp = m->g0->sched.sp // 将g0的SP值,拿出来,交给寄存器SP,看见没,开始切换了,或者说,已经切完了,下面就是调用 fn (g) + PUSHQ AX // AX 是什么,存的就是刚才的g,不是现在的g0,将g放到栈上。这一步就是普通的,我要调用fn函数了,我要把参数g,先放到栈上。 + MOVQ DI, DX // 这一步把fn存到DX不知道要干嘛,可能后续调用fn的时候,会用到??不知道,等再接着看。 + MOVQ 0(DI), DI // 这一步和下一步,就是调用 fn + CALL DI // 调用fn + POPQ AX + MOVQ $runtime·badmcall2(SB), AX + JMP AX + RET + +``` + + +### runtime.gogo + +与 mcall 相反,gogo 是专门从 g0 切换到 g 栈空间来执行代码的函数。 + +runtime.gogo 是汇编完成的,功能就是执行 `go func()` 的这个 func(),由于当前已经是 g0 的栈空间,因此调度的时候,无需保存 g0 的上下文信息,可以直接将 g 对象的 gobuf 里的内容搬到寄存器里即可。然后从 gobuf.pc 寄存器存储的指令位置开始继续向后执行。 + +- 设置TLS中的g为g.sched.g, 也就是g自身 +- 设置rsp寄存器为g.sched.rsp +- 设置rax寄存器为g.sched.ret +- 设置rdx寄存器为g.sched.ctxt (上下文) +- 设置rbp寄存器为g.sched.rbp +- 清空sched中保存的信息 +- 跳转到g.sched.pc +- 因为前面创建goroutine的newproc1函数把返回地址设为了goexit, 函数运行完毕返回时将会调用goexit函数。 + +值得我们注意的是,我们在 gogo 函数中切换到 g 栈空间从来不需要保存 g0 的 sp,但是 mcall 函数中每次都是从 gobuf 中获取 sp 赋值给 SP 寄存器,原因就是调度循环中都是一去不复返的,所以调度循环中 schedule 函数都会重新从 g0 的 sp 作为栈起点,不断的重复利用相同的 g0 栈空间地址。 + +g0 的 sp 栈地址是 M 物理线程启动初始化时由 mstart1 执行 save 函数保存的,这个值自从保存后就不会再发生变化。 + +``` +TEXT runtime·gogo(SB), NOSPLIT, $16-8 + MOVQ buf+0(FP), BX // gobuf + MOVQ gobuf_g(BX), DX + MOVQ 0(DX), CX // 这行代码没有实质作用,检查gp.sched.g是否是nil,如果是nil进程会crash死掉 + + #把要运行的g的指针放入线程本地存储,这样后面的代码就可以通过线程本地存储 + #获取到当前正在执行的goroutine的g结构体对象,从而找到与之关联的m和p + get_tls(CX) + MOVQ DX, g(CX) + + MOVQ gobuf_sp(BX), SP // 把CPU的SP寄存器设置为sched.sp,完成了栈的切换 + MOVQ gobuf_ret(BX), AX + MOVQ gobuf_ctxt(BX), DX + MOVQ gobuf_bp(BX), BP + + #清空sched的值,因为我们已把相关值放入CPU对应的寄存器了,不再需要,这样做可以少gc的工作量 + MOVQ $0, gobuf_sp(BX) // clear to help garbage collector + MOVQ $0, gobuf_ret(BX) + MOVQ $0, gobuf_ctxt(BX) + MOVQ $0, gobuf_bp(BX) + + + MOVQ gobuf_pc(BX), BX // 把sched.pc值放入BX寄存器 + JMP BX // JMP把BX寄存器的包含的地址值放入CPU的IP寄存器,于是,CPU跳转到该地址继续执行指令 + +``` \ No newline at end of file diff --git "a/Go \345\236\203\345\234\276\345\233\236\346\224\266.md" "b/Go \345\236\203\345\234\276\345\233\236\346\224\266.md" new file mode 100644 index 0000000..79aa594 --- /dev/null +++ "b/Go \345\236\203\345\234\276\345\233\236\346\224\266.md" @@ -0,0 +1,1849 @@ +# Go 垃圾回收 + +## 常见的GC算法 + +### 标记-清除算法 STW + +标记清除是最简单的也是最古老的垃圾回收算法。 它的思想很简单: + +- 先从根对象开始扫描,当我们的根对象指向了某个堆上的对象,我们就认为这个对象是可达的。 + +- 可达对象指向的对象也是可达对象。 + +- 从根对象开始遍历,采用深度遍历或者广度遍历 + +![](img/marksweep.jpg) + +如上图,根对象指向了A、B,说明A、B是可达的,同理F、G、D都是可达的对象,但是C、E就是不可达的对象,它们是要被清理的。 + +我们再来看看它的整个流程: + +- 触发垃圾回收事件发生,一般是当申请堆内存的时候,做一个检测机制,或者定时回收 2.STW(Stop The World),挂起整个程序,等待GC +- 从根对象开始扫描,在每个可达的对象的header做一个标记 +- 清除阶段,扫描整个堆,发现是活跃对象的话(根据header的标志),则清除掉它的标志位即可。如果发现是非活跃对象即不可达对象,则把对象作为分块,连接到被称为“空闲链表”的单向链表。在之后进行分配时只要遍历这个空闲链表,就可以找到分块了。 +- 清除完成,继续程序。 + +记清楚最大的一个缺点就是STW,这个很多场景下不能容忍的,我们先来分析下为什么要STW,想象一下,当你的标记阶段正在进行中,而你的程序也在跑,突然创建了一个新对象,也是可达的,比如如下图所示: + +![](img/marksweep1.jpg) + +当我已经标记完D之后,认为到它这就完事了,但是我们创建的新对象H就会在清除节点被认为是不可达的,最终被清理掉。在清除阶段也会遇到同样的问题。 + +### 引用计数 + +- 我们给每个对象都分配一个计数器,代表有多少其他的对象引用我 +- 当没有对象再引用我的时候,就代表我没用了,就是垃圾可以被回收了 我们来张图看看 + +### 节点复制 + +节点复制的想法比较的奇特,我们想想,标记清除算法中有两个阶段,第一步找到活跃的对象给它标记上,第二步扫描整个堆,把其余未标记的对象清除掉,这两个步骤能不能节省一个步骤呢?再有就是我们的对象申请的时候,都是从空闲链表上获取的,找到一个合适大小的内存块这个速度是比较慢的,我们知道栈内存的申请是很快,为什么呢?因为栈是一块连续的空间,申请的时候只需要按照地址增长的申请即可,是O(1)的,堆我觉得至少得O(logN)。 ok,节点复制其实就是为了解决我们提到的以上两个问题。先来看看它是怎么做的吧: + +- 首先它是把堆的内存分为两个部分,并且是均分。一个叫From,一个To。 +- From内存块就是我们内存分配的时候用的。 +- 当我们要进行GC的时候,我们把活跃的对象直接复制到To内存空间中去,然后直接把To空间换做我们的程序使用的空间,再把From整体清空,然后再把From作为To。 + +![](img/copy1.jpg) + +![](img/copy2.jpg) + +优缺点: + +- 分配速度快,既然它没有空闲链表的概念,直接当成一个栈内存分配即可,速度飞起,相应的就没有碎片化的苦恼了。 + +- 吞吐量高,其实就是GC速度快,如同我们之前所说,它不像标记清除那样进行第二个阶段去扫描所有的堆。 + +- 还有一个比较有意思的地方,通过老空间的内容复制到新空间之后,相互有引用的对象会被分配在距离较近的地方,还记得程序的局部性原理吗?这会提升我们的缓存命中率 + +- 优点那么好,肯定优缺点,第一个就是太浪费内存了,我们只能用一半!!! + +- 这里面也有递归的操作,效率可能会降低,或者有可能引起栈溢出的问题。 + +- 同样的,它也有STW的时间,复制清除的过程需要暂停程序。 + +### 分代收集 + +分代收集只是一种思想,并非一个专门的垃圾回收算法,不过这个想法真妙,值得学习,在平常的性能优化过程中应该很有用。 所谓分代,谜底就在字面上,就是把我们堆分配的对象,给他分代(上一代,下一代的意思),我们这里只说分成新旧两代的算法,那么分代按照什么分呢?按照我们的垃圾的生命周期来分,意思就是很快编程垃圾的那种对象被分为年轻的一代,相应的很长时间才变成垃圾的对象被称为老的一代,我们每次只对年轻一代的对象进行GC即可,每次GC都会给对象增加一个年龄,当到达老一代的对象的年龄的限制的时候,再把它升级为老对象,放到老对象的空间中,当老对象的空间满的时候,我们再去GC老对象即可。 + +这种算法基于这样的认知,大部分对象创建后很快就变成垃圾,其余的就是很长时间才会变成垃圾,那么我们没必要对这种长时间才变成垃圾的对象进行GC,浪费时间。 ok,我们来看下David Ungar研究的分代GC算法: + +- 该算法把堆空间划分为四个部分,分别是生成空间,幸存空间1,幸存空间2,老年代空间。并且我们把前三者合并成为新生代空间。 + +- 当对象刚创建的时候,分配的空间就是在生成空间。 + +- 当生成空间满的时候,我们就对新生代空间进行GC,这里是是对整个新生代空间进行GC,采用的GC的算法就是节点复制。我们看图说话: + +![](img/gen1.jpg) + +看上图,我们把幸存空间其中一个作为一个To空间。 + +- 另外,我们每次GC的时候,都会对对象的“年龄”加1,当判断对象的年龄到达一定阈值的时候,就把对象移动到老年代空间。 + +- 当我们对新生代对象进行GC的时候,我们注意一点,之前看节点复制的时候,我们知道是从根节点开始扫描,但是注意一有可能我们的老年代的对象也会指向新生代,所以如果我们把这点漏掉了,会多清除一些活跃的对象(至于为什么我们稍后解释)。为了解决这个问题,我们需要把老年代的对象扫描一遍,但是想想如果这样做的话我们岂不是每次要GC新生代对象的时候,都要把新、老都扫描了?这样的话我们的分代GC就没有优点了,如下图。 + +![](img/gen2.jpg) + +- 为了解决第5步的问题,David Ungar想到了一个方法,用一个记录集来记录那些老年代的对象指向新生代的情况。这样的话,当我们的GC新生代的时候,从根对象与记录集中就行,那么这个记录怎么做到呢,采用的写入屏障(write barrier)的方法。 + +- 那么我们什么时候GC老年代对象的呢?当我们发现新生代的对象的年龄到了之后,要晋升为老年代对象的时候,会先检查老年代空间是否满了,满的话我们就开始老年代GC,老年代对象GC采用的就是标记清除的方法,注意这里应该是把整个堆都进行了GC。 + + + +## Go 的三色标记法 + +在golang1.5之前,golang主要是采用标记清除的方法,这样的话STW时间会很长,1.5出来后采用了三色标记法,也是我们今天主要说的。 golang为何要选择三色标记算法呢?我们知道golang比较适合网络高并发的服务场景,那么如果使用STW时间较长的GC算法,对服务来说是致命的,故而要选用STW时间较少的算法,在标记清除的基础上发展来的三色标记出现了。 三色标记的思想其实是尽量把标记阶段、清除阶段与程序同时跑,它其实是一种增量式GC算法,所谓增量式其实就是把GC过程拆分出来的意思,跟我们要把最大的STW时间减少的思想吻合。 + +在看整个过程之前,我们先同步几件事情: + +- 在三色标记算法中,我们给对象进行了标记颜色,分别是白色、灰色、黑色,在GC开始的时候,所有的对象默认都是白色,标记完成之后,所有的可达的对象都会被标记为黑色,而灰色就是我们的中间状态 + +- 我们Golang的GC的根对象,都在栈上,所谓栈其实就是协程栈。 + +我们来看下其整个过程: + +- root_scan:首先,当要GC触发的时候,首先,我们会初始化写屏障(Write barrier),我们的垃圾回收器从根对象开始扫描,把所有的垃圾根对象压入一个栈当中,并且把对象都会标记为灰色 + +- mark:从栈中pop出一个对象,把该对象所有指向的子对象都入栈,并且标记为灰色,并且把该对象标记为黑色,然后放入黑色的对象集合中。mark 是分多次运行的,即增量式的 mark,不需要 STW,它和 mutator 交替运行。它主要是弹出栈里面的对象,将其子对象涂成灰色,压入栈,然后把这个对象涂成黑色。重复这个过程,直到栈为空。 + +- 无限的重复第二个步骤,直到栈为空。 + +- mark termination:在第一步骤中我们看到有写屏障,这个写屏障其实就是记录我们进行第一次扫描的时候漏掉的那些对内存的操作,我们会再次遍历这些记录(稍后细说),注意这个过程会进行STW,但是时间会很短。 + +- sweep:sweep也是分多次的,增量式的回收垃圾,跟mutator交替运行。跟标记-清除算法的实现基本一致,也是需要遍历整个堆,将白色对象挂到空闲链表上,黑色对象取消mark标记。 + +``` +简单的说主要分为四个阶段: + +root_scan:STW +mark...mark...mark...mark... +mark termination:STW +sweep...sweep...sweep... + +``` + +注意: + +- 还记得我们在说golang的堆内存管理与协程栈管理吗?首先栈的管理中有一个stackmap可以帮助我们寻找到到协程栈上的指针,这其实就是我们的根对象 + +- Edsger W. Dijkstra提出的三色标记法中,有一个地方需要注意,在标记阶段如果出现以下情况我们是要进行特殊处理的: + + - a.如果有新创建的对象,而指向该对象的是黑色的对象,或者指向该对象的恰好也是刚创建的根对象,这样这个刚创建的对象会被漏标记 + + - b.如果有如下图的情况,某个白色对象在被灰色对象引用的情况下,被改为被黑色对象引用了,而我们知道黑色的对象其实已经被扫描过了,这样这个白色的对象就不会被标记到。 + + ![](img/writeb.jpg) + + 这个时候我们就要用到写屏障了,这里看到,只要我们指向的对象是白色的,我们就把它标记为灰色的,这样的话,新创建的对象以及黑色对象指向白色对象的情况就能被解决,如下图所示。 + + ![](img/writeb2.jpg) + + - 我们说过三色标记是增量式的,具体体现在哪呢?从灰色的栈集合中,每次最多扫描MARK_MAX个灰色对象。也就是说把扫描阶段拆开了,一次一部分,所以叫增量式。 + + + +## Golang的内存逃逸 + +我们在写C++或者C的时候都知道一个事实,你自己申请的堆内存需要自己释放,因为它们存储在堆上,而且一般自己malloc或者new出来的内存,都是在堆上的,那么golang是否也这样呢? + +答案不是的,之前我们也说到过一些,就是程序开发人员在程序中申请的变量或者说内存,是存储在堆上还是栈上,是golang的编译器来决定的,那么何谓内存逃逸呢,举一个最简单的例子,看如下的代码 + +``` +func add (a int) *int{ + b := a + 1 + return &b +} + +``` + +如果这段代码在C++程序中,该函数返回的b的地址,由于b是局部变量,存储在栈上,当函数执行完成后栈上的资源释放那么如果其他地方再用b的地址去访问b的话,就会出现访问空指针,或者访问到错误内容。 但是,如上的代码它是golang的,就不会出现这样的问题,为何呢?其实golang编译器在编译这段代码的时候,发现该函数返回了局部变量的地址,它就认为该变量不太适合存储在栈上,于是它命令申请该变量的时候存储到堆上,这样的话函数执行完,也不会释放掉b的内容,我们就可以在其他地方开心的使用b的内容了。 明白了如上的原因,我们来再次说明下编译器的作用:编译器来决定变量是存储在堆上还是栈上。 比如,你在golang中New一个对象或者make一个对象,对象不一定会存储在堆上。 那么我们再解释下内存逃逸的定义:变量突破了自己的作用范围,在作用范围外能被访问的现象就叫内存逃逸,即为通过逃逸把栈上的内存分配到堆上了。 + +优缺点: + +1.这样做的好处是,不用程序开发人员太多的关注局部变量作用范围的问题,尤其是如上的问题。 + +2.那么它有坏处吗,必然是有的,想象下,如上这种情况,本应该在栈存储的变量最后都跑到堆上了,这势必会使得堆上的内存变多,带来的就是GC的压力,另外,申请堆上的内存与申请栈内存的速度是没发比的,那么我们怎么解决呢?这就是逃逸分析。 + + +## Go 内存回收总体设计 + +![](img/gc1.png) + +在GC过程中会有两种后台任务(G), 一种是标记用的后台任务, 一种是清扫用的后台任务. + +标记用的后台任务会在需要时启动, 可以同时工作的后台任务数量大约是P的数量的25%, 也就是go所讲的让25%的cpu用在GC上的根据. + +清扫用的后台任务在程序启动时会启动一个, 进入清扫阶段时唤醒. + +目前整个GC流程会进行两次STW(Stop The World), 第一次是Mark阶段的开始, 第二次是Mark Termination阶段. + +第一次STW会准备根对象的扫描, 启动写屏障(Write Barrier)和辅助GC(mutator assist). + +第二次STW会重新扫描部分根对象, 禁用写屏障(Write Barrier)和辅助GC(mutator assist). + +需要注意的是, 不是所有根对象的扫描都需要STW, 例如扫描栈上的对象只需要停止拥有该栈的G. + +从go 1.9开始, 写屏障的实现使用了Hybrid Write Barrier, 大幅减少了第二次STW的时间. + +### GC的触发条件 + +GC在满足一定条件后会被触发, 触发条件有以下几种: + +- gcTriggerHeap: 当前分配的内存达到一定值就触发GC +- gcTriggerTime: 当一定时间没有执行过GC就触发GC +- gcTriggerCycle: 要求启动新一轮的GC, 已启动则跳过, 手动触发GC的runtime.GC()会使用这个条件 + +#### mallocgc 触发 gcTriggerHeap + +mallocgc 在分配大对象或者 mspan 中所有对象都已经被用尽的时候,会进行 GC 测试,验证 heap 是否已经达到 GC 的条件。 + +``` +func mallocgc(size uintptr, typ *_type, needzero bool) unsafe.Pointer { + ... + + shouldhelpgc := false + ... + if size <= maxSmallSize { + if noscan && size < maxTinySize { + ... + v := nextFreeFast(span) + if v == 0 { + v, _, shouldhelpgc = c.nextFree(tinySpanClass) + } + .... + } + else { + .... + v := nextFreeFast(span) + if v == 0 { + v, _, shouldhelpgc = c.nextFree(tinySpanClass) + } + .... + } + } else { + shouldhelpgc = true + } + + if shouldhelpgc { + if t := (gcTrigger{kind: gcTriggerHeap}); t.test() { + gcStart(t) + } + } +} + +func (c *mcache) nextFree(spc spanClass) (v gclinkptr, s *mspan, shouldhelpgc bool) { + ... + shouldhelpgc = false + if freeIndex == s.nelems { + shouldhelpgc = true + } + ... + + return +} +``` + +#### forcegchelper 触发强制 GC gcTriggerTime + +强制2分钟触发。 + +``` +func init() { + go forcegchelper() +} + +func forcegchelper() { + forcegc.g = getg() + for { + lock(&forcegc.lock) + if forcegc.idle != 0 { + throw("forcegc: phase error") + } + atomic.Store(&forcegc.idle, 1) + goparkunlock(&forcegc.lock, waitReasonForceGGIdle, traceEvGoBlock, 1) + // this goroutine is explicitly resumed by sysmon + if debug.gctrace > 0 { + println("GC forced") + } + // Time-triggered, fully concurrent. + gcStart(gcTrigger{kind: gcTriggerTime, now: nanotime()}) + } +} + +``` + +#### runtime.GC() 触发 gcTriggerCycle + +用户主动触发。同一时间只有一个触发条件可以触发一轮GC。 + +``` +func GC() { + ... + gcStart(gcTrigger{kind: gcTriggerCycle, n: n + 1}) + ... +} + +``` + +#### gcTrigger.test 条件检查函数 + +触发条件的判断在gctrigger的test函数. +其中gcTriggerHeap和gcTriggerTime这两个条件是自然触发的, 代码如下: + +``` +var forcegcperiod int64 = 2 * 60 * 1e9 + +func (t gcTrigger) test() bool { + if !memstats.enablegc || panicking != 0 || gcphase != _GCoff { + return false + } + switch t.kind { + case gcTriggerHeap: + return memstats.heap_live >= memstats.gc_trigger + case gcTriggerTime: + if gcpercent < 0 { + return false + } + lastgc := int64(atomic.Load64(&memstats.last_gc_nanotime)) + return lastgc != 0 && t.now-lastgc > forcegcperiod + case gcTriggerCycle: + // t.n > work.cycles, but accounting for wraparound. + return int32(t.n-work.cycles) > 0 + } + return true +} + +``` + +heap_live的增加在上面对分配器的代码分析中可以看到, 当值达到gc_trigger就会触发GC, 那么gc_trigger是如何决定的? + +gc_trigger的计算在 gcSetTriggerRatio 函数中, 公式是: + +``` +// heap_marked is the number of bytes marked by the previous +// GC. After mark termination, heap_live == heap_marked, but +// unlike heap_live, heap_marked does not change until the +// next mark termination. + +trigger = uint64(float64(memstats.heap_marked) * (1 + triggerRatio)) + +``` + +heap_marked 上一个周期 gc 的时候 heap_live 的值,当前标记存活的大小乘以1+系数 triggerRatio, 就是下次出发GC需要的分配量. + +triggerRatio在每次GC后都会调整, 简单的说,它和目标 heap 增长率、实际 heap 增长率、CPU 占用时间相关,计算 triggerRatio 的函数是 endCycle: + +``` +const triggerGain = 0.5 +// 目标Heap增长率, 默认是1.0 +goalGrowthRatio := gcEffectiveGrowthRatio() +// 实际Heap增长率, 等于总大小/存活大小-1 +actualGrowthRatio := float64(memstats.heap_live)/float64(memstats.heap_marked) - 1 +// GC标记阶段的使用时间(因为endCycle是在Mark Termination阶段调用的) +assistDuration := nanotime() - c.markStartTime +// GC标记阶段的CPU占用率, 目标值是0.25 +utilization := gcGoalUtilization +if assistDuration > 0 { + // assistTime是G辅助GC标记对象所使用的时间合计 + // (nanosecnds spent in mutator assists during this cycle) + // 额外的CPU占用率 = 辅助GC标记对象的总时间 / (GC标记使用时间 * P的数量) + utilization += float64(c.assistTime) / float64(assistDuration*int64(gomaxprocs)) +} +// 触发系数偏移值 = 目标增长率 - 原触发系数 - CPU占用率 / 目标CPU占用率 * (实际增长率 - 原触发系数) +// 参数的分析: +// 实际增长率越大, 触发系数偏移值越小, 小于0时下次触发GC会提早 +// CPU占用率越大, 触发系数偏移值越小, 小于0时下次触发GC会提早 +// 原触发系数越大, 触发系数偏移值越小, 小于0时下次触发GC会提早 +triggerError := goalGrowthRatio - memstats.triggerRatio - utilization/gcGoalUtilization*(actualGrowthRatio-memstats.triggerRatio) +// 根据偏移值调整触发系数, 每次只调整偏移值的一半(渐进式调整) +triggerRatio := memstats.triggerRatio + triggerGain*triggerError + + +func gcEffectiveGrowthRatio() float64 { + egogc := float64(memstats.next_gc-memstats.heap_marked) / float64(memstats.heap_marked) + + return egogc +} +``` +目标Heap增长率"可以通过设置环境变量"GOGC"调整, 默认值是100, 增加它的值可以减少GC的触发.设置"GOGC=off"可以彻底关掉GC. + +### 三色的定义 + +在go内部对象并没有保存颜色的属性, 三色只是对它们的状态的描述, + +白色的对象在它所在的span的gcmarkBits中对应的bit为0, + +灰色的对象在它所在的span的gcmarkBits中对应的bit为1, 并且对象在标记队列中, + +黑色的对象在它所在的span的gcmarkBits中对应的bit为1, 并且对象已经从标记队列中取出并处理. + +gc完成后, gcmarkBits会移动到allocBits然后重新分配一个全部为0的bitmap, 这样黑色的对象就变为了白色. + +### 混合写屏障 + +混合写屏障会同时标记指针写入目标的"原指针"和“新指针". + +标记原指针的原因是, 其他运行中的线程有可能会同时把这个指针的值复制到寄存器或者栈上的本地变量, + +因为按照之前的写屏障规则,复制指针到寄存器或者栈上的本地变量不会经过写屏障, 所以有可能会导致指针不被标记, 试想下面的情况: + +``` +[go] b = obj +[go] oldx = nil +[gc] scan oldx...// gc 开始扫描 oldx +[go] oldx = b.x // 复制b.x到本地变量, 不进过写屏障 +[go] b.x = ptr // 写屏障应该标记b.x的原值 +[gc] scan b... + +``` + +可以看到,刚刚开始扫描 oldx 的时候,oldx 还是个 nil,扫描过程中 b 将 obj.x 指针赋予了临时变量 oldx,然后 b.x 转换成了新值,之后 gc 开始扫描 b。 + +如果不标记原值的话,最后扫描 b 对象的时候,b.x 已经变成了 ptr,而 b.x 的之前值 oldx 并不会扫描到,从而会被 gc 清除。 + +标记新指针的原因是, 其他运行中的线程有可能会转移指针的位置, 试想下面的情况: + +``` +[go] a = ptr +[go] b = obj +[gc] scan b... // gc 开始扫描 +[go] b.x = a // 写屏障应该标记b.x的新值 +[go] a = nil +[gc] scan a... + +``` + +可以看到,在扫描开始的时候,ptr 还存储在 a 对象上,在扫描中 a 对象转移了 ptr 的指针,然后自己变成了 nil,这时候才开始扫描 a 对象。如果写屏障不标记新值, 那么 ptr 就不会被扫描到. + +总的来说,b.x 被赋值的时候,既要保留原值,还要保留新值。 + +混合写屏障可以让GC在并行标记结束后不需要重新扫描各个G的堆栈, 可以减少Mark Termination中的STW时间. + +除了写屏障外, 在GC的过程中所有新分配的对象都会立刻变为黑色, 在上面的mallocgc函数中可以看到. + +``` +func mallocgc(size uintptr, typ *_type, needzero bool) unsafe.Pointer { + ... + // 内存屏障,汇编 + publicationBarrier() + ... +} + +``` + +### 辅助GC(mutator assist) + +为了防止heap增速太快, 在GC执行的过程中如果同时运行的G分配了内存, 那么这个G会被要求辅助GC做一部分的工作. +在GC的过程中同时运行的G称为"mutator", "mutator assist"机制就是G辅助GC做一部分工作的机制. + +辅助GC做的工作有两种类型, 一种是标记(Mark), 另一种是清扫(Sweep). + +#### 辅助 mark + +辅助标记的触发可以查看上面的mallocgc函数, + +``` +func mallocgc(size uintptr, typ *_type, needzero bool) unsafe.Pointer { + ... + + // 判断是否要辅助GC工作 + // gcBlackenEnabled在GC的标记阶段会开启 + var assistG *g + if gcBlackenEnabled != 0 { + // Charge the current user G for this allocation. + assistG = getg() + + assistG.gcAssistBytes -= int64(size) + + // 会按分配的大小判断需要协助GC完成多少工作 + // 具体的算法将在下面讲解收集器时说明 + if assistG.gcAssistBytes < 0 { + // This G is in debt. Assist the GC to correct + // this before allocating. This must happen + // before disabling preemption. + gcAssistAlloc(assistG) + } + } + ... +} + +func gcAssistAlloc(gp *g) { +retry: + debtBytes := -gp.gcAssistBytes + scanWork := int64(gcController.assistWorkPerByte * float64(debtBytes)) + if scanWork < gcOverAssistWork { + scanWork = gcOverAssistWork + debtBytes = int64(gcController.assistBytesPerWork * float64(scanWork)) + } + + bgScanCredit := atomic.Loadint64(&gcController.bgScanCredit) + stolen := int64(0) + if bgScanCredit > 0 { + if bgScanCredit < scanWork { + stolen = bgScanCredit + gp.gcAssistBytes += 1 + int64(gcController.assistBytesPerWork*float64(stolen)) + } else { + stolen = scanWork + gp.gcAssistBytes += debtBytes + } + atomic.Xaddint64(&gcController.bgScanCredit, -stolen) + + scanWork -= stolen + } + + // Perform assist work + systemstack(func() { + gcAssistAlloc1(gp, scanWork) + }) + + ... + + // 如果无法扫描足够多的对象,那么就要先采取一些措施防止分配过多的内存 + if gp.gcAssistBytes < 0 { + // 如果是由于当前 G 被抢占 + if gp.preempt { + Gosched() + goto retry + } + + //排队列等待标记,或者重试 + if !gcParkAssist() { + goto retry + } + } +} + +func gcAssistAlloc1(gp *g, scanWork int64) { + ... + + // drain own cached work first in the hopes that it + // will be more cache friendly. + gcw := &getg().m.p.ptr().gcw + workDone := gcDrainN(gcw, scanWork) + + casgstatus(gp, _Gwaiting, _Grunning) + + gp.gcAssistBytes += 1 + int64(gcController.assistBytesPerWork*float64(workDone)) + + ... + + _p_.gcAssistTime += duration + if _p_.gcAssistTime > gcAssistTimeSlack { + atomic.Xaddint64(&gcController.assistTime, _p_.gcAssistTime) + _p_.gcAssistTime = 0 + } + + ... +} +``` + +辅助标记是每次分配对象时都会检查,触发时G会帮助扫描"工作量"个对象, 工作量的计算公式是: + +``` +debtBytes * assistWorkPerByte + +``` + +意思是分配的大小乘以系数assistWorkPerByte, assistWorkPerByte的计算与 gc 待扫描对象数量和距离下次 gc heap 大小有关, 公式是: + +``` +// 等待扫描的对象数量 = 未扫描的对象数量 - 已扫描的对象数量 +scanWorkExpected := int64(memstats.heap_scan) - c.scanWork + +// 距离触发GC的Heap大小 = 期待触发GC的Heap大小 - 当前的Heap大小 +// 注意next_gc的计算跟gc_trigger不一样, next_gc等于heap_marked * (1 + gcpercent / 100) +heapDistance := int64(memstats.next_gc) - int64(atomic.Load64(&memstats.heap_live)) + +// 每分配1 byte需要辅助扫描的对象数量 = 等待扫描的对象数量 / 距离触发GC的Heap大小 +c.assistWorkPerByte = float64(scanWorkExpected) / float64(heapDistance) +c.assistBytesPerWork = float64(heapDistance) / float64(scanWorkExpected) + +``` + +#### 辅助 sweep + +辅助清扫申请新span时才会检查,辅助清扫的触发可以看上面的cacheSpan函数 + +``` +func (c *mcentral) cacheSpan() *mspan { + spanBytes := uintptr(class_to_allocnpages[c.spanclass.sizeclass()]) * _PageSize + deductSweepCredit(spanBytes, 0) + + ... +} + +func deductSweepCredit(spanBytes uintptr, callerSweepPages uintptr) { +retry: + sweptBasis := atomic.Load64(&mheap_.pagesSweptBasis) + + // Fix debt if necessary. + newHeapLive := uintptr(atomic.Load64(&memstats.heap_live)-mheap_.sweepHeapLiveBasis) + spanBytes + pagesTarget := int64(mheap_.sweepPagesPerByte*float64(newHeapLive)) - int64(callerSweepPages) + for pagesTarget > int64(atomic.Load64(&mheap_.pagesSwept)-sweptBasis) { + if sweepone() == ^uintptr(0) { + mheap_.sweepPagesPerByte = 0 + break + } + if atomic.Load64(&mheap_.pagesSweptBasis) != sweptBasis { + // Sweep pacing changed. Recompute debt. + goto retry + } + } +} +``` + + +触发时G会帮助回收"工作量"页的对象, 工作量的计算公式是: + +``` +spanBytes * sweepPagesPerByte // 不完全相同, 具体看deductSweepCredit函数 + +``` + +sweepPagesPerByte的计算在函数gcSetTriggerRatio中, 公式是: + +``` +// 当前的Heap大小 +heapLiveBasis := atomic.Load64(&memstats.heap_live) +// 距离触发GC的Heap大小 = 下次触发GC的Heap大小 - 当前的Heap大小 +heapDistance := int64(trigger) - int64(heapLiveBasis) +heapDistance -= 1024 * 1024 + +// 已清扫的页数 +pagesSwept := atomic.Load64(&mheap_.pagesSwept) +// 未清扫的页数 = 使用中的页数 - 已清扫的页数 +sweepDistancePages := int64(mheap_.pagesInUse) - int64(pagesSwept) +if sweepDistancePages <= 0 { + mheap_.sweepPagesPerByte = 0 +} else { + // 每分配1 byte(的span)需要辅助清扫的页数 = 未清扫的页数 / 距离触发GC的Heap大小 + mheap_.sweepPagesPerByte = float64(sweepDistancePages) / float64(heapDistance) +} + +``` + +### 根对象 + +在GC的标记阶段首先需要标记的就是"根对象", 从根对象开始可到达的所有对象都会被认为是存活的. + +- Fixed Roots: 特殊的扫描工作 + - fixedRootFinalizers: 扫描析构器队列 + - fixedRootFreeGStacks: 释放已中止的G的栈 +- Flush Cache Roots: 释放mcache中的所有span, 要求STW +- Data Roots: 扫描可读写的全局变量 +- BSS Roots: 扫描只读的全局变量 +- Span Roots: 扫描各个span中特殊对象(析构器列表) +- Stack Roots: 扫描各个G的栈 + +标记阶段(Mark)会做其中的"Fixed Roots", "Data Roots", "BSS Roots", "Span Roots", "Stack Roots". +完成标记阶段(Mark Termination)会做其中的"Fixed Roots", "Flush Cache Roots". + +## GO 垃圾回收源码 + +### gcStart + +``` +func gcStart(trigger gcTrigger) { + ... + + // 判断当前G是否可抢占, 不可抢占时不触发GC + mp := acquirem() + if gp := getg(); gp == mp.g0 || mp.locks > 1 || mp.preemptoff != "" { + releasem(mp) + return + } + releasem(mp) + mp = nil + + //并行清扫上一轮GC未清扫的span + for trigger.test() && sweepone() != ^uintptr(0) { + sweep.nbgsweep++ + } + + //上锁, 然后重新检查gcTrigger的条件是否成立, 不成立时不触发GC + semacquire(&work.startSema) + if !trigger.test() { + semrelease(&work.startSema) + return + } + + gcBgMarkStartWorkers() + + ... + + // 停止所有运行中的G, 并禁止它们运行 + systemstack(stopTheWorldWithSema) + + // !!!!!!!!!!!!!!!! + // 世界已停止(STW)... + // !!!!!!!!!!!!!!!! + + // 清扫上一轮GC未清扫的span, 确保上一轮GC已完成 + systemstack(func() { + finishsweep_m() + }) + + ... + + // 计算扫描根对象的任务数量 + gcMarkRootPrepare() + + // 标记所有tiny alloc等待合并的对象 + gcMarkTinyAllocs() + + // 启用辅助GC + atomic.Store(&gcBlackenEnabled, 1) + + // 重新启动世界 + // 前面创建的后台标记任务会开始工作, 所有后台标记任务都完成工作后, 进入完成标记阶段 + systemstack(startTheWorldWithSema) + + // !!!!!!!!!!!!!!! + // 世界已重新启动... + // !!!!!!!!!!!!!!! + + semrelease(&work.startSema) +} + +``` + +### gcBgMarkStartWorkers + +函数gcBgMarkStartWorkers用于启动后台标记任务, 先分别对每个P启动一个: + +``` +func gcBgMarkStartWorkers() { + // Background marking is performed by per-P G's. Ensure that + // each P has a background GC G. + for _, p := range allp { + if p.gcBgMarkWorker == 0 { + go gcBgMarkWorker(p) + + // 启动后等待该任务通知信号量bgMarkReady再继续 + notetsleepg(&work.bgMarkReady, -1) + noteclear(&work.bgMarkReady) + } + } +} + +``` + +这里虽然为每个P启动了一个后台标记任务, 但是可以同时工作的只有25%, 这个逻辑在协程M获取G时调用的findRunnableGCWorker中: + +``` +func (c *gcControllerState) findRunnableGCWorker(_p_ *p) *g { + ... + decIfPositive := func(ptr *int64) bool { + if *ptr > 0 { + if atomic.Xaddint64(ptr, -1) >= 0 { + return true + } + // We lost a race + atomic.Xaddint64(ptr, +1) + } + return false + } + + // 减少dedicatedMarkWorkersNeeded, 成功时后台标记任务的模式是Dedicated + // dedicatedMarkWorkersNeeded是当前P的数量的25%去除小数点 + // 详见startCycle函数 + if decIfPositive(&c.dedicatedMarkWorkersNeeded) { + _p_.gcMarkWorkerMode = gcMarkWorkerDedicatedMode + } else { + // 减少fractionalMarkWorkersNeeded, 成功是后台标记任务的模式是Fractional + // 上面的计算如果小数点后有数值(不能够整除)则fractionalMarkWorkersNeeded为1, 否则为0 + // 详见startCycle函数 + // 举例来说, 4个P时会执行1个Dedicated模式的任务, 5个P时会执行1个Dedicated模式和1个Fractional模式的任务 + if !decIfPositive(&c.fractionalMarkWorkersNeeded) { + // No more workers are need right now. + return nil + } + + _p_.gcMarkWorkerMode = gcMarkWorkerFractionalMode + } + ... + +} + +``` +### stopTheWorldWithSema + +stopTheWorldWithSema函数会停止整个世界, 这个函数必须在g0中运行: + +``` +func stopTheWorldWithSema() { + ... + // 抢占所有运行中的G + preemptall() + ... + // 抢占所有在Psyscall状态的P, 防止它们重新参与调度 + // try to retake all P's in Psyscall status + for i := 0; i < int(gomaxprocs); i++ { + p := allp[i] + s := p.status + if s == _Psyscall && atomic.Cas(&p.status, s, _Pgcstop) { + sched.stopwait-- + } + } + + // 防止所有空闲的P重新参与调度 + for { + p := pidleget() + if p == nil { + break + } + p.status = _Pgcstop + sched.stopwait-- + } + + // 如果仍有需要停止的P, 则等待它们停止 + if wait { + for { + // 循环等待 + 抢占所有运行中的G + // wait for 100us, then try to re-preempt in case of any races + if notetsleep(&sched.stopnote, 100*1000) { + noteclear(&sched.stopnote) + break + } + preemptall() + } + } +} + +func preemptall() bool { + res := false + for _, _p_ := range allp { + if _p_.status != _Prunning { + continue + } + if preemptone(_p_) { + res = true + } + } + return res +} + +func preemptone(_p_ *p) bool { + mp := _p_.m.ptr() + + gp := mp.curg + gp.preempt = true + gp.stackguard0 = stackPreempt + + return true +} + +``` + +当 G 调用新的函数时,stackPreempt 会触发 G 的调度,而在调度函数 schedule 中会调用 gcstopm,将 P 设置为 _Pgcstop 状态,并将 M 进行睡眠,放入空闲 M 队列: + +``` +func schedule() { + ... +top: + if sched.gcwaiting != 0 { + gcstopm() + goto top + } + ... +} + +func gcstopm() { + _g_ := getg() + + if sched.gcwaiting == 0 { + throw("gcstopm: not waiting for gc") + } + if _g_.m.spinning { + _g_.m.spinning = false + // OK to just drop nmspinning here, + // startTheWorld will unpark threads as necessary. + if int32(atomic.Xadd(&sched.nmspinning, -1)) < 0 { + throw("gcstopm: negative nmspinning") + } + } + _p_ := releasep() + lock(&sched.lock) + _p_.status = _Pgcstop + sched.stopwait-- + if sched.stopwait == 0 { + notewakeup(&sched.stopnote) + } + unlock(&sched.lock) + stopm() +} + +func stopm() { + _g_ := getg() + + if _g_.m.locks != 0 { + throw("stopm holding locks") + } + if _g_.m.p != 0 { + throw("stopm holding p") + } + if _g_.m.spinning { + throw("stopm spinning") + } + + lock(&sched.lock) + mput(_g_.m) + unlock(&sched.lock) + notesleep(&_g_.m.park) + noteclear(&_g_.m.park) + acquirep(_g_.m.nextp.ptr()) + _g_.m.nextp = 0 +} +``` + +### finishsweep_m + +finishsweep_m函数会清扫上一轮GC未清扫的span, 确保上一轮GC已完成: + +``` +func finishsweep_m() { + //sweepone会取出一个未sweep的span然后执行sweep + for sweepone() != ^uintptr(0) { + sweep.npausesweep++ + } + + // 所有span都sweep完成后, 启动一个新的markbit时代 + // 这个函数是实现span的gcmarkBits和allocBits的分配和复用的关键, 流程如下 + // - span分配gcmarkBits和allocBits + // - span完成sweep + // - 原allocBits不再被使用 + // - gcmarkBits变为allocBits + // - 分配新的gcmarkBits + // - 开启新的markbit时代 + // - span完成sweep, 同上 + // - 开启新的markbit时代 + // - 2个时代之前的bitmap将不再被使用, 可以复用这些bitmap + nextMarkBitArenaEpoch() +} + +``` + +### gcMarkRootPrepare + +gcMarkRootPrepare函数会计算扫描根对象的任务数量: + +``` +func gcMarkRootPrepare() { + work.nFlushCacheRoots = 0 + + // Compute how many data and BSS root blocks there are. + nBlocks := func(bytes uintptr) int { + return int((bytes + rootBlockBytes - 1) / rootBlockBytes) + } + + work.nDataRoots = 0 + work.nBSSRoots = 0 + + // 计算扫描可读写的全局变量的任务数量 + for _, datap := range activeModules() { + nDataRoots := nBlocks(datap.edata - datap.data) + if nDataRoots > work.nDataRoots { + work.nDataRoots = nDataRoots + } + } + + // 计算扫描只读的全局变量的任务数量 + for _, datap := range activeModules() { + nBSSRoots := nBlocks(datap.ebss - datap.bss) + if nBSSRoots > work.nBSSRoots { + work.nBSSRoots = nBSSRoots + } + } + + // span中的finalizer和各个G的栈每一轮GC只扫描一次 + work.nSpanRoots = mheap_.sweepSpans[mheap_.sweepgen/2%2].numBlocks() + + // 计算扫描各个G的栈的任务数量 + work.nStackRoots = int(atomic.Loaduintptr(&allglen)) + + // 计算总任务数量 + // 后台标记任务会对markrootNext进行原子递增, 来决定做哪个任务 + work.markrootNext = 0 + work.markrootJobs = uint32(fixedRootCount + work.nFlushCacheRoots + work.nDataRoots + work.nBSSRoots + work.nSpanRoots + work.nStackRoots) +} + +``` +### gcMarkTinyAllocs + +gcMarkTinyAllocs函数会标记所有tiny alloc等待合并的对象: + +``` +func gcMarkTinyAllocs() { + for _, p := range &allp { + if p == nil || p.status == _Pdead { + break + } + c := p.mcache + if c == nil || c.tiny == 0 { + continue + } + // 标记各个P中的mcache中的tiny + // 在上面的mallocgc函数中可以看到tiny是当前等待合并的对象 + _, hbits, span, objIndex := heapBitsForObject(c.tiny, 0, 0) + gcw := &p.gcw + // 标记一个对象存活, 并把它加到标记队列(该对象变为灰色) + greyobject(c.tiny, 0, 0, hbits, span, gcw, objIndex) + // gcBlackenPromptly变量表示当前是否禁止本地队列, 如果已禁止则把标记任务flush到全局队列 + if gcBlackenPromptly { + gcw.dispose() + } + } +} + +``` + +### startTheWorldWithSema + +startTheWorldWithSema函数会重新启动世界: + +``` +func startTheWorldWithSema() { + ... + // 判断收到的网络事件(fd可读可写或错误)并添加对应的G到待运行队列 + gp := netpoll(false) // non-blocking + injectglist(gp) + + ... + + // 取消GC等待标记 + sched.gcwaiting = 0 + + // 如果sysmon在等待则唤醒它 + if sched.sysmonwait != 0 { + sched.sysmonwait = 0 + notewakeup(&sched.sysmonnote) + } + unlock(&sched.lock) + + // 唤醒有可运行任务的P + p1 := procresize(procs) + for p1 != nil { + p := p1 + p1 = p1.link.ptr() + if p.m != 0 { + mp := p.m.ptr() + p.m = 0 + + mp.nextp.set(p) + notewakeup(&mp.park) + } else { + // Start M to run P. Do not start another M below. + newm(nil, p) + add = false + } + } + + // 如果有空闲的P,并且没有自旋中的M则唤醒或者创建一个M + if atomic.Load(&sched.npidle) != 0 && atomic.Load(&sched.nmspinning) == 0 { + wakep() + } + + ... +} + +``` + + +重启世界后各个M会重新开始调度, 调度时会优先使用上面提到的findRunnableGCWorker函数查找任务, 之后就有大约25%的P运行后台标记任务. + + +## 后台标记 + +后台标记任务的函数是gcBgMarkWorker + +有三种工作模式: + +- gcMarkWorkerDedicatedMode 无视抢占,一直工作,直到无更多任务 +- gcMarkWorkerFractionalMode 适当工作 +- gcMarkWorkerIdleMode 这个模式下P只在空闲时执行标记 + +``` +func gcBgMarkWorker(_p_ *p) { + ... + + for { + // 让当前G进入休眠 + // Go to sleep until woken by gcController.findRunnable. + // We can't releasem yet since even the call to gopark + // may be preempted. + gopark(func(g *g, parkp unsafe.Pointer) bool { + // 设置关联的P + // 把当前的G设到P的gcBgMarkWorker成员, 下次findRunnableGCWorker会使用 + // 设置失败时不休眠 + if !p.gcBgMarkWorker.cas(0, guintptr(unsafe.Pointer(g))) { + return false + } + } + return true + }, unsafe.Pointer(park), waitReasonGCWorkerIdle, traceEvGoBlock, 0) + + ... + + systemstack(func() { + // 判断后台标记任务的模式 + switch _p_.gcMarkWorkerMode { + default: + throw("gcBgMarkWorker: unexpected gcMarkWorkerMode") + case gcMarkWorkerDedicatedMode: + // 这个模式下P应该专心执行标记 + // 执行标记, 直到被抢占, 并且需要计算后台的扫描量来减少辅助GC和唤醒等待中的G + gcDrain(&_p_.gcw, gcDrainUntilPreempt|gcDrainFlushBgCredit) + + if gp.preempt { + // 被抢占时把本地运行队列中的所有G都踢到全局运行队列 + lock(&sched.lock) + for { + gp, _ := runqget(_p_) + if gp == nil { + break + } + globrunqput(gp) + } + unlock(&sched.lock) + } + + // 继续执行标记, 直到无更多任务, 并且需要计算后台的扫描量来减少辅助GC和唤醒等待中的G + gcDrain(&_p_.gcw, gcDrainFlushBgCredit) + case gcMarkWorkerFractionalMode: + // 这个模式下P应该适当执行标记 + gcDrain(&_p_.gcw, gcDrainFractional|gcDrainUntilPreempt|gcDrainFlushBgCredit) + case gcMarkWorkerIdleMode: + // 这个模式下P只在空闲时执行标记 + gcDrain(&_p_.gcw, gcDrainIdle|gcDrainUntilPreempt|gcDrainFlushBgCredit) + } + casgstatus(gp, _Gwaiting, _Grunning) + }) + + // 判断是否所有后台标记任务都完成, 并且没有更多的任务 + if incnwait == work.nproc && !gcMarkWorkAvailable(nil) { + // 取消和P的关联 + _p_.gcBgMarkWorker.set(nil) + releasem(park.m.ptr()) + + // 准备进入完成标记阶段 + gcMarkDone() + + ... + } + } +} + +``` + +### gcDrain + +gcDrain函数用于执行标记: + +``` +func gcDrain(gcw *gcWork, flags gcDrainFlags) { + gp := getg().m.curg + + // 看到抢占标志时是否要返回 + preemptible := flags&gcDrainUntilPreempt != 0 + + // 是否计算后台的扫描量来减少辅助GC和唤醒等待中的G + flushBgCredit := flags&gcDrainFlushBgCredit != 0 + + // 是否只执行一定量的工作 + idle := flags&gcDrainIdle != 0 + + // 记录初始的已扫描数量 + initScanWork := gcw.scanWork + + // checkWork is the scan work before performing the next + // self-preempt check. + checkWork := int64(1<<63 - 1) + var check func() bool + if flags&(gcDrainIdle|gcDrainFractional) != 0 { + checkWork = initScanWork + drainCheckThreshold + if idle { + check = pollWork + } else if flags&gcDrainFractional != 0 { + check = pollFractionalWorkerExit + } + } + + // 如果根对象未扫描完, 则先扫描根对象 + if work.markrootNext < work.markrootJobs { + + // 如果标记了preemptible, 循环直到被抢占 + for !(preemptible && gp.preempt) { + + // 从根对象扫描队列取出一个值(原子递增) + job := atomic.Xadd(&work.markrootNext, +1) - 1 + if job >= work.markrootJobs { + break + } + + // 执行根对象扫描工作 + markroot(gcw, job) + + // 如果是idle模式并且有其他工作, 则返回 + if check != nil && check() { + goto done + } + } + } + + // 根对象已经在标记队列中, 消费标记队列 + for !(preemptible && gp.preempt) { + // 如果全局标记队列为空, 把本地标记队列的一部分工作分过去 + if work.full == 0 { + gcw.balance() + } + + // 从本地标记队列中获取对象, 获取不到则从全局标记队列获取 + b := gcw.tryGetFast() + if b == 0 { + b = gcw.tryGet() + if b == 0 { + wbBufFlush(nil, 0) + b = gcw.tryGet() + } + } + + // 获取不到对象, 标记队列已为空, 跳出循环 + if b == 0 { + // Unable to get work. + break + } + + // 扫描获取到的对象 + scanobject(b, gcw) + + ... + } +} +``` + +### markroot + +markroot 函数用于执行根对象扫描工作: + +``` +func markroot(gcw *gcWork, i uint32) { + // 判断取出的数值对应哪种任务 + baseFlushCache := uint32(fixedRootCount) + baseData := baseFlushCache + uint32(work.nFlushCacheRoots) + baseBSS := baseData + uint32(work.nDataRoots) + baseSpans := baseBSS + uint32(work.nBSSRoots) + baseStacks := baseSpans + uint32(work.nSpanRoots) + end := baseStacks + uint32(work.nStackRoots) + + // Note: if you add a case here, please also update heapdump.go:dumproots. + switch { + // 释放mcache中的所有span, 要求STW,这一部分在第一次 mark root 的时候,baseFlushCache = baseData + // 只有 mark done 阶段才会真正操作 + case baseFlushCache <= i && i < baseData: + flushmcache(int(i - baseFlushCache)) + + // 扫描可读写的全局变量 + // 这里只会扫描i对应的block, 扫描时传入包含哪里有指针的bitmap数据 + case baseData <= i && i < baseBSS: + for _, datap := range activeModules() { + markrootBlock(datap.data, datap.edata-datap.data, datap.gcdatamask.bytedata, gcw, int(i-baseData)) + } + + // 扫描只读的全局变量 + // 这里只会扫描i对应的block, 扫描时传入包含哪里有指针的bitmap数据 + case baseBSS <= i && i < baseSpans: + for _, datap := range activeModules() { + markrootBlock(datap.bss, datap.ebss-datap.bss, datap.gcbssmask.bytedata, gcw, int(i-baseBSS)) + } + + // 扫描析构器队列 + case i == fixedRootFinalizers: + for fb := allfin; fb != nil; fb = fb.alllink { + cnt := uintptr(atomic.Load(&fb.cnt)) + scanblock(uintptr(unsafe.Pointer(&fb.fin[0])), cnt*unsafe.Sizeof(fb.fin[0]), &finptrmask[0], gcw, nil) + } + + // 释放已中止的G的栈 + case i == fixedRootFreeGStacks: + // Switch to the system stack so we can call + // stackfree. + systemstack(markrootFreeGStacks) + + // 扫描各个span中特殊对象(析构器列表) + case baseSpans <= i && i < baseStacks: + // mark mspan.specials + markrootSpans(gcw, int(i-baseSpans)) + + // 扫描各个G的栈 + default: + // 获取需要扫描的G + var gp *g + if baseStacks <= i && i < end { + gp = allgs[i-baseStacks] + } else { + throw("markroot: bad index") + } + + // 切换到g0运行(有可能会扫到自己的栈) + systemstack(func() { + // 扫描G的栈 + scang(gp, gcw) + }) + } +} +``` + +### scang + +scang 函数负责扫描G的栈,设置preemptscan后, 在抢占G成功时会调用scanstack扫描它自己的栈。 + +``` +func scang(gp *g, gcw *gcWork) { + gp.gcscandone = false + + // See https://golang.org/cl/21503 for justification of the yield delay. + const yieldDelay = 10 * 1000 + var nextYield int64 + + // 循环直到扫描完成 +loop: + for i := 0; !gp.gcscandone; i++ { + // 判断G的当前状态 + switch s := readgstatus(gp); s { + ... + + // G不是运行中, 首先需要防止它运行 + case _Grunnable, _Gsyscall, _Gwaiting: + // 原子切换状态成功时扫描它的栈 + if castogscanstatus(gp, s, s|_Gscan) { + if !gp.gcscandone { + scanstack(gp, gcw) + gp.gcscandone = true + } + restartg(gp) + break loop + } + + // G正在运行 + case _Grunning: + + // 如果已经有抢占请求, 则抢占成功时会帮我们处理 + if gp.preemptscan && gp.preempt && gp.stackguard0 == stackPreempt { + break + } + + /// 抢占G, 抢占成功时G会扫描它自己 + if castogscanstatus(gp, _Grunning, _Gscanrunning) { + if !gp.gcscandone { + gp.preemptscan = true + gp.preempt = true + gp.stackguard0 = stackPreempt + } + casfrom_Gscanstatus(gp, _Gscanrunning, _Grunning) + } + } + + if i == 0 { + nextYield = nanotime() + yieldDelay + } + if nanotime() < nextYield { + procyield(10) + } else { + osyield() + nextYield = nanotime() + yieldDelay/2 + } + } + + gp.preemptscan = false // cancel scan request if no longer needed +} + +``` + +### scanstack + +扫描栈用的函数 + +``` +func scanstack(gp *g, gcw *gcWork) { + ... + + // Scan the saved context register. This is effectively a live + // register that gets moved back and forth between the + // register and sched.ctxt without a write barrier. + if gp.sched.ctxt != nil { + scanblock(uintptr(unsafe.Pointer(&gp.sched.ctxt)), sys.PtrSize, &oneptrmask[0], gcw, &state) + } + + // Scan the stack. Accumulate a list of stack objects. + scanframe := func(frame *stkframe, unused unsafe.Pointer) bool { + // scanframeworker会根据代码地址(pc)获取函数信息 + // 然后找到函数信息中的stackmap.bytedata, 它保存了函数的栈上哪些地方有指针 + // 再调用scanblock来扫描函数的栈空间, 同时函数的参数也会这样扫描 + scanframeworker(frame, &state, gcw) + return true + } + + // 枚举所有调用帧, 分别调用scanframe函数 + gentraceback(^uintptr(0), ^uintptr(0), 0, gp, 0, nil, 0x7fffffff, scanframe, nil, 0) + + // 枚举所有defer的调用帧, 分别调用scanframe函数 + tracebackdefers(gp, scanframe, nil) + + ... +} +``` + +### scanblock + +scanblock函数是一个通用的扫描函数, 扫描全局变量和栈空间都会用它, 和scanobject不同的是bitmap需要手动传入: + +``` +func scanblock(b0, n0 uintptr, ptrmask *uint8, gcw *gcWork, stk *stackScanState) { + // Use local copies of original parameters, so that a stack trace + // due to one of the throws below shows the original block + // base and extent. + b := b0 + n := n0 + + // 枚举扫描的地址 + for i := uintptr(0); i < n; { + // 找到bitmap中对应的byte + bits := uint32(*addb(ptrmask, i/(sys.PtrSize*8))) + if bits == 0 { + i += sys.PtrSize * 8 + continue + } + + for j := 0; j < 8 && i < n; j++ { + if bits&1 != 0 { + // 如果该地址包含指针 + p := *(*uintptr)(unsafe.Pointer(b + i)) + if p != 0 { + // 找到该对象对应的span + if obj, span, objIndex := findObject(p, b, i); obj != 0 { + // 标记一个对象存活, 并把它加到标记队列(该对象变为灰色) + greyobject(obj, b, i, span, gcw, objIndex) + } else if stk != nil && p >= stk.stack.lo && p < stk.stack.hi { + stk.putPtr(p) + } + } + } + bits >>= 1 + i += sys.PtrSize + } + } +} + +``` + +### greyobject + +greyobject用于标记一个对象存活, 并把它加到标记队列(该对象变为灰色): + +``` +func greyobject(obj, base, off uintptr, span *mspan, gcw *gcWork, objIndex uintptr) { + mbits := span.markBitsForIndex(objIndex) + + ... + { + // 如果对象所在的span中的gcmarkBits对应的bit已经设置为1则可以跳过处理 + if mbits.isMarked() { + return + } + + // 设置对象所在的span中的gcmarkBits对应的bit为1 + mbits.setMarked() + + // Mark span. + arena, pageIdx, pageMask := pageIndexOf(span.base()) + if arena.pageMarks[pageIdx]&pageMask == 0 { + atomic.Or8(&arena.pageMarks[pageIdx], pageMask) + } + + // 如果确定对象不包含指针(所在span的类型是noscan), 则不需要把对象放入标记队列 + if span.spanclass.noscan() { + gcw.bytesMarked += uint64(span.elemsize) + return + } + } + + // 把对象放入标记队列 + // 先放入本地标记队列, 失败时把本地标记队列中的部分工作转移到全局标记队列, 再放入本地标记队列 + if !gcw.putFast(obj) { + gcw.put(obj) + } +} + +``` + + +## gcMarkDone 完成标记 + +在所有后台标记任务都把标记队列消费完毕时, 会执行gcMarkDone函数准备进入完成标记阶段(mark termination). + +在并行GC中gcMarkDone会被执行两次, 第一次会禁止本地标记队列然后重新开始后台标记任务, 第二次会进入完成标记阶段(mark termination)。 + +``` +func gcMarkDone() { + // Ensure only one thread is running the ragged barrier at a + // time. + semacquire(&work.markDoneSema) + +top: + // 循环直到所有的标记队列中的对象都标记完毕 + gcMarkDoneFlushed = 0 + systemstack(func() { + casgstatus(gp, _Grunning, _Gwaiting) + forEachP(func(_p_ *p) { + // Flush the write barrier buffer, since this may add + // work to the gcWork. + wbBufFlush1(_p_) + + // 把所有本地标记队列中的对象都推到全局标记队列 + _p_.gcw.dispose() + + if _p_.gcw.flushedWork { + atomic.Xadd(&gcMarkDoneFlushed, 1) + _p_.gcw.flushedWork = false + } + ... + }) + casgstatus(gp, _Gwaiting, _Grunning) + }) + + // 继续循环 + if gcMarkDoneFlushed != 0 { + goto top + } + + // 停止所有运行中的G, 并禁止它们运行 + systemstack(stopTheWorldWithSema) + + // 禁止辅助GC和后台标记任务的运行 + atomic.Store(&gcBlackenEnabled, 0) + + // 唤醒所有因为辅助GC而休眠的G + gcWakeAllAssists() + + // Likewise, release the transition lock. Blocked + // workers and assists will run when we start the + // world again. + semrelease(&work.markDoneSema) + + // In STW mode, re-enable user goroutines. These will be + // queued to run after we start the world. + schedEnableUser(true) + + // 计算下一次触发gc需要的heap大小 + nextTriggerRatio := gcController.endCycle() + + // 进入完成标记阶段, 会重新启动世界 + gcMarkTermination(nextTriggerRatio) +} +``` + +### gcMarkTermination + + +``` +func gcMarkTermination(nextTriggerRatio float64) { + ... + + systemstack(func() { + // 开始STW中的标记 + gcMark(startTime) + }) + + systemstack(func() { + work.heap2 = work.bytesMarked + + // 设置当前GC阶段到关闭, 并禁用写屏障 + setGCPhase(_GCoff) + + // 唤醒后台清扫任务, 将在STW结束后开始运行 + gcSweep(work.mode) + }) + + _g_.m.traceback = 0 + casgstatus(gp, _Gwaiting, _Grunning) + + // Update GC trigger and pacing for the next cycle. + gcSetTriggerRatio(nextTriggerRatio) + + systemstack(func() { startTheWorldWithSema(true) }) + + // Free stack spans. This must be done between GC cycles. + systemstack(freeStackSpans) + + systemstack(func() { + forEachP(func(_p_ *p) { + _p_.mcache.prepareForSweep() + }) + }) + + semrelease(&worldsema) + // Careful: another GC cycle may start now. + + releasem(mp) + mp = nil + + ... +} + +``` + +## gcSweep + +``` +func gcSweep(mode gcMode) { + // 增加sweepgen, 这样sweepSpans中两个队列角色会交换, 所有span都会变为"待清扫"的span + lock(&mheap_.lock) + mheap_.sweepgen += 2 + mheap_.sweepdone = 0 + if mheap_.sweepSpans[mheap_.sweepgen/2%2].index != 0 { + throw("non-empty swept list") + } + + mheap_.pagesSwept = 0 + mheap_.sweepArenas = mheap_.allArenas + mheap_.reclaimIndex = 0 + mheap_.reclaimCredit = 0 + unlock(&mheap_.lock) + + ... + + // 唤醒后台清扫任务 + lock(&sweep.lock) + if sweep.parked { + sweep.parked = false + ready(sweep.g, 0, true) + } + unlock(&sweep.lock) +} + +``` +每次从 mheap 中获取一个新的 free mspan 后,都会被放入 h.sweepSpans 中,等待着 gc 的清扫工作。 + +``` +func (h *mheap) alloc_m(npage uintptr, spanclass spanClass, large bool) *mspan { + ... + + s := h.allocSpanLocked(npage, &memstats.heap_inuse) + if s != nil { + ... + h.sweepSpans[h.sweepgen/2%2].push(s) // Add to swept in-use list. + s.state = mSpanInUse + ... + } + +``` + +### bgsweep + +后台清扫任务的函数是bgsweep: + +``` +func bgsweep(c chan int) { + sweep.g = getg() + + lock(&sweep.lock) + sweep.parked = true + c <- 1 + goparkunlock(&sweep.lock, waitReasonGCSweepWait, traceEvGoBlock, 1) + + for { + // 清扫一个span, 然后进入调度(一次只做少量工作) + for sweepone() != ^uintptr(0) { + sweep.nbgsweep++ + Gosched() + } + + // 释放一些未使用的标记队列缓冲区到heap + for freeSomeWbufs(true) { + Gosched() + } + + // 如果清扫未完成则继续循环 + lock(&sweep.lock) + if !isSweepDone() { + // This can happen if a GC runs between + // gosweepone returning ^0 above + // and the lock being acquired. + unlock(&sweep.lock) + continue + } + + // 否则让后台清扫任务进入休眠, 当前M继续调度 + sweep.parked = true + goparkunlock(&sweep.lock, waitReasonGCSweepWait, traceEvGoBlock, 1) + } +} + +``` + +### sweepone + + +``` +func sweepone() uintptr { + ... + + // Find a span to sweep. + var s *mspan + sg := mheap_.sweepgen + for { + // 从sweepSpans中取出一个span + s = mheap_.sweepSpans[1-sg/2%2].pop() + + // 全部清扫完毕时跳出循环 + if s == nil { + atomic.Store(&mheap_.sweepdone, 1) + break + } + + // 原子增加span的sweepgen, 成功表示已经成功更改状态,跳出循环,开始清扫 + if s.sweepgen == sg-2 && atomic.Cas(&s.sweepgen, sg-2, sg-1) { + break + } + } + + // 清扫这个span, 然后跳出循环 + npages := ^uintptr(0) + if s != nil { + npages = s.npages + if s.sweep(false) { + // Whole span was freed. Count it toward the + // page reclaimer credit since these pages can + // now be used for span allocation. + atomic.Xadduintptr(&mheap_.reclaimCredit, npages) + } else { + // Span is still in-use, so this returned no + // pages to the heap and the span needs to + // move to the swept in-use list. + npages = 0 + } + } + + ... + + _g_.m.locks-- + return npages +} + +``` + +### sweep + + +``` +func (s *mspan) sweep(preserve bool) bool { + ... + + // 计算释放的对象数量 + // s.countAlloc 会根据 gcmarkBits 返回 mspan 活跃的对象个数 + nalloc := uint16(s.countAlloc()) + if spc.sizeclass() == 0 && nalloc == 0 { + // 如果span的类型是0(大对象)并且其中的对象已经不存活则释放到heap + s.needzero = 1 + freeToHeap = true + } + nfreed := s.allocCount - nalloc + + // 设置新的allocCount + s.allocCount = nalloc + + // 判断span是否无未分配的对象 + wasempty := s.nextFreeIndex() == s.nelems + + // 重置freeindex, 下次分配从0开始搜索 + s.freeindex = 0 // reset allocation index to start of span. + + // gcmarkBits变为新的allocBits + // 然后重新分配一块全部为0的gcmarkBits + // 下次分配对象时可以根据allocBits得知哪些元素是未分配的 + s.allocBits = s.gcmarkBits + s.gcmarkBits = newMarkBits(s.nelems) + + // 更新freeindex开始的allocCache + s.refillAllocCache(0) + + // 如果span中已经无存活的对象则更新sweepgen到最新 + // 下面会把span加到mcentral或者mheap + if freeToHeap || nfreed == 0 { + atomic.Store(&s.sweepgen, sweepgen) + } + + if nfreed > 0 && spc.sizeclass() != 0 { + // 把span加到mcentral, res等于是否添加成功 + c.local_nsmallfree[spc.sizeclass()] += uintptr(nfreed) + res = mheap_.central[spc].mcentral.freeSpan(s, preserve, wasempty) + // mcentral.freeSpan updates sweepgen + } else if freeToHeap { + // 把span释放到mheap + + mheap_.freeSpan(s, true) + c.local_nlargefree++ + c.local_largefree += size + res = true + } + + // 如果span未加到mcentral或者未释放到mheap, 则表示span仍在使用 + if !res { + // The span has been swept and is still in-use, so put + // it on the swept in-use list. + mheap_.sweepSpans[sweepgen/2%2].push(s) + } + return res +} + + +``` + +### mcentral.freeSpan + +本函数查看 mspan 中的对象是否已经完全释放,如果已经完全释放,那么将其放回到 heap 中,否则还是放到 nonempty 链表中去。 + +``` +func (c *mcentral) freeSpan(s *mspan, preserve bool, wasempty bool) bool { + if sg := mheap_.sweepgen; s.sweepgen == sg+1 || s.sweepgen == sg+3 { + throw("freeSpan given cached span") + } + s.needzero = 1 + + if preserve { + // preserve is set only when called from (un)cacheSpan above, + // the span must be in the empty list. + if !s.inList() { + throw("can't preserve unlinked span") + } + atomic.Store(&s.sweepgen, mheap_.sweepgen) + return false + } + + lock(&c.lock) + + // Move to nonempty if necessary. + if wasempty { + c.empty.remove(s) + c.nonempty.insert(s) + } + + // delay updating sweepgen until here. This is the signal that + // the span may be used in an mcache, so it must come after the + // linked list operations above (actually, just after the + // lock of c above.) + atomic.Store(&s.sweepgen, mheap_.sweepgen) + + if s.allocCount != 0 { + unlock(&c.lock) + return false + } + + c.nonempty.remove(s) + unlock(&c.lock) + mheap_.freeSpan(s, false) + return true +} +``` + + + + diff --git "a/Go \347\263\273\347\273\237\350\260\203\347\224\250.md" "b/Go \347\263\273\347\273\237\350\260\203\347\224\250.md" new file mode 100644 index 0000000..0d477b6 --- /dev/null +++ "b/Go \347\263\273\347\273\237\350\260\203\347\224\250.md" @@ -0,0 +1,303 @@ +# Go 系统调用 + +[TOC] + +## 入口 + +syscall 有下面几个入口,在 `syscall/asm_linux_amd64.s` 中。 + +```go +func Syscall(trap, a1, a2, a3 uintptr) (r1, r2 uintptr, err syscall.Errno) + +func Syscall6(trap, a1, a2, a3, a4, a5, a6 uintptr) (r1, r2 uintptr, err syscall.Errno) + +func RawSyscall(trap, a1, a2, a3 uintptr) (r1, r2 uintptr, err syscall.Errno) + +func RawSyscall6(trap, a1, a2, a3, a4, a5, a6 uintptr) (r1, r2 uintptr, err syscall.Errno) +``` + +这些函数的实现都是汇编,按照 linux 的 syscall 调用规范,我们只要在汇编中把参数依次传入寄存器,并调用 SYSCALL 指令即可进入内核处理逻辑,系统调用执行完毕之后,返回值放在 RAX 中: + + +| RDI | RSI | RDX | R10 | R8 | R9 | RAX| +|---|---|---|---|---| ---|---| +|参数一|参数二|参数三|参数四|参数五|参数六|系统调用编号/返回值| + +Syscall 和 Syscall6 的区别只有传入参数不一样: + +```go +// func Syscall(trap int64, a1, a2, a3 uintptr) (r1, r2, err uintptr); +TEXT ·Syscall(SB),NOSPLIT,$0-56 + CALL runtime·entersyscall(SB) + MOVQ a1+8(FP), DI + MOVQ a2+16(FP), SI + MOVQ a3+24(FP), DX + MOVQ $0, R10 + MOVQ $0, R8 + MOVQ $0, R9 + MOVQ trap+0(FP), AX // syscall entry + SYSCALL + // 0xfffffffffffff001 是 linux MAX_ERRNO 取反 转无符号,http://lxr.free-electrons.com/source/include/linux/err.h#L17 + CMPQ AX, $0xfffffffffffff001 + JLS ok + MOVQ $-1, r1+32(FP) + MOVQ $0, r2+40(FP) + NEGQ AX + MOVQ AX, err+48(FP) + CALL runtime·exitsyscall(SB) + RET +ok: + MOVQ AX, r1+32(FP) + MOVQ DX, r2+40(FP) + MOVQ $0, err+48(FP) + CALL runtime·exitsyscall(SB) + RET + +// func Syscall6(trap, a1, a2, a3, a4, a5, a6 uintptr) (r1, r2, err uintptr) +TEXT ·Syscall6(SB),NOSPLIT,$0-80 + CALL runtime·entersyscall(SB) + MOVQ a1+8(FP), DI + MOVQ a2+16(FP), SI + MOVQ a3+24(FP), DX + MOVQ a4+32(FP), R10 + MOVQ a5+40(FP), R8 + MOVQ a6+48(FP), R9 + MOVQ trap+0(FP), AX // syscall entry + SYSCALL + CMPQ AX, $0xfffffffffffff001 + JLS ok6 + MOVQ $-1, r1+56(FP) + MOVQ $0, r2+64(FP) + NEGQ AX + MOVQ AX, err+72(FP) + CALL runtime·exitsyscall(SB) + RET +ok6: + MOVQ AX, r1+56(FP) + MOVQ DX, r2+64(FP) + MOVQ $0, err+72(FP) + CALL runtime·exitsyscall(SB) + RET +``` + +两个函数没什么大区别,为啥不用一个呢?个人猜测,Go 的函数参数都是栈上传入,可能是为了节省一点栈空间。。在正常的 Syscall 操作之前会通知 runtime,接下来我要进行 syscall 操作了 `runtime·entersyscall`,退出时会调用 `runtime·exitsyscall`。 + +```go +// func RawSyscall(trap, a1, a2, a3 uintptr) (r1, r2, err uintptr) +TEXT ·RawSyscall(SB),NOSPLIT,$0-56 + MOVQ a1+8(FP), DI + MOVQ a2+16(FP), SI + MOVQ a3+24(FP), DX + MOVQ $0, R10 + MOVQ $0, R8 + MOVQ $0, R9 + MOVQ trap+0(FP), AX // syscall entry + SYSCALL + CMPQ AX, $0xfffffffffffff001 + JLS ok1 + MOVQ $-1, r1+32(FP) + MOVQ $0, r2+40(FP) + NEGQ AX + MOVQ AX, err+48(FP) + RET +ok1: + MOVQ AX, r1+32(FP) + MOVQ DX, r2+40(FP) + MOVQ $0, err+48(FP) + RET + +// func RawSyscall6(trap, a1, a2, a3, a4, a5, a6 uintptr) (r1, r2, err uintptr) +TEXT ·RawSyscall6(SB),NOSPLIT,$0-80 + MOVQ a1+8(FP), DI + MOVQ a2+16(FP), SI + MOVQ a3+24(FP), DX + MOVQ a4+32(FP), R10 + MOVQ a5+40(FP), R8 + MOVQ a6+48(FP), R9 + MOVQ trap+0(FP), AX // syscall entry + SYSCALL + CMPQ AX, $0xfffffffffffff001 + JLS ok2 + MOVQ $-1, r1+56(FP) + MOVQ $0, r2+64(FP) + NEGQ AX + MOVQ AX, err+72(FP) + RET +ok2: + MOVQ AX, r1+56(FP) + MOVQ DX, r2+64(FP) + MOVQ $0, err+72(FP) + RET +``` + +RawSyscall 和 Syscall 的区别也非常微小,就只是在进入 Syscall 和退出的时候没有通知 runtime,这样 runtime 理论上是没有办法通过调度把这个 g 的 m 的 p 调度走的,所以如果用户代码使用了 RawSyscall 来做一些阻塞的系统调用,是有可能阻塞其它的 g 的,下面是官方开发的原话: + +> Yes, if you call RawSyscall you may block other goroutines from running. The system monitor may start them up after a while, but I think there are cases where it won't. I would say that Go programs should always call Syscall. RawSyscall exists to make it slightly more efficient to call system calls that never block, such as getpid. But it's really an internal mechanism. + +RawSyscall 只是为了在执行那些一定不会阻塞的系统调用时,能节省两次对 runtime 的函数调用消耗。 + +### vdso + +vdso 可以认为是一种特殊的调用,在使用时,没有本文开头的用户态到内核态的切换,引用一段参考资料: + +> 用来执行特定的系统调用,减少系统调用的开销。某些系统调用并不会向内核提交参数,而仅仅只是从内核里请求读取某个数据,例如gettimeofday(),内核在处理这部分系统调用时可以把系统当前时间写在一个固定的位置(由内核在每个时间中断里去完成这个更新动作),mmap映射到用户空间。这样会更快速,避免了传统系统调用模式INT 0x80/SYSCALL造成的内核空间和用户空间的上下文切换。 + +```go +// func gettimeofday(tv *Timeval) (err uintptr) +TEXT ·gettimeofday(SB),NOSPLIT,$0-16 + MOVQ tv+0(FP), DI + MOVQ $0, SI + MOVQ runtime·__vdso_gettimeofday_sym(SB), AX + CALL AX + + CMPQ AX, $0xfffffffffffff001 + JLS ok7 + NEGQ AX + MOVQ AX, err+8(FP) + RET +ok7: + MOVQ $0, err+8(FP) + RET +``` + +## 系统调用管理 + +先是系统调用的定义文件: + +```shell +/syscall/syscall_linux.go +``` + +可以把系统调用分为三类: + +1. 阻塞系统调用 +2. 非阻塞系统调用 +3. wrapped 系统调用 + +以 Madvise 为例,阻塞系统调用会定义成下面这样的形式: + +```go +//sys Madvise(b []byte, advice int) (err error) +``` + +EpollCreate 为例,非阻塞系统调用: + +```go +//sysnb EpollCreate(size int) (fd int, err error) +``` + +然后,根据这些注释,mksyscall.pl 脚本会生成对应的平台的具体实现。mksyscall.pl 是一段 perl 脚本,感兴趣的同学可以自行查看,这里就不再赘述了。 + +看看阻塞和非阻塞的系统调用的生成结果: + +```go +func Madvise(b []byte, advice int) (err error) { + var _p0 unsafe.Pointer + if len(b) > 0 { + _p0 = unsafe.Pointer(&b[0]) + } else { + _p0 = unsafe.Pointer(&_zero) + } + _, _, e1 := Syscall(SYS_MADVISE, uintptr(_p0), uintptr(len(b)), uintptr(advice)) + if e1 != 0 { + err = errnoErr(e1) + } + return +} + +func EpollCreate(size int) (fd int, err error) { + r0, _, e1 := RawSyscall(SYS_EPOLL_CREATE, uintptr(size), 0, 0) + fd = int(r0) + if e1 != 0 { + err = errnoErr(e1) + } + return +} +``` + +显然,标记为 sys 的系统调用使用的是 Syscall 或者 Syscall6,标记为 sysnb 的系统调用使用的是 RawSyscall 或 RawSyscall6。 + +wrapped 的系统调用是怎么一回事呢? + +```go +func Rename(oldpath string, newpath string) (err error) { + return Renameat(_AT_FDCWD, oldpath, _AT_FDCWD, newpath) +} +``` + +可能是觉得系统调用的名字不太好,或者参数太多,我们就简单包装一下。没啥特别的。 + +## runtime 中的 SYSCALL + +除了上面提到的阻塞非阻塞和 wrapped syscall,runtime 中还定义了一些 low-level 的 syscall,这些是不暴露给用户的。 + +提供给用户的 syscall 库,在使用时,会使 goroutine 和 p 分别进入 Gsyscall 和 Psyscall 状态。但 runtime 自己封装的这些 syscall 无论是否阻塞,都不会调用 entersyscall 和 exitsyscall。 虽说是 “low-level” 的 syscall, +不过和暴露给用户的 syscall 本质是一样的。这些代码在 `runtime/sys_linux_amd64.s` 中,举个具体的例子: + +```go +TEXT runtime·write(SB),NOSPLIT,$0-28 + MOVQ fd+0(FP), DI + MOVQ p+8(FP), SI + MOVL n+16(FP), DX + MOVL $SYS_write, AX + SYSCALL + CMPQ AX, $0xfffffffffffff001 + JLS 2(PC) + MOVL $-1, AX + MOVL AX, ret+24(FP) + RET + +TEXT runtime·read(SB),NOSPLIT,$0-28 + MOVL fd+0(FP), DI + MOVQ p+8(FP), SI + MOVL n+16(FP), DX + MOVL $SYS_read, AX + SYSCALL + CMPQ AX, $0xfffffffffffff001 + JLS 2(PC) + MOVL $-1, AX + MOVL AX, ret+24(FP) + RET +``` + +下面是所有 runtime 另外定义的 syscall 列表: + +```go +#define SYS_read 0 +#define SYS_write 1 +#define SYS_open 2 +#define SYS_close 3 +#define SYS_mmap 9 +#define SYS_munmap 11 +#define SYS_brk 12 +#define SYS_rt_sigaction 13 +#define SYS_rt_sigprocmask 14 +#define SYS_rt_sigreturn 15 +#define SYS_access 21 +#define SYS_sched_yield 24 +#define SYS_mincore 27 +#define SYS_madvise 28 +#define SYS_setittimer 38 +#define SYS_getpid 39 +#define SYS_socket 41 +#define SYS_connect 42 +#define SYS_clone 56 +#define SYS_exit 60 +#define SYS_kill 62 +#define SYS_fcntl 72 +#define SYS_getrlimit 97 +#define SYS_sigaltstack 131 +#define SYS_arch_prctl 158 +#define SYS_gettid 186 +#define SYS_tkill 200 +#define SYS_futex 202 +#define SYS_sched_getaffinity 204 +#define SYS_epoll_create 213 +#define SYS_exit_group 231 +#define SYS_epoll_wait 232 +#define SYS_epoll_ctl 233 +#define SYS_pselect6 270 +#define SYS_epoll_create1 291 +``` + +这些 syscall 理论上都是不会在执行期间被调度器剥离掉 p 的,所以执行成功之后 goroutine 会继续执行,而不像用户的 goroutine 一样,若被剥离 p 会进入等待队列。 \ No newline at end of file diff --git "a/Go \347\275\221\347\273\234\350\260\203\347\224\250 netpoll.md" "b/Go \347\275\221\347\273\234\350\260\203\347\224\250 netpoll.md" new file mode 100644 index 0000000..6cecf9e --- /dev/null +++ "b/Go \347\275\221\347\273\234\350\260\203\347\224\250 netpoll.md" @@ -0,0 +1,1939 @@ +# Go 网络调用 netpoll + +[TOC] + +## 初始 + +socket,connect,listen,getsockopt 都有一个全局函数变量来表示。 + +hook_unix.go : + +```go + // Placeholders for socket system calls. + socketFunc func(int, int, int) (int, error) = syscall.Socket + connectFunc func(int, syscall.Sockaddr) error = syscall.Connect + listenFunc func(int, int) error = syscall.Listen + getsockoptIntFunc func(int, int, int) (int, error) = syscall.GetsockoptInt +``` + +这些 hook 主要是为了能够写测试,在测试代码中,socketFunc,connectFunc ... 都会被替换成测试专用函数,main_unix_test.go: + +```go +func installTestHooks() { + socketFunc = sw.Socket + poll.CloseFunc = sw.Close + connectFunc = sw.Connect + listenFunc = sw.Listen + poll.AcceptFunc = sw.Accept + getsockoptIntFunc = sw.GetsockoptInt + + for _, fn := range extraTestHookInstallers { + fn() + } +} +``` + +用这种全局函数 hook,或者叫注册表的方式,可以实现类似于面向对象中的 interface 功能。不过因为不同平台提供的网络编程函数差别有些大,所以这里这些全局网络函数也就只是用来方便测试。 + +## 数据结构 + +### 编程接口 + +``` +func Listen(net, laddr string) (Listener, error) +func (*TCPListener) Accept (c Conn, err error) + +func Dial(network, address string) (Conn, error) + +func (c *conn) Read(b []byte) (int, error) +func (c *conn) Write(b []byte) (int, error) + +``` + +可以看到,对外接口重要的数据结构就是 `Listener`、`Conn`,一个是用户监听的描述符,一个是 `accept` 返回的用于读写的描述符。 + +### Listener 接口与 TCPListener + +#### Listener 接口 + +``` +type Listener interface { + // Accept waits for and returns the next connection to the listener. + Accept() (Conn, error) + + // Close closes the listener. + // Any blocked Accept operations will be unblocked and return errors. + Close() error + + // Addr returns the listener's network address. + Addr() Addr +} + +``` + +为了了解这个接口,我们先从 `Listen` 这个函数开始: + +``` +func Listen(network, address string) (Listener, error) { + var lc ListenConfig + return lc.Listen(context.Background(), network, address) +} + +func (lc *ListenConfig) Listen(ctx context.Context, network, address string) (Listener, error) { + addrs, err := DefaultResolver.resolveAddrList(ctx, "listen", network, address, nil) + + sl := &sysListener{ + ListenConfig: *lc, + network: network, + address: address, + } + var l Listener + la := addrs.first(isIPv4) + + switch la := la.(type) { + case *TCPAddr: + l, err = sl.listenTCP(ctx, la) + case *UnixAddr: + l, err = sl.listenUnix(ctx, la) + } + + return l, nil +} + +``` +#### ListenConfig + +`ListenConfig` 是一个用于配置 `Listener` 的数据结构: + +``` +type ListenConfig struct { + Control func(network, address string, c syscall.RawConn) error + + KeepAlive time.Duration +} + + +func (lc *ListenConfig) Listen(ctx context.Context, network, address string) (Listener, error) + +func (lc *ListenConfig) ListenPacket(ctx context.Context, network, address string) (PacketConn, error) +``` + +它仅仅有两个函数,分别用于 stream 数据与 datagram 数据。结构体里面的两个成语变量,一个用于在 bind 之前初始化用于 listen 的文件描述符,一个用于控制 accept 之后新的连接的 `KeepAlive` 属性。 + +#### sysListener + +接下来,就是 sysListener 这个数据结构: + +``` +type sysListener struct { + ListenConfig + network, address string +} +``` +它专门用于为各种网络类型建立连接: + +``` +func (sl *sysListener) listenTCP(ctx context.Context, laddr *TCPAddr) (*TCPListener, error) + +func (sl *sysListener) listenUDP(ctx context.Context, laddr *UDPAddr) (*UDPConn, error) + +func (sl *sysListener) listenUnix(ctx context.Context, laddr *UnixAddr) (*UnixListener, error) + +func (sl *sysListener) listenUnixgram(ctx context.Context, laddr *UnixAddr) (*UnixConn, error) + +func (sl *sysListener) listenIP(ctx context.Context, laddr *IPAddr) (*IPConn, error) + +``` + +#### TCPListener + +对于 TCP 的连接来说,最重要的那就是 `TCPListener`: + +``` +func (sl *sysListener) listenTCP(ctx context.Context, laddr *TCPAddr) (*TCPListener, error) { + ... + return &TCPListener{fd: fd, lc: sl.ListenConfig}, nil +} + +type TCPListener struct { + fd *netFD + lc ListenConfig +} + +``` + +我们暂时先不需要了解 internetSocket 建立监听描述符的过程,只关注总体的流程。下面我们看看拿到 TCPListener 后,使用 accept 函数的过程。 + +### Conn 接口与 TCPConn + +#### Conn 接口 + +我们先开始研究编程接口: + +``` +func (*TCPListener) Accept (c Conn, err error) + +``` + +我们发现该函数返回的是 Conn 的类型,实际上它也是一个接口: + + +``` +type Conn interface { + Read(b []byte) (n int, err error) + + Write(b []byte) (n int, err error) + + Close() error + + LocalAddr() Addr + + RemoteAddr() Addr + + SetDeadline(t time.Time) error + + SetReadDeadline(t time.Time) error + + SetWriteDeadline(t time.Time) error +} + +``` + +#### TCPConn + +TCPListener 的 Accept 函数正是 Listener 接口的 Accept 实现。 + +``` +func (l *TCPListener) Accept() (Conn, error) { + c, err := l.accept() + + return c, nil +} + +func (ln *TCPListener) accept() (*TCPConn, error) { + fd, err := ln.fd.accept() + + tc := newTCPConn(fd) + if ln.lc.KeepAlive >= 0 { + setKeepAlive(fd, true) + + ka := ln.lc.KeepAlive + if ln.lc.KeepAlive == 0 { + ka = defaultTCPKeepAlive + } + + setKeepAlivePeriod(fd, ka) + } + + return tc, nil +} + +type TCPConn struct { + conn +} + +type conn struct { + fd *netFD +} +``` + +上文所说的 ListenConfig 长连接的配置,在这里的 accept 之后新生成的 fd 上进行设置。 + +我们看到实际上,TCPListener 返回的是 TCPConn 类型,它实现了 Conn 接口。 + +### Dialer 客户端 + +``` +type Dialer struct { + Timeout time.Duration + + Deadline time.Time + + // 真正dial时的本地地址,兼容各种类型(TCP、UDP...),如果为nil,则系统自动选择一个地址 + LocalAddr Addr + + DualStack bool // 双协议栈,即是否同时支持ipv4和ipv6.当network值为tcp时,dial函数会向host主机的v4和v6地址都发起连接 + + FallbackDelay time.Duration // 当DualStack为真,ipv6会延后于ipv4发起,此字段即为延迟时间,默认为300ms + + KeepAlive time.Duration + + Cancel <-chan struct{} // 用于取消dial + + Control func(network, address string, c syscall.RawConn) error +} + +func Dial(network, address string) (Conn, error) + +``` + +### netFD + +服务端通过 Listen 方法返回的 Listener 接口的实现和通过 listener 的 Accept 方法返回的 Conn 接口的实现都包含一个网络文件描述符 netFD, + +netFD 中包含一个 poll.FD 数据结构,而 poll.FD 中包含两个重要的数据结构 Sysfd 和 pollDesc,前者是真正的系统文件描述符,后者对是底层事件驱动的封装,所有的读写超时等操作都是通过调用后者的对应方法实现的。 + +- 服务端的netFD在listen时会创建epoll的实例,并将listenFD加入epoll的事件队列 +- netFD在accept时将返回的connFD也加入epoll的事件队列 +- netFD在读写时出现syscall.EAGAIN错误,通过pollDesc将当前的goroutine park住,直到ready,从pollDesc的waitRead中返回 + +pollDesc 包含两个二元信号量, rg 和 wg, 分别用来 park 读、写的 goroutine +信号量可以是下面几种状态: + +- nil:初始化状态 +- pdWait:当前连接 fd 描述符已经添加到 poller,在 gopark 之前设置 +- G pointer:gopark 调用 mcall 切换到 m0 后,pdWait 被替换为当前正在等待的 Goroutine +- pdReady: netpoll 已经通知完毕,程序已经将 rg、wg 的 G 链表取出 + +```go +const ( + pdReady uintptr = 1 + pdWait uintptr = 2 +) + +const pollBlockSize = 4 * 1024 + +// Network file descriptor. +type netFD struct { + pfd poll.FD + + // 下面这些元素在 Close 之前都是不可变的 + family int + sotype int + + isConnected bool + net string + + laddr Addr + raddr Addr +} + +// FD 是对 file descriptor 的一个包装,内部的 Sysfd 就是 linux 下的 +// file descriptor。net 和 os 包中使用这个类型来代表一个网络连接或者一个 OS 文件 +type FD struct { + // 对 sysfd 加锁,以使 Read 和 Write 方法串行执行 + fdmu fdMutex + + // 操作系统的 file descriptor。在关闭之前是不可变的 + Sysfd int + + // I/O poller. + pd pollDesc + + // 不可变。表示当前这个 fd 是否是一个流,或者是一个基于包的 fd + // 用来区分是 TCP 还是 UDP + IsStream bool + + // 这个文件是否被设置为了 blocking 模式 + isBlocking bool +} + +type pollDesc struct { + link *pollDesc // in pollcache, protected by pollcache.lock + + lock mutex // protects the following fields + fd uintptr + + rg uintptr // pdReady, pdWait, G waiting for read or nil + rt timer // read deadline timer (set if rt.f != nil) + rd int64 // read deadline + + wg uintptr // pdReady, pdWait, G waiting for write or nil + wt timer // write deadline timer + wd int64 // write deadline +} +``` + +## listen 流程 + +### Listen + +listen 的入口函数就是 Listen: + +```go +func Listen(network, address string) (Listener, error) { + var lc ListenConfig + return lc.Listen(context.Background(), network, address) +} + +func (lc *ListenConfig) Listen(ctx context.Context, network, address string) (Listener, error) { + addrs, err := DefaultResolver.resolveAddrList(ctx, "listen", network, address, nil) + + sl := &sysListener{ + ListenConfig: *lc, + network: network, + address: address, + } + var l Listener + la := addrs.first(isIPv4) + + switch la := la.(type) { + case *TCPAddr: + l, err = sl.listenTCP(ctx, la) + case *UnixAddr: + l, err = sl.listenUnix(ctx, la) + } + + return l, nil +} + +``` + +```go +func (sl *sysListener) listenTCP(ctx context.Context, laddr *TCPAddr) (*TCPListener, error) { + fd, err := internetSocket(ctx, sl.network, laddr, nil, syscall.SOCK_STREAM, 0, "listen", sl.ListenConfig.Control) + if err != nil { + return nil, err + } + return &TCPListener{fd: fd, lc: sl.ListenConfig}, nil +} +``` + +```go +func internetSocket(ctx context.Context, net string, laddr, raddr sockaddr, sotype, proto int, mode string) (fd *netFD, err error) { + if (runtime.GOOS == "windows" || runtime.GOOS == "openbsd" || runtime.GOOS == "nacl") && mode == "dial" && raddr.isWildcard() { + raddr = raddr.toLocal(net) + } + family, ipv6only := favoriteAddrFamily(net, laddr, raddr, mode) + return socket(ctx, net, family, sotype, proto, ipv6only, laddr, raddr) +} +``` + +### socket 创建套接字 + +这个函数非常重要,它首先使用 sysSocket 进行系统调用,创建一个监听套接字。 + +然后使用 newFD 创建一个 netFD 类型的对象。 + +调用 fd.listenStream 执行监听系统调用。 + +```go +// socket returns a network file descriptor that is ready for +// asynchronous I/O using the network poller. +func socket(ctx context.Context, net string, family, sotype, proto int, ipv6only bool, laddr, raddr sockaddr) (fd *netFD, err error) { + s, err := sysSocket(family, sotype, proto) + if err != nil { + return nil, err + } + if err = setDefaultSockopts(s, family, sotype, ipv6only); err != nil { + poll.CloseFunc(s) + return nil, err + } + if fd, err = newFD(s, family, sotype, net); err != nil { + poll.CloseFunc(s) + return nil, err + } + + if laddr != nil && raddr == nil { + switch sotype { + // 基于流的协议 + case syscall.SOCK_STREAM, syscall.SOCK_SEQPACKET: + if err := fd.listenStream(laddr, listenerBacklog); err != nil { + fd.Close() + return nil, err + } + return fd, nil + // 基于数据报的协议 + case syscall.SOCK_DGRAM: + if err := fd.listenDatagram(laddr); err != nil { + fd.Close() + return nil, err + } + return fd, nil + } + } + if err := fd.dial(ctx, laddr, raddr); err != nil { + fd.Close() + return nil, err + } + return fd, nil +} +``` + +### listenStream 执行 listen 系统调用 + +这个函数中的 listenFunc 执行 listen 系统调用。 + +```go +func (fd *netFD) listenStream(laddr sockaddr, backlog int) error { + if err := setDefaultListenerSockopts(fd.pfd.Sysfd); err != nil { + return err + } + if lsa, err := laddr.sockaddr(fd.family); err != nil { + return err + } else if lsa != nil { + // bind() + if err := syscall.Bind(fd.pfd.Sysfd, lsa); err != nil { + return os.NewSyscallError("bind", err) + } + } + + // listenFunc 是全局函数值,在 linux 下非测试环境被绑定到 syscall.Listen + if err := listenFunc(fd.pfd.Sysfd, backlog); err != nil { + return os.NewSyscallError("listen", err) + } + if err := fd.init(); err != nil { + return err + } + lsa, _ := syscall.Getsockname(fd.pfd.Sysfd) + fd.setAddr(fd.addrFunc()(lsa), nil) + return nil +} +``` + +Go 的 listenTCP 一个函数就把 c 网络编程中 `socket()`,`bind()`,`listen()` 三步都完成了。大大减小了用户的心智负担。 + +这里有一点需要注意,listenStream 虽然提供了 backlog 的参数,但用户层是没有办法通过 Go 的代码来修改 listen 的 backlog 的。 + +```go +func maxListenerBacklog() int { + fd, err := open("/proc/sys/net/core/somaxconn") + if err != nil { + return syscall.SOMAXCONN + } + defer fd.close() + l, ok := fd.readLine() + if !ok { + return syscall.SOMAXCONN + } + f := getFields(l) + n, _, ok := dtoi(f[0]) + if n == 0 || !ok { + return syscall.SOMAXCONN + } + // Linux stores the backlog in a uint16. + // Truncate number to avoid wrapping. + // See issue 5030. + if n > 1<<16-1 { + n = 1<<16 - 1 + } + return n +} +``` + +如上,在 linux 中,如果配置了 /proc/sys/net/core/somaxconn,那么就用这个值,如果没有配置,那么就使用 syscall 中的 SOMAXCONN: + +```go +const ( + SOMAXCONN = 0x80 // 128 +) +``` + +社区里有很多人吐槽,希望能有手段能修改这个值,不过看起来官方并不打算支持。所以现阶段只能通过修改 /proc/sys/net/core/somaxconn 来修改 listen 的 backlog。 + +### netFD 添加到 poller + +在上面的 listen 流程的 socket 函数中会调用 newFD 来初始化一个 fd。 + +```go +func newFD(sysfd, family, sotype int, net string) (*netFD, error) { + ret := &netFD{ + pfd: poll.FD{ + Sysfd: sysfd, + IsStream: sotype == syscall.SOCK_STREAM, + ZeroReadIsEOF: sotype != syscall.SOCK_DGRAM && sotype != syscall.SOCK_RAW, + }, + family: family, + sotype: sotype, + net: net, + } + return ret, nil +} +``` + +在 socket、bind、listen 三连发,都没有出错的情况下,会调用 fd.init(): + +```go +func (fd *netFD) init() error { + return fd.pfd.Init(fd.net, true) +} +``` + +```go +// Init initializes the FD. The Sysfd field should already be set. +// This can be called multiple times on a single FD. +// The net argument is a network name from the net package (e.g., "tcp"), +// or "file". +// Set pollable to true if fd should be managed by runtime netpoll. +func (fd *FD) Init(net string, pollable bool) error { + // We don't actually care about the various network types. + if net == "file" { + fd.isFile = true + } + if !pollable { + fd.isBlocking = true + return nil + } + return fd.pd.init(fd) +} +``` + +```go +func (pd *pollDesc) init(fd *FD) error { + serverInit.Do(runtime_pollServerInit) + ctx, errno := runtime_pollOpen(uintptr(fd.Sysfd)) + if errno != 0 { + if ctx != 0 { + runtime_pollUnblock(ctx) + runtime_pollClose(ctx) + } + return syscall.Errno(errno) + } + pd.runtimeCtx = ctx + return nil +} +``` + +```go +//go:linkname poll_runtime_pollOpen internal/poll.runtime_pollOpen +func poll_runtime_pollOpen(fd uintptr) (*pollDesc, int) { + pd := pollcache.alloc() + lock(&pd.lock) + if pd.wg != 0 && pd.wg != pdReady { + throw("runtime: blocked write on free polldesc") + } + if pd.rg != 0 && pd.rg != pdReady { + throw("runtime: blocked read on free polldesc") + } + pd.fd = fd + pd.closing = false + pd.seq++ + pd.rg = 0 + pd.rd = 0 + pd.wg = 0 + pd.wd = 0 + unlock(&pd.lock) + + var errno int32 + errno = netpollopen(fd, pd) + return pd, int(errno) +} +``` + +每一个 fd 对会都应一个 pollDesc 结构,可以看到有 pollcache 提供一定程度的复用。 + +```go +func netpollopen(fd uintptr, pd *pollDesc) int32 { + var ev epollevent + ev.events = _EPOLLIN | _EPOLLOUT | _EPOLLRDHUP | _EPOLLET + *(**pollDesc)(unsafe.Pointer(&ev.data)) = pd + return -epollctl(epfd, _EPOLL_CTL_ADD, int32(fd), &ev) +} +``` + +pollDesc 初始化好之后,会当作 epoll event 的数据存储到 ev.data 中。 当有事件就续时,会取 ev.data,以判断是哪个 fd 可读/可写。 + + + +## accept 流程 + +### TCPListener.Accept 函数 + +```go +// Accept implements the Accept method in the Listener interface; it +// waits for the next call and returns a generic Conn. +func (l *TCPListener) Accept() (Conn, error) { + if !l.ok() { + return nil, syscall.EINVAL + } + c, err := l.accept() + if err != nil { + return nil, &OpError{Op: "accept", Net: l.fd.net, Source: nil, Addr: l.fd.laddr, Err: err} + } + return c, nil +} +``` + +```go +func (ln *TCPListener) accept() (*TCPConn, error) { + fd, err := ln.fd.accept() + if err != nil { + return nil, err + } + return newTCPConn(fd), nil +} + +func newTCPConn(fd *netFD) *TCPConn { + c := &TCPConn{conn{fd}} + setNoDelay(c.fd, true) + return c +} +``` + +### netFD.Accept + +```go +func (fd *netFD) accept() (netfd *netFD, err error) { + d, rsa, errcall, err := fd.pfd.Accept() + if err != nil { + if errcall != "" { + err = wrapSyscallError(errcall, err) + } + return nil, err + } + + if netfd, err = newFD(d, fd.family, fd.sotype, fd.net); err != nil { + poll.CloseFunc(d) + return nil, err + } + if err = netfd.init(); err != nil { + fd.Close() + return nil, err + } + lsa, _ := syscall.Getsockname(netfd.pfd.Sysfd) + netfd.setAddr(netfd.addrFunc()(lsa), netfd.addrFunc()(rsa)) + return netfd, nil +} +``` + +### FD.Accept 执行非阻塞 accept + +```go +// Accept wraps the accept network call. +func (fd *FD) Accept() (int, syscall.Sockaddr, string, error) { + if err := fd.readLock(); err != nil { + return -1, nil, "", err + } + defer fd.readUnlock() + + if err := fd.pd.prepareRead(fd.isFile); err != nil { + return -1, nil, "", err + } + for { + s, rsa, errcall, err := accept(fd.Sysfd) + if err == nil { + return s, rsa, "", err + } + switch err { + case syscall.EAGAIN: + if fd.pd.pollable() { + if err = fd.pd.waitRead(fd.isFile); err == nil { + continue + } + } + case syscall.ECONNABORTED: + // This means that a socket on the listen + // queue was closed before we Accept()ed it; + // it's a silly error, so try again. + continue + } + return -1, nil, errcall, err + } +} +``` + +```go +// Wrapper around the accept system call that marks the returned file +// descriptor as nonblocking and close-on-exec. +func accept(s int) (int, syscall.Sockaddr, string, error) { + ns, sa, err := Accept4Func(s, syscall.SOCK_NONBLOCK|syscall.SOCK_CLOEXEC) + // On Linux the accept4 system call was introduced in 2.6.28 + // kernel and on FreeBSD it was introduced in 10 kernel. If we + // get an ENOSYS error on both Linux and FreeBSD, or EINVAL + // error on Linux, fall back to using accept. + switch err { + case nil: + return ns, sa, "", nil + default: // errors other than the ones listed + return -1, sa, "accept4", err + case syscall.ENOSYS: // syscall missing + case syscall.EINVAL: // some Linux use this instead of ENOSYS + case syscall.EACCES: // some Linux use this instead of ENOSYS + case syscall.EFAULT: // some Linux use this instead of ENOSYS + } + + // See ../syscall/exec_unix.go for description of ForkLock. + // It is probably okay to hold the lock across syscall.Accept + // because we have put fd.sysfd into non-blocking mode. + // However, a call to the File method will put it back into + // blocking mode. We can't take that risk, so no use of ForkLock here. + ns, sa, err = AcceptFunc(s) + if err == nil { + syscall.CloseOnExec(ns) + } + if err != nil { + return -1, nil, "accept", err + } + if err = syscall.SetNonblock(ns, true); err != nil { + CloseFunc(ns) + return -1, nil, "setnonblock", err + } + return ns, sa, "", nil +} + +``` + +可以看到,最终还是用 syscall 中的 accept4 或 accept 完成了系统调用。accept4 对比 accept 的优势是,可以通过一次系统调用完成 accept 和 nonblock flag 的两个目的。而使用 accept 的话,还要手动 syscall.SetNonblock。 + +### fd.pd.waitRead 非阻塞失败开始切换协程 + +这一部分和 read 阻塞流程相同,详见下文。 + +### netpollblock + +### netfd.init 连接套接字添加到 poller + +## Connect 客户端连接 + +### Dial + +``` +func Dial(network, address string) (Conn, error) { + var d Dialer + return d.Dial(network, address) +} + +func (d *Dialer) Dial(network, address string) (Conn, error) { + return d.DialContext(context.Background(), network, address) +} + +func (d *Dialer) DialContext(ctx context.Context, network, address string) (Conn, error) { + //d.deadline() 比较d.deadline、ctx.deadline、now+timeout,返回其中最小.如果都为空,返回0 + deadline := d.deadline(ctx, time.Now()) + + if !deadline.IsZero() { + if d, ok := ctx.Deadline(); !ok || deadline.Before(d) { + subCtx, cancel := context.WithDeadline(ctx, deadline) // 设置新的超时context,deadline 时间一到,subCtx.Done() 立刻返回 + defer cancel() + ctx = subCtx + } + } + + if oldCancel := d.Cancel; oldCancel != nil { + subCtx, cancel := context.WithCancel(ctx) // 使用新的 context + defer cancel() + go func() { + select { + case <-oldCancel: + cancel() + case <-subCtx.Done(): + } + }() + ctx = subCtx + } + + // 解析IP地址,返回值是一个切片 + addrs, err := d.resolver().resolveAddrList(resolveCtx, "dial", network, address, d.LocalAddr) + + sd := &sysDialer{ + Dialer: *d, + network: network, + address: address, + } + + var primaries, fallbacks addrList + if d.dualStack() && network == "tcp" { + primaries, fallbacks = addrs.partition(isIPv4) // 将addrs分成两个切片,前者包含ipv4地址,后者包含ipv6地址 + } else { + primaries = addrs + } + + var c Conn + if len(fallbacks) > 0 { //有ipv6的情况,v4和v6一起dial + c, err = sd.dialParallel(ctx, primaries, fallbacks) + } else { + c, err = sd.dialSerial(ctx, primaries) + } + if err != nil { + return nil, err + } + + if tc, ok := c.(*TCPConn); ok && d.KeepAlive >= 0 { + setKeepAlive(tc.fd, true) + ka := d.KeepAlive + if d.KeepAlive == 0 { + ka = defaultTCPKeepAlive + } + setKeepAlivePeriod(tc.fd, ka) + testHookSetKeepAlive(ka) + } + return c, nil +} + +``` +从上面代码看到,DialContext最终调用的是dialParallel和dialSerial,先看dialParallel,该函数将v4地址和v6地址分开,先尝试v4地址组,在dialer.fallbackDelay 时间后开始尝试v6地址组,每一组都是调用dialSerial(),让两组竞争: + +``` +func (sd *sysDialer) dialParallel(ctx context.Context, primaries, fallbacks addrList) (Conn, error) { + if len(fallbacks) == 0 { + return sd.dialSerial(ctx, primaries) + } + + returned := make(chan struct{}) + defer close(returned) + + type dialResult struct { + Conn + error + primary bool + done bool + } + results := make(chan dialResult) // unbuffered + + startRacer := func(ctx context.Context, primary bool) { + ras := primaries + if !primary { + ras = fallbacks + } + c, err := sd.dialSerial(ctx, ras) + select { + case results <- dialResult{Conn: c, error: err, primary: primary, done: true}: + case <-returned://提取返回,取消连接 + if c != nil { + c.Close() + } + } + } + + var primary, fallback dialResult + + // Start the main racer. + primaryCtx, primaryCancel := context.WithCancel(ctx) + defer primaryCancel() + go startRacer(primaryCtx, true)//先尝试ipv4地址组 + + // Start the timer for the fallback racer. + fallbackTimer := time.NewTimer(sd.fallbackDelay()) + defer fallbackTimer.Stop() + + for { + select { + case <-fallbackTimer.C: // ipv6延迟时间到,开始尝试ipv6地址组 + fallbackCtx, fallbackCancel := context.WithCancel(ctx) + defer fallbackCancel() + go startRacer(fallbackCtx, false) + + case res := <-results://表示至少有一组已经建立连接 + if res.error == nil { + return res.Conn, nil + } + if res.primary { + primary = res + } else { + fallback = res + } + if primary.done && fallback.done { + return nil, primary.error + } + if res.primary && fallbackTimer.Stop() { + // If we were able to stop the timer, that means it + // was running (hadn't yet started the fallback), but + // we just got an error on the primary path, so start + // the fallback immediately (in 0 nanoseconds). + fallbackTimer.Reset(0) + } + } + } +} + +``` + +继续看dialSerial: + +``` +func (sd *sysDialer) dialSerial(ctx context.Context, ras addrList) (Conn, error) { + var firstErr error // The error from the first address is most relevant. + + for i, ra := range ras { + select { + case <-ctx.Done(): // 先观察是否已经被取消 + return nil, &OpError{Op: "dial", Net: sd.network, Source: sd.LocalAddr, Addr: ra, Err: mapErr(ctx.Err())} + default: + } + + deadline, _ := ctx.Deadline() + partialDeadline, err := partialDeadline(time.Now(), deadline, len(ras)-i) + + dialCtx := ctx + if partialDeadline.Before(deadline) { + var cancel context.CancelFunc + dialCtx, cancel = context.WithDeadline(ctx, partialDeadline) + defer cancel() + } + + c, err := sd.dialSingle(dialCtx, ra) + if err == nil { + return c, nil + } + if firstErr == nil { + firstErr = err + } + } + + if firstErr == nil { + firstErr = &OpError{Op: "dial", Net: sd.network, Source: nil, Addr: nil, Err: errMissingAddress} + } + return nil, firstErr +} + +``` + +### sysDialer.dialSingle + +``` +func (sd *sysDialer) dialSingle(ctx context.Context, ra Addr) (c Conn, err error) { + la := sd.LocalAddr + switch ra := ra.(type) { + case *TCPAddr: + la, _ := la.(*TCPAddr) + c, err = sd.dialTCP(ctx, la, ra) + case *UDPAddr: + la, _ := la.(*UDPAddr) + c, err = sd.dialUDP(ctx, la, ra) + case *IPAddr: + la, _ := la.(*IPAddr) + c, err = sd.dialIP(ctx, la, ra) + case *UnixAddr: + la, _ := la.(*UnixAddr) + c, err = sd.dialUnix(ctx, la, ra) + default: + return nil, &OpError{Op: "dial", Net: sd.network, Source: la, Addr: ra, Err: &AddrError{Err: "unexpected address type", Addr: sd.address}} + } + if err != nil { + return nil, &OpError{Op: "dial", Net: sd.network, Source: la, Addr: ra, Err: err} // c is non-nil interface containing nil pointer + } + return c, nil +} + +func (sd *sysDialer) dialTCP(ctx context.Context, laddr, raddr *TCPAddr) (*TCPConn, error) { + return sd.doDialTCP(ctx, laddr, raddr) +} + +func (sd *sysDialer) doDialTCP(ctx context.Context, laddr, raddr *TCPAddr) (*TCPConn, error) { + fd, err := internetSocket(ctx, sd.network, laddr, raddr, syscall.SOCK_STREAM, 0, "dial", sd.Dialer.Control) + + for i := 0; i < 2 && (laddr == nil || laddr.Port == 0) && (selfConnect(fd, err) || spuriousENOTAVAIL(err)); i++ { + if err == nil { + fd.Close() + } + fd, err = internetSocket(ctx, sd.network, laddr, raddr, syscall.SOCK_STREAM, 0, "dial", sd.Dialer.Control) + } + + return newTCPConn(fd), nil +} +``` + +参数里的ctx自然不言而喻了,是为了控制请求超时取消请求释放资源的;laddr是 local address , raddr是指 remote address;返回值这里会得到 TCPConn。代码不长,就是调用了 internetSocket得到一个文件描述符,并用其新建一个conn返回。但这里我想多说几句,因为不难发现, internetSocket可能会被调用多次,为什么呢? + +首先我们需要知道 Tcp 有一个极少使用的机制,叫simultaneous connection(同时连接)。正常的连接是:A主机 dial B主机,B主机 listen。 而同时连接则是: A 向 B dial 同时 B 向 A dial,那么 A 和 B 都不需要监听。 + +我们知道,当 传入 dial 函数的参数laddr==raddr时,内核会拒绝dial。但如果传入的laddr为nil,kernel 会自动选择一个本机端口,这时候有可能会使得新的laddr==raddr,这个时候,kernel不会拒绝dial,并且这个dial会成功,原因是就simultaneous connection,这可能是kernel的bug。所以会判断是否是 selfConnect或者spuriousENOTAVAIL(spurious error not avail)来判断上一次调用internetSocket返回的 err 类型,在特定的情况下重新尝试internetSocket. + +``` +func internetSocket(ctx context.Context, net string, laddr, raddr sockaddr, sotype, proto int, mode string, ctrlFn func(string, string, syscall.RawConn) error) (fd *netFD, err error) { + family, ipv6only := favoriteAddrFamily(net, laddr, raddr, mode) + return socket(ctx, net, family, sotype, proto, ipv6only, laddr, raddr, ctrlFn) +} + +func socket(ctx context.Context, net string, family, sotype, proto int, ipv6only bool, laddr, raddr sockaddr, ctrlFn func(string, string, syscall.RawConn) error) (fd *netFD, err error) { + s, err := sysSocket(family, sotype, proto) + if err != nil { + return nil, err + } + if err = setDefaultSockopts(s, family, sotype, ipv6only); err != nil { + poll.CloseFunc(s) + return nil, err + } + if fd, err = newFD(s, family, sotype, net); err != nil { + poll.CloseFunc(s) + return nil, err + } + + if err := fd.dial(ctx, laddr, raddr, ctrlFn); err != nil { + fd.Close() + return nil, err + } + return fd, nil +} +``` + +``` +func (fd *netFD) dial(ctx context.Context, laddr, raddr sockaddr, ctrlFn func(string, string, syscall.RawConn) error) error { + if ctrlFn != nil { + c, err := newRawConn(fd) + if err != nil { + return err + } + var ctrlAddr string + if raddr != nil { + ctrlAddr = raddr.String() + } else if laddr != nil { + ctrlAddr = laddr.String() + } + if err := ctrlFn(fd.ctrlNetwork(), ctrlAddr, c); err != nil { + return err + } + } + + var rsa syscall.Sockaddr // remote address from the user + var crsa syscall.Sockaddr // remote address we actually connected to + if raddr != nil { + if rsa, err = raddr.sockaddr(fd.family); err != nil { + return err + } + if crsa, err = fd.connect(ctx, lsa, rsa); err != nil { + return err + } + fd.isConnected = true + } else { + if err := fd.init(); err != nil { + return err + } + } + + lsa, _ = syscall.Getsockname(fd.pfd.Sysfd) + if crsa != nil { + fd.setAddr(fd.addrFunc()(lsa), fd.addrFunc()(crsa)) + } else if rsa, _ = syscall.Getpeername(fd.pfd.Sysfd); rsa != nil { + fd.setAddr(fd.addrFunc()(lsa), fd.addrFunc()(rsa)) + } else { + fd.setAddr(fd.addrFunc()(lsa), raddr) + } + return nil +} + +``` + +### fd.connect + +``` +func (fd *netFD) connect(ctx context.Context, la, ra syscall.Sockaddr) (rsa syscall.Sockaddr, ret error) { + // 先尝试非阻塞 connect + switch err := connectFunc(fd.pfd.Sysfd, ra); err { + case syscall.EINPROGRESS, syscall.EALREADY, syscall.EINTR: + case nil, syscall.EISCONN: + select { + case <-ctx.Done(): + return nil, mapErr(ctx.Err()) + default: + } + if err := fd.pfd.Init(fd.net, true); err != nil { // 初始化到 poller + return nil, err + } + runtime.KeepAlive(fd) + return nil, nil // 成功建立连接,返回 + case syscall.EINVAL: + fallthrough + default: + return nil, os.NewSyscallError("connect", err) + } + + // 初始化,放置到 netpoll + if err := fd.pfd.Init(fd.net, true); err != nil { + return nil, err + } + + for { + // 阻塞调度,等待建立连接 + if err := fd.pfd.WaitWrite(); err != nil { + select { + case <-ctx.Done(): + return nil, mapErr(ctx.Err()) + default: + } + return nil, err + } + nerr, err := getsockoptIntFunc(fd.pfd.Sysfd, syscall.SOL_SOCKET, syscall.SO_ERROR) + if err != nil { + return nil, os.NewSyscallError("getsockopt", err) + } + switch err := syscall.Errno(nerr); err { + case syscall.EINPROGRESS, syscall.EALREADY, syscall.EINTR: + case syscall.EISCONN: + return nil, nil + case syscall.Errno(0): + // The runtime poller can wake us up spuriously; + // see issues 14548 and 19289. Check that we are + // really connected; if not, wait again. + if rsa, err := syscall.Getpeername(fd.pfd.Sysfd); err == nil { // 建立连接成功 + return rsa, nil + } + default: + return nil, os.NewSyscallError("connect", err) + } + runtime.KeepAlive(fd) + } +} + +``` + +## Read 流程 + +### conn.Read + +```go +func (c *conn) ok() bool { return c != nil && c.fd != nil } + +// Implementation of the Conn interface. + +// Read implements the Conn Read method. +func (c *conn) Read(b []byte) (int, error) { + if !c.ok() { + return 0, syscall.EINVAL + } + n, err := c.fd.Read(b) + if err != nil && err != io.EOF { + err = &OpError{Op: "read", Net: c.fd.net, Source: c.fd.laddr, Addr: c.fd.raddr, Err: err} + } + return n, err +} + +``` + +### netFD.Read + +```go +func (fd *netFD) Read(buf []byte) (int, error) { + n, err := fd.pfd.Read(buf) + runtime.KeepAlive(fd) + return n, wrapSyscallError("wsarecv", err) +} +``` + +### FD.Read 尝试非阻塞读 + +```go +// Read implements io.Reader. +func (fd *FD) Read(p []byte) (int, error) { + if err := fd.readLock(); err != nil { + return 0, err + } + defer fd.readUnlock() + if len(p) == 0 { + return 0, nil + } + + if err := fd.pd.prepareRead(fd.isFile); err != nil { + return 0, err + } + if fd.IsStream && len(p) > maxRW { + p = p[:maxRW] + } + for { + // 第一次调用 syscall.Read 之后,如果读到了数据 + // 那么直接就返回了 + n, err := syscall.Read(fd.Sysfd, p) + if err != nil { + n = 0 + // 如果 os 返回 EAGAIN,说明可能暂时没数据 + // 判断 fd 是 pollable 的话,说明可以走 poll 流程 + if err == syscall.EAGAIN && fd.pd.pollable() { + if err = fd.pd.waitRead(fd.isFile); err == nil { + continue + } + } + + } + err = fd.eofError(n, err) + return n, err + } +} + +``` + +### pollDesc.waitRead 阻塞调度 + +waitRead 并不是真正的阻塞,而是直接从当前的 G 调度到其他可运行的 G 去运行,等待着 netpoll 的通知,再回来。 + +```go +func (pd *pollDesc) waitRead(isFile bool) error { + return pd.wait('r', isFile) +} + + +func (pd *pollDesc) wait(mode int, isFile bool) error { + if pd.runtimeCtx == 0 { + return errors.New("waiting for unsupported file type") + } + res := runtime_pollWait(pd.runtimeCtx, mode) + return convertErr(res, isFile) +} +``` + +runtime_pollWait 是用 `go:linkname` 来链接期链接到的函数,实现在 `runtime/netpoll.go` 中: + +```go +//go:linkname poll_runtime_pollWait internal/poll.runtime_pollWait +func poll_runtime_pollWait(pd *pollDesc, mode int) int { + err := netpollcheckerr(pd, int32(mode)) + if err != 0 { + return err + } + + for !netpollblock(pd, int32(mode), false) { + err = netpollcheckerr(pd, int32(mode)) + if err != 0 { + return err + } + } + return 0 +} +``` +### netpollblock 设置 rg/wg 为 pdWait + +本函数主要工作有两个: + +- 一个是将 pd.rg 或者 pd.wg 从初始状态转换为 pdWait。 +- 调用 gopark 调度 + +```go +// returns true if IO is ready, or false if timedout or closed +// waitio - wait only for completed IO, ignore errors +func netpollblock(pd *pollDesc, mode int32, waitio bool) bool { + gpp := &pd.rg + if mode == 'w' { + gpp = &pd.wg + } + + // set the gpp semaphore to WAIT + for { + old := *gpp + if old == pdReady { + *gpp = 0 + return true + } + if old != 0 { + throw("runtime: double wait") + } + if atomic.Casuintptr(gpp, 0, pdWait) { + break + } + } + + // need to recheck error states after setting gpp to WAIT + // this is necessary because runtime_pollUnblock/runtime_pollSetDeadline/deadlineimpl + // do the opposite: store to closing/rd/wd, membarrier, load of rg/wg + if waitio || netpollcheckerr(pd, mode) == 0 { + gopark(netpollblockcommit, unsafe.Pointer(gpp), "IO wait", traceEvGoBlockNet, 5) + } + // be careful to not lose concurrent READY notification + old := atomic.Xchguintptr(gpp, 0) + if old > pdWait { + throw("runtime: corrupted polldesc") + } + return old == pdReady +} +``` + +gopark 将当前 g 挂起,等待就绪事件到达之后再继续执行。 + +### netpollblockcommit 设置 rg/wg 为当前 Goroutine + +在上面读写流程,syscall.Read 或者 syscall.Write 返回 EAGAIN 时,会挂起当前正在进行这个读/写操作的 g,具体是调用 gopark,并执行 netpollblockcommit,并将 gpp 挂起,netpollblockcommit 比较简单: + +```go +func netpollblockcommit(gp *g, gpp unsafe.Pointer) bool { + r := atomic.Casuintptr((*uintptr)(gpp), pdWait, uintptr(unsafe.Pointer(gp))) + if r { + // Bump the count of goroutines waiting for the poller. + // The scheduler uses this to decide whether to block + // waiting for the poller if there is nothing else to do. + atomic.Xadd(&netpollWaiters, 1) + } + return r +} +``` + +EAGAIN 的时候: + +```go +gopark(netpollblockcommit, unsafe.Pointer(gpp), "IO wait", traceEvGoBlockNet, 5) +``` + +至于唤醒流程,当调度器在 findrunnable、startTheWorldWithSema 或者 sysmon 中调用 netpoll 函数时,会获取到上面说的就绪的 g 列表。把这些 g 的 bp/sp/pc 都从 g.gobuf 中恢复出来,就可以继续执行它们的 Read/Write 操作了。 + +因为调度中有讲,这里就不赘述了。 + +## Write 流程 + +### conn.Write + +```go +// Write implements the Conn Write method. +func (c *conn) Write(b []byte) (int, error) { + if !c.ok() { + return 0, syscall.EINVAL + } + n, err := c.fd.Write(b) + if err != nil { + err = &OpError{Op: "write", Net: c.fd.net, Source: c.fd.laddr, Addr: c.fd.raddr, Err: err} + } + return n, err +} + +``` + +### netFD.Write + +```go +func (fd *netFD) Write(buf []byte) (int, error) { + n, err := fd.pfd.Write(buf) + runtime.KeepAlive(fd) + return n, wrapSyscallError("wsasend", err) +} +``` + +### FD.Write 循环非阻塞写 + +```go +// Write implements io.Writer. +func (fd *FD) Write(p []byte) (int, error) { + if err := fd.writeLock(); err != nil { + return 0, err + } + defer fd.writeUnlock() + if err := fd.pd.prepareWrite(fd.isFile); err != nil { + return 0, err + } + var nn int + for { + max := len(p) + if fd.IsStream && max-nn > maxRW { + max = nn + maxRW + } + n, err := syscall.Write(fd.Sysfd, p[nn:max]) + if n > 0 { + nn += n + } + if nn == len(p) { + return nn, err + } + if err == syscall.EAGAIN && fd.pd.pollable() { + if err = fd.pd.waitWrite(fd.isFile); err == nil { + continue + } + } + if err != nil { + return nn, err + } + if n == 0 { + return nn, io.ErrUnexpectedEOF + } + } +} +``` + +### pd.waitWrite 阻塞调度 + +内核的写缓冲区满,这里的 syscall.Write 就会返回 EAGAIN。 + +```go +func (pd *pollDesc) waitWrite(isFile bool) error { + return pd.wait('w', isFile) +} + +func (pd *pollDesc) wait(mode int, isFile bool) error { + if pd.runtimeCtx == 0 { + return errors.New("waiting for unsupported file type") + } + res := runtime_pollWait(pd.runtimeCtx, mode) + return convertErr(res, isFile) +} +``` + +后面的流程就和 Read 完全一致了。 + +### netpollblock + +## 就续通知 + +### netpoll + +```go +// poll 已经就绪的网络连接 +// 返回那些已经可以跑的 goroutine 列表 +func netpoll(block bool) *g { + if epfd == -1 { + return nil + } + waitms := int32(-1) + if !block { + waitms = 0 + } + var events [128]epollevent +retry: + n := epollwait(epfd, &events[0], int32(len(events)), waitms) + if n < 0 { + if n != -_EINTR { + println("runtime: epollwait on fd", epfd, "failed with", -n) + throw("runtime: netpoll failed") + } + goto retry + } + var gp guintptr + for i := int32(0); i < n; i++ { + ev := &events[i] + if ev.events == 0 { + continue + } + var mode int32 + if ev.events&(_EPOLLIN|_EPOLLRDHUP|_EPOLLHUP|_EPOLLERR) != 0 { + mode += 'r' + } + if ev.events&(_EPOLLOUT|_EPOLLHUP|_EPOLLERR) != 0 { + mode += 'w' + } + if mode != 0 { + pd := *(**pollDesc)(unsafe.Pointer(&ev.data)) + + netpollready(&gp, pd, mode) + } + } + if block && gp == 0 { + goto retry + } + return gp.ptr() +} + +// 让 pd 就续,新的可以运行的 goroutine 会 set 到 wg/rg +func netpollready(toRun *gList, pd *pollDesc, mode int32) { + var rg, wg *g + if mode == 'r' || mode == 'r'+'w' { + rg = netpollunblock(pd, 'r', true) + } + if mode == 'w' || mode == 'r'+'w' { + wg = netpollunblock(pd, 'w', true) + } + if rg != nil { + toRun.push(rg) + } + if wg != nil { + toRun.push(wg) + } +} +``` +### netpollunblock 获取 netpoll 中的 rg/wg 并转为 pdReady + +``` +// 按照 mode 把 pollDesc 的 wg 或者 rg 捞出来,返回 +func netpollunblock(pd *pollDesc, mode int32, ioready bool) *g { + gpp := &pd.rg + if mode == 'w' { + gpp = &pd.wg + } + + for { + old := *gpp + if old == pdReady { + return nil + } + if old == 0 && !ioready { + // Only set READY for ioready. runtime_pollWait + // will check for timeout/cancel before waiting. + return nil + } + var new uintptr + if ioready { + new = pdReady + } + if atomic.Casuintptr(gpp, old, new) { + if old == pdReady || old == pdWait { + old = 0 + } + return (*g)(unsafe.Pointer(old)) + } + } +} +``` + +三个函数配合完成就续后唤醒对应的 g 的工作,netpollunblock 从 pollDesc 中捞出 rg/wg,netpollready 然后再把所有的 rg/wg 通过 schedlink 串成一个链表。findrunnable 之类需要 g 的场景下,调度器会主动调用 netpoll 函数来寻找是否有已经就绪的网络事件对应的 g。 + +netpoll 这个函数是平台相关的,实现在对应的 netpoll_epoll、netpoll_kqueue 文件中。 + +## 读写超时 + +### Conn.SetReadDeadline/SetWriteDeadline + +```go +// 设置底层连接的读超时 +// 超时时间是 0 值的话永远都不会超时 +func (c *Conn) SetReadDeadline(t time.Time) error { + return c.conn.SetReadDeadline(t) +} + +// 设置底层连接的读超时 +// 超时时间是 0 值的话永远都不会超时 +// 写超时发生之后, TLS 状态会被破坏,未来的所有写都会返回相同的错误 +func (c *Conn) SetWriteDeadline(t time.Time) error { + return c.conn.SetWriteDeadline(t) +} +``` + +```go +// 实现 Conn 接口中的方法 +func (c *conn) SetReadDeadline(t time.Time) error { + if !c.ok() { + return syscall.EINVAL + } + if err := c.fd.SetReadDeadline(t); err != nil { + return &OpError{Op: "set", Net: c.fd.net, Source: nil, Addr: c.fd.laddr, Err: err} + } + return nil +} + +// 实现 Conn 接口中的方法 +func (c *conn) SetWriteDeadline(t time.Time) error { + if !c.ok() { + return syscall.EINVAL + } + if err := c.fd.SetWriteDeadline(t); err != nil { + return &OpError{Op: "set", Net: c.fd.net, Source: nil, Addr: c.fd.laddr, Err: err} + } + return nil +} +``` + +### FD.SetReadDeadline/SetWriteDeadline + +```go +// 设置关联 fd 的读取 deadline +func (fd *FD) SetReadDeadline(t time.Time) error { + return setDeadlineImpl(fd, t, 'r') +} + +// 设置关联 fd 的写入 deadline +func (fd *FD) SetWriteDeadline(t time.Time) error { + return setDeadlineImpl(fd, t, 'w') +} +``` + +```go +func setDeadlineImpl(fd *FD, t time.Time, mode int) error { + diff := int64(time.Until(t)) + d := runtimeNano() + diff + if d <= 0 && diff > 0 { + // 如果用户提供了未来的 deadline,但是 delay 计算溢出了,那么设置 dealine 到最大的可能的值 + d = 1<<63 - 1 + } + if t.IsZero() { + // IsZero reports whether t represents the zero time instant, + // January 1, year 1, 00:00:00 UTC. + // func (t Time) IsZero() bool { + // return t.sec() == 0 && t.nsec() == 0 + // } + d = 0 + } + if err := fd.incref(); err != nil { + return err + } + defer fd.decref() + if fd.pd.runtimeCtx == 0 { + return ErrNoDeadline + } + runtime_pollSetDeadline(fd.pd.runtimeCtx, d, mode) + return nil +} +``` + +### poll_runtime_pollSetDeadline 设置超时 timer + +```go +//go:linkname poll_runtime_pollSetDeadline internal/poll.runtime_pollSetDeadline +func poll_runtime_pollSetDeadline(pd *pollDesc, d int64, mode int) { + lock(&pd.lock) + if pd.closing { + unlock(&pd.lock) + return + } + rd0, wd0 := pd.rd, pd.wd + combo0 := rd0 > 0 && rd0 == wd0 + if d > 0 { + d += nanotime() + if d <= 0 { + // If the user has a deadline in the future, but the delay calculation + // overflows, then set the deadline to the maximum possible value. + d = 1<<63 - 1 + } + } + if mode == 'r' || mode == 'r'+'w' { + pd.rd = d + } + if mode == 'w' || mode == 'r'+'w' { + pd.wd = d + } + combo := pd.rd > 0 && pd.rd == pd.wd + rtf := netpollReadDeadline + if combo { + rtf = netpollDeadline + } + if pd.rt.f == nil { + if pd.rd > 0 { + pd.rt.f = rtf + pd.rt.when = pd.rd + // Copy current seq into the timer arg. + // Timer func will check the seq against current descriptor seq, + // if they differ the descriptor was reused or timers were reset. + pd.rt.arg = pd + pd.rt.seq = pd.rseq + addtimer(&pd.rt) + } + } else if pd.rd != rd0 || combo != combo0 { + pd.rseq++ // invalidate current timers + if pd.rd > 0 { + modtimer(&pd.rt, pd.rd, 0, rtf, pd, pd.rseq) + } else { + deltimer(&pd.rt) + pd.rt.f = nil + } + } + if pd.wt.f == nil { + if pd.wd > 0 && !combo { + pd.wt.f = netpollWriteDeadline + pd.wt.when = pd.wd + pd.wt.arg = pd + pd.wt.seq = pd.wseq + addtimer(&pd.wt) + } + } else if pd.wd != wd0 || combo != combo0 { + pd.wseq++ // invalidate current timers + if pd.wd > 0 && !combo { + modtimer(&pd.wt, pd.wd, 0, netpollWriteDeadline, pd, pd.wseq) + } else { + deltimer(&pd.wt) + pd.wt.f = nil + } + } + + // 如果发现超时时间已经是过去了,那么提前取出 + var rg, wg *g + if pd.rd < 0 || pd.wd < 0 { + atomic.StorepNoWB(noescape(unsafe.Pointer(&wg)), nil) // full memory barrier between stores to rd/wd and load of rg/wg in netpollunblock + if pd.rd < 0 { + rg = netpollunblock(pd, 'r', false) + } + if pd.wd < 0 { + wg = netpollunblock(pd, 'w', false) + } + } + unlock(&pd.lock) + if rg != nil { + netpollgoready(rg, 3) + } + if wg != nil { + netpollgoready(wg, 3) + } +} +``` + +根据 read deadline 和 write deadline 给要插入时间堆的 timer 设置不同的回调函数。 + +```go +func netpollDeadline(arg interface{}, seq uintptr) { + netpolldeadlineimpl(arg.(*pollDesc), seq, true, true) +} + +func netpollReadDeadline(arg interface{}, seq uintptr) { + netpolldeadlineimpl(arg.(*pollDesc), seq, true, false) +} + +func netpollWriteDeadline(arg interface{}, seq uintptr) { + netpolldeadlineimpl(arg.(*pollDesc), seq, false, true) +} +``` + +### netpolldeadlineimpl 超时回调函数(被调用表明已超时) + +调用最终的实现函数: + +```go +func netpolldeadlineimpl(pd *pollDesc, seq uintptr, read, write bool) { + lock(&pd.lock) + // Seq arg is seq when the timer was set. + // If it's stale, ignore the timer event. + if seq != pd.seq { + // The descriptor was reused or timers were reset. + unlock(&pd.lock) + return + } + var rg *g + if read { + if pd.rd <= 0 || pd.rt.f == nil { + throw("runtime: inconsistent read deadline") + } + pd.rd = -1 + atomicstorep(unsafe.Pointer(&pd.rt.f), nil) // full memory barrier between store to rd and load of rg in netpollunblock + rg = netpollunblock(pd, 'r', false) + } + var wg *g + if write { + if pd.wd <= 0 || pd.wt.f == nil && !read { + throw("runtime: inconsistent write deadline") + } + pd.wd = -1 + atomicstorep(unsafe.Pointer(&pd.wt.f), nil) // full memory barrier between store to wd and load of wg in netpollunblock + wg = netpollunblock(pd, 'w', false) + } + unlock(&pd.lock) + // rg 和 wg 是通过 netpollunblock 从 pollDesc 结构中捞出来的 + if rg != nil { + // 恢复 goroutine 执行现场 + // 继续执行 + netpollgoready(rg, 0) + } + if wg != nil { + // 恢复 goroutine 执行现场 + // 继续执行 + netpollgoready(wg, 0) + } +} +``` + +## 连接关闭 + +### conn.Close + +```go +// Close closes the connection. +func (c *conn) Close() error { + if !c.ok() { + return syscall.EINVAL + } + err := c.fd.Close() + if err != nil { + err = &OpError{Op: "close", Net: c.fd.net, Source: c.fd.laddr, Addr: c.fd.raddr, Err: err} + } + return err +} +``` + +### netFD.Close + +```go +func (fd *netFD) Close() error { + runtime.SetFinalizer(fd, nil) + return fd.pfd.Close() +} +``` + +### FD.Close + +```go +// Close closes the FD. The underlying file descriptor is closed by the +// destroy method when there are no remaining references. +func (fd *FD) Close() error { + if !fd.fdmu.increfAndClose() { + return errClosing(fd.isFile) + } + + // Unblock any I/O. Once it all unblocks and returns, + // so that it cannot be referring to fd.sysfd anymore, + // the final decref will close fd.sysfd. This should happen + // fairly quickly, since all the I/O is non-blocking, and any + // attempts to block in the pollDesc will return errClosing(fd.isFile). + fd.pd.evict() + + // The call to decref will call destroy if there are no other + // references. + err := fd.decref() + + // Wait until the descriptor is closed. If this was the only + // reference, it is already closed. Only wait if the file has + // not been set to blocking mode, as otherwise any current I/O + // may be blocking, and that would block the Close. + if !fd.isBlocking { + runtime_Semacquire(&fd.csema) + } + + return err +} +``` + +### pollDesc.evict + +```go +// Evict evicts fd from the pending list, unblocking any I/O running on fd. +func (pd *pollDesc) evict() { + if pd.runtimeCtx == 0 { + return + } + runtime_pollUnblock(pd.runtimeCtx) +} +``` + +### poll_runtime_pollUnblock 提取取出被阻塞的 G + +```go +//go:linkname poll_runtime_pollUnblock internal/poll.runtime_pollUnblock +func poll_runtime_pollUnblock(pd *pollDesc) { + lock(&pd.lock) + if pd.closing { + throw("runtime: unblock on closing polldesc") + } + pd.closing = true + pd.seq++ + var rg, wg *g + atomicstorep(unsafe.Pointer(&rg), nil) // full memory barrier between store to closing and read of rg/wg in netpollunblock + rg = netpollunblock(pd, 'r', false) + wg = netpollunblock(pd, 'w', false) + if pd.rt.f != nil { + deltimer(&pd.rt) + pd.rt.f = nil + } + if pd.wt.f != nil { + deltimer(&pd.wt) + pd.wt.f = nil + } + unlock(&pd.lock) + if rg != nil { + netpollgoready(rg, 3) + } + if wg != nil { + netpollgoready(wg, 3) + } +} +``` + +### fd.decref 销毁连接套接字 + +``` +func (fd *FD) decref() error { + if fd.fdmu.decref() { + return fd.destroy() + } + return nil +} + +func (fd *FD) destroy() error { + // Poller may want to unregister fd in readiness notification mechanism, + // so this must be executed before CloseFunc. + fd.pd.close() + err := CloseFunc(fd.Sysfd) + fd.Sysfd = -1 + runtime_Semrelease(&fd.csema) + return err +} + +var CloseFunc func(int) error = syscall.Close + +func (pd *pollDesc) close() { + if pd.runtimeCtx == 0 { + return + } + runtime_pollClose(pd.runtimeCtx) + pd.runtimeCtx = 0 +} +``` + +### poll_runtime_pollClose 取消 netpoll + +``` +func poll_runtime_pollClose(pd *pollDesc) { + if !pd.closing { + throw("runtime: close polldesc w/o unblock") + } + if pd.wg != 0 && pd.wg != pdReady { + throw("runtime: blocked write on closing polldesc") + } + if pd.rg != 0 && pd.rg != pdReady { + throw("runtime: blocked read on closing polldesc") + } + netpollclose(pd.fd) + pollcache.free(pd) +} + +func netpollclose(fd uintptr) int32 { + var ev epollevent + return -epollctl(epfd, _EPOLL_CTL_DEL, int32(fd), &ev) +} + +``` diff --git a/img/PMG1.png b/img/PMG1.png new file mode 100644 index 0000000..d5db7dc Binary files /dev/null and b/img/PMG1.png differ diff --git a/img/PMG2.png b/img/PMG2.png new file mode 100644 index 0000000..f11a6c5 Binary files /dev/null and b/img/PMG2.png differ diff --git a/img/PMG3.png b/img/PMG3.png new file mode 100644 index 0000000..60d0aea Binary files /dev/null and b/img/PMG3.png differ diff --git a/img/_type.png b/img/_type.png new file mode 100644 index 0000000..bdea850 Binary files /dev/null and b/img/_type.png differ diff --git a/img/arenas.png b/img/arenas.png new file mode 100644 index 0000000..da11f7d Binary files /dev/null and b/img/arenas.png differ diff --git a/img/arenas2.jpg b/img/arenas2.jpg new file mode 100644 index 0000000..75813d1 Binary files /dev/null and b/img/arenas2.jpg differ diff --git a/img/arenas2.png b/img/arenas2.png new file mode 100644 index 0000000..8c71847 Binary files /dev/null and b/img/arenas2.png differ diff --git a/img/arenas3.jpg b/img/arenas3.jpg new file mode 100644 index 0000000..48ab4cc Binary files /dev/null and b/img/arenas3.jpg differ diff --git a/img/arenas4.jpg b/img/arenas4.jpg new file mode 100644 index 0000000..b64e39e Binary files /dev/null and b/img/arenas4.jpg differ diff --git a/img/bucket.png b/img/bucket.png new file mode 100644 index 0000000..ccfa044 Binary files /dev/null and b/img/bucket.png differ diff --git a/img/cat.png b/img/cat.png new file mode 100644 index 0000000..1db74b8 Binary files /dev/null and b/img/cat.png differ diff --git a/img/cat1.png b/img/cat1.png new file mode 100644 index 0000000..f71344a Binary files /dev/null and b/img/cat1.png differ diff --git a/img/cat3.png b/img/cat3.png new file mode 100644 index 0000000..d991f63 Binary files /dev/null and b/img/cat3.png differ diff --git a/img/copy1.jpg b/img/copy1.jpg new file mode 100644 index 0000000..1ad7232 Binary files /dev/null and b/img/copy1.jpg differ diff --git a/img/copy2.jpg b/img/copy2.jpg new file mode 100644 index 0000000..195d768 Binary files /dev/null and b/img/copy2.jpg differ diff --git a/img/cycle.jpg b/img/cycle.jpg new file mode 100644 index 0000000..7756ae7 Binary files /dev/null and b/img/cycle.jpg differ diff --git a/img/duck.png b/img/duck.png new file mode 100644 index 0000000..55fb6a7 Binary files /dev/null and b/img/duck.png differ diff --git a/img/duck2.png b/img/duck2.png new file mode 100644 index 0000000..a926d30 Binary files /dev/null and b/img/duck2.png differ diff --git a/img/duck3.png b/img/duck3.png new file mode 100644 index 0000000..022eb4e Binary files /dev/null and b/img/duck3.png differ diff --git a/img/duck4.png b/img/duck4.png new file mode 100644 index 0000000..d82ff0d Binary files /dev/null and b/img/duck4.png differ diff --git a/img/duck5.png b/img/duck5.png new file mode 100644 index 0000000..228d444 Binary files /dev/null and b/img/duck5.png differ diff --git a/img/duck6.png b/img/duck6.png new file mode 100644 index 0000000..75e4b3c Binary files /dev/null and b/img/duck6.png differ diff --git a/img/falseshare.png b/img/falseshare.png new file mode 100644 index 0000000..8cc994d Binary files /dev/null and b/img/falseshare.png differ diff --git a/img/gc1.png b/img/gc1.png new file mode 100644 index 0000000..d2d516a Binary files /dev/null and b/img/gc1.png differ diff --git a/img/gen1.jpg b/img/gen1.jpg new file mode 100644 index 0000000..aed22fb Binary files /dev/null and b/img/gen1.jpg differ diff --git a/img/gen2.jpg b/img/gen2.jpg new file mode 100644 index 0000000..67a1b14 Binary files /dev/null and b/img/gen2.jpg differ diff --git a/img/hashgrow.png b/img/hashgrow.png new file mode 100644 index 0000000..16f72a9 Binary files /dev/null and b/img/hashgrow.png differ diff --git a/img/hashgrow1.png b/img/hashgrow1.png new file mode 100644 index 0000000..c3f347b Binary files /dev/null and b/img/hashgrow1.png differ diff --git a/img/hmap.png b/img/hmap.png new file mode 100644 index 0000000..9f85bae Binary files /dev/null and b/img/hmap.png differ diff --git a/img/interface.png b/img/interface.png new file mode 100644 index 0000000..91d38c7 Binary files /dev/null and b/img/interface.png differ diff --git a/img/interface1.png b/img/interface1.png new file mode 100644 index 0000000..d3bc3cd Binary files /dev/null and b/img/interface1.png differ diff --git a/img/interface3.png b/img/interface3.png new file mode 100644 index 0000000..097cf7b Binary files /dev/null and b/img/interface3.png differ diff --git a/img/map.png b/img/map.png new file mode 100644 index 0000000..a27efaf Binary files /dev/null and b/img/map.png differ diff --git a/img/marksweep.jpg b/img/marksweep.jpg new file mode 100644 index 0000000..ceecbc7 Binary files /dev/null and b/img/marksweep.jpg differ diff --git a/img/marksweep1.jpg b/img/marksweep1.jpg new file mode 100644 index 0000000..8288ebc Binary files /dev/null and b/img/marksweep1.jpg differ diff --git a/img/mem.jpg b/img/mem.jpg new file mode 100644 index 0000000..5591f2a Binary files /dev/null and b/img/mem.jpg differ diff --git a/img/mem.png b/img/mem.png new file mode 100644 index 0000000..0d1f335 Binary files /dev/null and b/img/mem.png differ diff --git a/img/mesi.png b/img/mesi.png new file mode 100644 index 0000000..42b0998 Binary files /dev/null and b/img/mesi.png differ diff --git a/img/mesi2.png b/img/mesi2.png new file mode 100644 index 0000000..6e2590b Binary files /dev/null and b/img/mesi2.png differ diff --git a/img/mesi3.jpg b/img/mesi3.jpg new file mode 100644 index 0000000..75bd8a9 Binary files /dev/null and b/img/mesi3.jpg differ diff --git a/img/mesi4.jpg b/img/mesi4.jpg new file mode 100644 index 0000000..36739e4 Binary files /dev/null and b/img/mesi4.jpg differ diff --git a/img/methodset.png b/img/methodset.png new file mode 100644 index 0000000..6a6a30f Binary files /dev/null and b/img/methodset.png differ diff --git a/img/mheap.jpg b/img/mheap.jpg new file mode 100644 index 0000000..99dda7a Binary files /dev/null and b/img/mheap.jpg differ diff --git a/img/mspan.jpg b/img/mspan.jpg new file mode 100644 index 0000000..29e2615 Binary files /dev/null and b/img/mspan.jpg differ diff --git a/img/mutex.png b/img/mutex.png new file mode 100644 index 0000000..18e4b85 Binary files /dev/null and b/img/mutex.png differ diff --git a/img/preem.jpg b/img/preem.jpg new file mode 100644 index 0000000..96d6230 Binary files /dev/null and b/img/preem.jpg differ diff --git a/img/span1.jpg b/img/span1.jpg new file mode 100644 index 0000000..905cfca Binary files /dev/null and b/img/span1.jpg differ diff --git a/img/stackmap.png b/img/stackmap.png new file mode 100644 index 0000000..5239b68 Binary files /dev/null and b/img/stackmap.png differ diff --git a/img/stackmap1.png b/img/stackmap1.png new file mode 100644 index 0000000..e53ff40 Binary files /dev/null and b/img/stackmap1.png differ diff --git a/img/tiny1.png b/img/tiny1.png new file mode 100644 index 0000000..eaea2af Binary files /dev/null and b/img/tiny1.png differ diff --git a/img/tiny2.png b/img/tiny2.png new file mode 100644 index 0000000..16cb882 Binary files /dev/null and b/img/tiny2.png differ diff --git a/img/volatile.png b/img/volatile.png new file mode 100644 index 0000000..724396f Binary files /dev/null and b/img/volatile.png differ diff --git a/img/volatile1.png b/img/volatile1.png new file mode 100644 index 0000000..1109883 Binary files /dev/null and b/img/volatile1.png differ diff --git a/img/volatile3.png b/img/volatile3.png new file mode 100644 index 0000000..cf654ea Binary files /dev/null and b/img/volatile3.png differ diff --git a/img/volatile4.png b/img/volatile4.png new file mode 100644 index 0000000..8c4bb4e Binary files /dev/null and b/img/volatile4.png differ diff --git a/img/volatile5.png b/img/volatile5.png new file mode 100644 index 0000000..aabfded Binary files /dev/null and b/img/volatile5.png differ diff --git a/img/waiting.png b/img/waiting.png new file mode 100644 index 0000000..fe8feb4 Binary files /dev/null and b/img/waiting.png differ diff --git a/img/writeb.jpg b/img/writeb.jpg new file mode 100644 index 0000000..9c3bd58 Binary files /dev/null and b/img/writeb.jpg differ diff --git a/img/writeb2.jpg b/img/writeb2.jpg new file mode 100644 index 0000000..6ea7808 Binary files /dev/null and b/img/writeb2.jpg differ