- 什么是屏障霎俩?
- golang 涉及到的三個寫屏障
- 原理分析
- 示例分析代碼
- 先看逃逸分析
- 寫屏障真實(shí)的樣子
什么是屏障雁刷?
承接上篇概述德崭,下面討論什么是寫屏障荔睹?先說結(jié)論:
- 內(nèi)存屏障只是對應(yīng)一段特殊的代碼
- 內(nèi)存屏障這段代碼在編譯期間生成
- 內(nèi)存屏障本質(zhì)上在運(yùn)行期間攔截內(nèi)存寫操作,相當(dāng)于一個 hook 調(diào)用
golang 涉及到的三個寫屏障
- 插入寫屏障
- 刪除寫屏障
- 混合寫屏障(旁白:其實(shí)本質(zhì)上是兩個项阴,混合寫屏障就是插入寫屏障和刪除寫屏障的混合)
這三個名詞什么意思滑黔?區(qū)別在哪里?
最本質(zhì)的區(qū)別就是:我們說了环揽,內(nèi)存屏障其實(shí)就是編譯器幫你生成的一段 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ī)
先看逃逸分析
為什么先看逃逸分析帝嗡?
因?yàn)橹挥卸焉蠈ο蟮膶懖艜赡苡袑懫琳希@又是個什么原因呢璃氢?因?yàn)槿绻麑I系膶懽鰯r截哟玷,那么流程代碼會非常復(fù)雜,并且性能下降會非常大一也,得不償失巢寡。根據(jù)局部性的原理來說,其實(shí)我們程序跑起來椰苟,大部分的其實(shí)都是操作在棧上抑月,函數(shù)參數(shù)啊、函數(shù)調(diào)用導(dǎo)致的壓棧出棧啊舆蝴、局部變量啊谦絮,協(xié)程棧,這些如果也弄起寫屏障洁仗,那么可想而知了层皱,根本就不現(xiàn)實(shí),復(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)過編譯器的“逃逸分析”之后,實(shí)際分配到堆上的是三次:
- 14 行 —— 觸發(fā)逃逸(分配到堆上)
- 這個必須得分配到堆上鸳谜,因?yàn)槌诉@個 goroutine 還要存活呢
- 19 行 —— 無 (分配到棧上)
- 這個雖然也是 new膝藕,單就分配到棧上就行,因?yàn)?b0 這個對象就是一個純粹的棧對象
- 23 行 —— 觸發(fā)逃逸 (分配到堆上)
- 這個需要分配到堆上咐扭,因?yàn)榉峙涑鰜淼膶ο笮枰獋鬟f到其他協(xié)程使用
- 24 行 —— 觸發(fā)逃逸 (分配到堆上)
- 這次必須注意下芭挽,其實(shí)站在我們上帝視角,這次的分配其實(shí)也可以分配到棧上蝗肪。這種情況編譯器就簡單處理了袜爪,直接給分配到堆上。這種就屬于編譯器它摸不準(zhǔn)的薛闪,那么分配到堆上就對了辛馆,反正也就性能有點(diǎn)影響,功能不會有問題豁延,不然的話你真分配到棧上了昙篙,一旦棧被回收就出問題了
寫屏障真實(shí)的樣子
再看下編譯器匯編的代碼:
從這個地方我們需要知道一個事情,go 的關(guān)鍵字語法呀诱咏,其實(shí)在編譯的時候苔可,都會對應(yīng)到一個特定的函數(shù),比如 new 這個關(guān)鍵字就對應(yīng)了 newobject
函數(shù)袋狞,go 這個關(guān)鍵字對應(yīng)的是 newproc
函數(shù)焚辅。貼一張比較完整的圖:
從這個匯編代碼我們也確認(rèn)了,23苟鸯,24行的對象分配確實(shí)是在堆上法焰。我們再看下函數(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)是邏輯簡單了,也能很好的支持多返回值的實(shí)現(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 里的實(shí)現(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 隊(duì)列是否滿障陶?
CMPQ R14, (p_wbBuf+wbBuf_end)(R13)
// 賦值的前后兩個值都會被入隊(duì)
// 把 value 存到指定 buffer 位置
MOVQ AX, -16(R14) // Record value
// 把 *slot 存到指定 buffer 位置
MOVQ (DI), R13
MOVQ R13, -8(R14)
// 如果 wbBuffer 隊(duì)列滿了,那么就下刷處理聊训,比如置灰抱究,置黑等操作
JEQ flush
ret:
// 賦值:*slot = val
MOVQ 104(SP), R14
MOVQ 112(SP), R13
MOVQ AX, (DI)
RET
flush:
。带斑。鼓寺。
// 隊(duì)列滿了,統(tǒng)一處理勋磕,這個其實(shí)是一個批量優(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 隊(duì)列。
我們看到 CALL runtime·wbBufFlush(SB)
冬阳,這個函數(shù) wbBufFlush 是 golang 實(shí)現(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)批量處理隊(duì)列里的值驳庭,這個就是之前在 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++
}
// 置灰色(投入灰色的隊(duì)列),這就是我們的目的氯窍,對象在這里面我們就不怕了饲常,我們要掃描的就是這個隊(duì)列捏检;
gcw.putBatch(ptrs[:pos])
_p_.wbBuf.reset()
}
所以我們總結(jié)下,寫屏障到底做了什么:
- hook 寫操作
- hook 住了寫操作之后不皆,把賦值語句的前后兩個值都記錄下來贯城,投入 buffer 隊(duì)列
- buffer 攢滿之后,批量刷到掃描隊(duì)列(置灰)(這是 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ù)講述插入寫屏障究竟是什么東西枕磁?
堅(jiān)持思考渡蜻,方向比努力更重要。微信公眾號關(guān)注我:奇伢云存儲