- 什么是屏障?
- golang 涉及到的三個寫屏障
- 原理分析
- 示例分析代碼
- 先看逃逸分析
- 寫屏障真實的樣子
什么是屏障?
承接上篇概述,下面討論什么是寫屏障?先說結(jié)論:
- 內(nèi)存屏障只是對應(yīng)一段特殊的代碼
- 內(nèi)存屏障這段代碼在編譯期間生成
- 內(nèi)存屏障本質(zhì)上在運(yùn)行期間攔截內(nèi)存寫操作,相當(dāng)于一個 hook 調(diào)用
golang 涉及到的三個寫屏障
- 插入寫屏障
- 刪除寫屏障
- 混合寫屏障(旁白:其實本質(zhì)上是兩個,混合寫屏障就是插入寫屏障和刪除寫屏障的混合)
這三個名詞什么意思?區(qū)別在哪里?
最本質(zhì)的區(qū)別就是:我們說了,內(nèi)存屏障其實就是編譯器幫你生成的一段 hook 代碼,這三個屏障的本質(zhì)區(qū)別就是 hook 的時機(jī)不同而已。
原理分析
聲明下,下面的例子使用的是 go1.13.3。
示例分析代碼
一直說,寫屏障是編譯器生成的,先形象看下代碼樣子:
1 package main
2
3 type BaseStruct struct {
4 name string
5 age int
6 }
7
8 type Tstruct struct {
9 base *BaseStruct
10 field0 int
11 }
12
13 func funcAlloc0 (a *Tstruct) {
14 a.base = new(BaseStruct) // new 一個BaseStruct結(jié)構(gòu)體,賦值給 a.base 字段
15 }
16
17 func funcAlloc1 (b *Tstruct) {
18 var b0 Tstruct
19 b0.base = new(BaseStruct) // new 一個BaseStruct結(jié)構(gòu)體,賦值給 b0.base 字段
20 }
21
22 func main() {
23 a := new(Tstruct) // new 一個Tstruct 結(jié)構(gòu)體
24 b := new(Tstruct) // new 一個Tstruct 結(jié)構(gòu)體
25
26 go funcAlloc0(a)
27 go funcAlloc1(b)
28 }
這里例子,可以用來觀察兩個東西:
- 概述篇提到的逃逸分析
- 編譯器插入內(nèi)存屏障的時機(jī)
先看逃逸分析
為什么先看逃逸分析?
因為只有堆上對象的寫才會可能有寫屏障,這又是個什么原因呢?因為如果對棧上的寫做攔截,那么流程代碼會非常復(fù)雜,并且性能下降會非常大,得不償失。根據(jù)局部性的原理來說,其實我們程序跑起來,大部分的其實都是操作在棧上,函數(shù)參數(shù)啊、函數(shù)調(diào)用導(dǎo)致的壓棧出棧啊、局部變量啊,協(xié)程棧,這些如果也弄起寫屏障,那么可想而知了,根本就不現(xiàn)實,復(fù)雜度和性能就是越不過去的坎。
繼續(xù)看逃逸什么意思?就是內(nèi)存分配到堆上。golang 可以在編譯的時候使用 -m 參數(shù)支持把這個可視化出來:
$ go build -gcflags "-N -l -m" ./test_writebarrier0.go
# command-line-arguments
./test_writebarrier0.go:13:18: funcAlloc0 a does not escape
./test_writebarrier0.go:14:17: new(BaseStruct) escapes to heap
./test_writebarrier0.go:17:18: funcAlloc1 b does not escape
./test_writebarrier0.go:19:18: funcAlloc1 new(BaseStruct) does not escape
./test_writebarrier0.go:23:13: new(Tstruct) escapes to heap
./test_writebarrier0.go:24:13: new(Tstruct) escapes to heap
先說逃逸分析兩點(diǎn)原則:
- 在保證程序正確性的前提下,盡可能的把對象分配到棧上,這樣性能最好;
- 棧上的對象生命周期就跟隨 goroutine ,協(xié)程終結(jié)了,它就沒了
- 明確一定要分配到堆上對象,或者不確定是否要分配在堆上的對象,那么就全都分配到堆上;
- 這種對象的生命周期始于業(yè)務(wù)程序的創(chuàng)建,終于垃圾回收器的回收
我們看到源代碼,有四次 new 對象的操作,經(jīng)過編譯器的“逃逸分析”之后,實際分配到堆上的是三次:
- 14 行 —— 觸發(fā)逃逸(分配到堆上)
- 這個必須得分配到堆上,因為除了這個 goroutine 還要存活呢
- 19 行 —— 無 (分配到棧上)
- 這個雖然也是 new,單就分配到棧上就行,因為 b0 這個對象就是一個純粹的棧對象
- 23 行 —— 觸發(fā)逃逸 (分配到堆上)
- 這個需要分配到堆上,因為分配出來的對象需要傳遞到其他協(xié)程使用
- 24 行 —— 觸發(fā)逃逸 (分配到堆上)
- 這次必須注意下,其實站在我們上帝視角,這次的分配其實也可以分配到棧上。這種情況編譯器就簡單處理了,直接給分配到堆上。這種就屬于編譯器它摸不準(zhǔn)的,那么分配到堆上就對了,反正也就性能有點(diǎn)影響,功能不會有問題,不然的話你真分配到棧上了,一旦棧被回收就出問題了
寫屏障真實的樣子
再看下編譯器匯編的代碼:

從這個地方我們需要知道一個事情,go 的關(guān)鍵字語法呀,其實在編譯的時候,都會對應(yīng)到一個特定的函數(shù),比如 new 這個關(guān)鍵字就對應(yīng)了 newobject 函數(shù),go 這個關(guān)鍵字對應(yīng)的是 newproc 函數(shù)。貼一張比較完整的圖:

從這個匯編代碼我們也確認(rèn)了,23,24行的對象分配確實是在堆上。我們再看下函數(shù) funcAlloc0 和 funcAlloc1 這兩個。
main.funcAlloc0
13 func funcAlloc0 (a *Tstruct) {
14 a.base = new(BaseStruct) // new 一個BaseStruct結(jié)構(gòu)體,賦值給 a.base 字段
15 }

簡單的注釋解析:
(gdb) disassemble
Dump of assembler code for function main.funcAlloc0:
0x0000000000456b10 <+0>: mov %fs:0xfffffffffffffff8,%rcx
0x0000000000456b19 <+9>: cmp 0x10(%rcx),%rsp
0x0000000000456b1d <+13>: jbe 0x456b6f <main.funcAlloc0+95>
0x0000000000456b1f <+15>: sub $0x20,%rsp
0x0000000000456b23 <+19>: mov %rbp,0x18(%rsp)
0x0000000000456b28 <+24>: lea 0x18(%rsp),%rbp
0x0000000000456b2d <+29>: lea 0x1430c(%rip),%rax # 0x46ae40
0x0000000000456b34 <+36>: mov %rax,(%rsp)
0x0000000000456b38 <+40>: callq 0x40b060 <runtime.newobject>
# newobject的返回值在 0x8(%rsp) 里,golang 的參數(shù)和返回值都是通過棧傳遞的。這個跟 c 程序不同,c 程序是溢出才會用到棧,這里先把返回值放到寄存器 rax
0x0000000000456b3d <+45>: mov 0x8(%rsp),%rax
0x0000000000456b42 <+50>: mov %rax,0x10(%rsp)
# 0x28(%rsp) 就是 a 的地址:0xc0000840b0
=> 0x0000000000456b47 <+55>: mov 0x28(%rsp),%rdi
0x0000000000456b4c <+60>: test %al,(%rdi)
# 這里判斷是否開啟了屏障(垃圾回收的掃描并發(fā)過程,才會把這個標(biāo)記打開,沒有打開的情況,對于堆上的賦值只是多走一次判斷開銷)
0x0000000000456b4e <+62>: cmpl $0x0,0x960fb(%rip) # 0x4ecc50 <runtime.writeBarrier>
0x0000000000456b55 <+69>: je 0x456b59 <main.funcAlloc0+73>
0x0000000000456b57 <+71>: jmp 0x456b68 <main.funcAlloc0+88>
# 賦值 a.base = xxxx
0x0000000000456b59 <+73>: mov %rax,(%rdi)
0x0000000000456b5c <+76>: jmp 0x456b5e <main.funcAlloc0+78>
0x0000000000456b5e <+78>: mov 0x18(%rsp),%rbp
0x0000000000456b63 <+83>: add $0x20,%rsp
0x0000000000456b67 <+87>: retq
# 如果是開啟了屏障,那么完成 a.base = xxx 的賦值就是在 gcWriteBarrier 函數(shù)里面了
0x0000000000456b68 <+88>: callq 0x44d170 <runtime.gcWriteBarrier>
0x0000000000456b6d <+93>: jmp 0x456b5e <main.funcAlloc0+78>
0x0000000000456b6f <+95>: callq 0x44b370 <runtime.morestack_noctxt>
0x0000000000456b74 <+100>: jmp 0x456b10 <main.funcAlloc0>
End of assembler dump.
所以,從上面簡單的匯編代碼,我們印證得出幾個小知識點(diǎn):
- golang 傳參和返回參數(shù)都是通過棧來傳遞的(可以思考下優(yōu)略點(diǎn),有點(diǎn)是邏輯簡單了,也能很好的支持多返回值的實現(xiàn),缺點(diǎn)是比寄存器的方式略慢,但是這種損耗在程序的運(yùn)行下可以忽略);
- 寫屏障是一段編譯器插入的特殊代碼,在編譯期間插入,代碼函數(shù)名字叫做
gcWriteBarrier; - 屏障代碼并不是直接運(yùn)行,也是要條件判斷的,并不是只要是堆上內(nèi)存賦值就會運(yùn)行
gcWriteBarrier代碼,而是要有一個條件判斷。這里提前透露下,這個條件判斷是垃圾回收器掃描開始前,stw 程序給設(shè)置上去的;- 所以平時對于堆上內(nèi)存的賦值,多了一次寫操作;
偽代碼如下:
if runtime.writeBarrier.enabled {
runtime.gcWriteBarrier(ptr, val)
} else {
*ptr = val
}
說到 golang 傳參數(shù)只用棧這點(diǎn),這里就再深入挖掘一點(diǎn),golang ABI(Application Binary Interface)標(biāo)準(zhǔn)就是這樣的,傳參數(shù)用棧,返回值也用棧。但是巧了,剛好,就有一些特例,我們今天遇到的 runtime.gcWriteBarrier 就是個特例,gcWriteBarrier 就故意違反了這個慣例,這里引用一段這匯編文件的注釋:
// gcWriteBarrier performs a heap pointer write and informs the GC.
//
// gcWriteBarrier does NOT follow the Go ABI. It takes two arguments:
// - DI is the destination of the write
// - AX is the value being written at DI
// It clobbers FLAGS. It does not clobber any general-purpose registers,
// but may clobber others (e.g., SSE registers).
這里為了減少 GC 導(dǎo)致性能的損耗,使用了 rdi ,rax ,這兩個寄存器來傳參數(shù):
- rdi :堆內(nèi)存寫入的地址
- rax :賦的值
我們繼續(xù)看下 runtime·gcWriteBarrier 函數(shù)干啥的,這個函數(shù)是用純匯編寫的,舉一個特定cpu集合的例子,在 asm_amd64.s 里的實現(xiàn)。這個函數(shù)只干兩件事:
- 執(zhí)行寫請求
- 處理 GC 相關(guān)的邏輯
下面簡單理解下 runtime·gcWriteBarrier 這個函數(shù):
TEXT runtime·gcWriteBarrier(SB),NOSPLIT,$120
get_tls(R13)
MOVQ g(R13), R13
MOVQ g_m(R13), R13
MOVQ m_p(R13), R13
MOVQ (p_wbBuf+wbBuf_next)(R13), R14
LEAQ 16(R14), R14
MOVQ R14, (p_wbBuf+wbBuf_next)(R13)
// 檢查 buffer 隊列是否滿?
CMPQ R14, (p_wbBuf+wbBuf_end)(R13)
// 賦值的前后兩個值都會被入隊
// 把 value 存到指定 buffer 位置
MOVQ AX, -16(R14) // Record value
// 把 *slot 存到指定 buffer 位置
MOVQ (DI), R13
MOVQ R13, -8(R14)
// 如果 wbBuffer 隊列滿了,那么就下刷處理,比如置灰,置黑等操作
JEQ flush
ret:
// 賦值:*slot = val
MOVQ 104(SP), R14
MOVQ 112(SP), R13
MOVQ AX, (DI)
RET
flush:
。。。
// 隊列滿了,統(tǒng)一處理,這個其實是一個批量優(yōu)化手段
CALL runtime·wbBufFlush(SB)
。。。
JMP ret
思考下:不是說把 *slot = value 直接置灰色,置黑色,就完了嘛,這里搞得這么復(fù)雜?
最開始還真不是這樣的,這個也是一個優(yōu)化的過程,這里是利用批量的一個思想做的一個優(yōu)化。我們再理解下最本質(zhì)的東西,觸發(fā)了寫屏障之后,我們的核心目的是為了能夠把賦值的前后兩個值記錄下來,以便 GC 垃圾回收器能得到通知,從而避免錯誤的回收。記錄下來是最本質(zhì)的,但是并不是要立馬處理,所以這里做的優(yōu)化就是,攢滿一個 buffer ,然后批量處理,這樣效率會非常高的。
wbBuf 結(jié)構(gòu)如下:
|-------------------------------------|
| 8 | 8 | 8 * 512 | 4 |
|-------------------------------------|
每個 P 都有這么個 wbBuf 隊列。
我們看到 CALL runtime·wbBufFlush(SB) ,這個函數(shù) wbBufFlush 是 golang 實現(xiàn)的,本質(zhì)上是調(diào)用 wbBufFlush1 。這個函數(shù)才是 hook 寫操作想要做的事情,精簡了下代碼如下:
func wbBufFlush1(_p_ *p) {
start := uintptr(unsafe.Pointer(&_p_.wbBuf.buf[0]))
n := (_p_.wbBuf.next - start) / unsafe.Sizeof(_p_.wbBuf.buf[0])
ptrs := _p_.wbBuf.buf[:n]
_p_.wbBuf.next = 0
gcw := &_p_.gcw
pos := 0
// 循環(huán)批量處理隊列里的值,這個就是之前在 gcWriteBarrier 賦值的
for _, ptr := range ptrs {
if ptr < minLegalPointer {
continue
}
obj, span, objIndex := findObject(ptr, 0, 0)
if obj == 0 {
continue
}
mbits := span.markBitsForIndex(objIndex)
if mbits.isMarked() {
continue
}
mbits.setMarked()
if span.spanclass.noscan() {
gcw.bytesMarked += uint64(span.elemsize)
continue
}
ptrs[pos] = obj
pos++
}
// 置灰色(投入灰色的隊列),這就是我們的目的,對象在這里面我們就不怕了,我們要掃描的就是這個隊列;
gcw.putBatch(ptrs[:pos])
_p_.wbBuf.reset()
}
所以我們總結(jié)下,寫屏障到底做了什么:
- hook 寫操作
- hook 住了寫操作之后,把賦值語句的前后兩個值都記錄下來,投入 buffer 隊列
- buffer 攢滿之后,批量刷到掃描隊列(置灰)(這是 GO 1.10 左右引入的優(yōu)化)
main.funcAlloc1
17 func funcAlloc1 (b *Tstruct) {
18 var b0 Tstruct
19 b0.base = new(BaseStruct) // new 一個BaseStruct結(jié)構(gòu)體,賦值給 b0.base 字段
20 }

最后,再回顧看下 main.funcAlloc1 函數(shù),這個函數(shù)是只有棧操作,非常簡單。
下一篇,繼續(xù)講述插入寫屏障究竟是什么東西?
堅持思考,方向比努力更重要。微信公眾號關(guān)注我:奇伢云存儲
