golang 垃圾回收(二)屏障技術(shù) 2020/6/3 10:44

  • 什么是屏障霎俩?
  • golang 涉及到的三個寫屏障
  • 原理分析
    • 示例分析代碼
    • 先看逃逸分析
    • 寫屏障真實(shí)的樣子

什么是屏障雁刷?

承接上篇概述德崭,下面討論什么是寫屏障荔睹?先說結(jié)論:

  1. 內(nèi)存屏障只是對應(yīng)一段特殊的代碼
  2. 內(nèi)存屏障這段代碼在編譯期間生成
  3. 內(nèi)存屏障本質(zhì)上在運(yùn)行期間攔截內(nèi)存寫操作,相當(dāng)于一個 hook 調(diào)用

golang 涉及到的三個寫屏障

  1. 插入寫屏障
  2. 刪除寫屏障
  3. 混合寫屏障(旁白:其實(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 }

這里例子衡创,可以用來觀察兩個東西:

  1. 概述篇提到的逃逸分析
  2. 編譯器插入內(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)原則

  1. 在保證程序正確性的前提下她奥,盡可能的把對象分配到棧上瓮增,這樣性能最好怎棱;
    1. 棧上的對象生命周期就跟隨 goroutine ,協(xié)程終結(jié)了绷跑,它就沒了
  2. 明確一定要分配到堆上對象拳恋,或者不確定是否要分配在堆上的對象,那么就全都分配到堆上你踩;
    1. 這種對象的生命周期始于業(yè)務(wù)程序的創(chuàng)建诅岩,終于垃圾回收器的回收

我們看到源代碼讳苦,有四次 new 對象的操作带膜,經(jīng)過編譯器的“逃逸分析”之后,實(shí)際分配到堆上的是三次:

  1. 14 行 —— 觸發(fā)逃逸(分配到堆上)
    1. 這個必須得分配到堆上鸳谜,因?yàn)槌诉@個 goroutine 還要存活呢
  2. 19 行 —— 無 (分配到棧上)
    1. 這個雖然也是 new膝藕,單就分配到棧上就行,因?yàn)?b0 這個對象就是一個純粹的棧對象
  3. 23 行 —— 觸發(fā)逃逸 (分配到堆上)
    1. 這個需要分配到堆上咐扭,因?yàn)榉峙涑鰜淼膶ο笮枰獋鬟f到其他協(xié)程使用
  4. 24 行 —— 觸發(fā)逃逸 (分配到堆上)
    • 這次必須注意下芭挽,其實(shí)站在我們上帝視角,這次的分配其實(shí)也可以分配到棧上蝗肪。這種情況編譯器就簡單處理了袜爪,直接給分配到堆上。這種就屬于編譯器它摸不準(zhǔn)的薛闪,那么分配到堆上就對了辛馆,反正也就性能有點(diǎn)影響,功能不會有問題豁延,不然的話你真分配到棧上了昙篙,一旦棧被回收就出問題了

寫屏障真實(shí)的樣子

再看下編譯器匯編的代碼:

image.png

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

image.png

從這個匯編代碼我們也確認(rèn)了,23苟鸯,24行的對象分配確實(shí)是在堆上法焰。我們再看下函數(shù) funcAlloc0funcAlloc1 這兩個。

main.funcAlloc0

 13 func funcAlloc0 (a *Tstruct) {
 14     a.base = new(BaseStruct)    // new 一個BaseStruct結(jié)構(gòu)體倔毙,賦值給 a.base 字段
 15 }

image.png

簡單的注釋解析:

(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)

  1. golang 傳參和返回參數(shù)都是通過棧來傳遞的(可以思考下優(yōu)略點(diǎn)亏掀,有點(diǎn)是邏輯簡單了,也能很好的支持多返回值的實(shí)現(xiàn)泛释,缺點(diǎn)是比寄存器的方式略慢滤愕,但是這種損耗在程序的運(yùn)行下可以忽略);
  2. 寫屏障是一段編譯器插入的特殊代碼怜校,在編譯期間插入间影,代碼函數(shù)名字叫做 gcWriteBarrier
  3. 屏障代碼并不是直接運(yùn)行茄茁,也是要條件判斷的魂贬,并不是只要是堆上內(nèi)存賦值就會運(yùn)行 gcWriteBarrier 代碼,而是要有一個條件判斷裙顽。這里提前透露下付燥,這個條件判斷是垃圾回收器掃描開始前,stw 程序給設(shè)置上去的愈犹;
    1. 所以平時對于堆上內(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ù):

  1. rdi :堆內(nèi)存寫入的地址
  2. rax :賦的值

我們繼續(xù)看下 runtime·gcWriteBarrier 函數(shù)干啥的亿乳,這個函數(shù)是用純匯編寫的,舉一個特定cpu集合的例子,在 asm_amd64.s 里的實(shí)現(xiàn)葛假。這個函數(shù)只干兩件事:

  1. 執(zhí)行寫請求
  2. 處理 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é)下,寫屏障到底做了什么:

  1. hook 寫操作
  2. hook 住了寫操作之后不皆,把賦值語句的前后兩個值都記錄下來贯城,投入 buffer 隊(duì)列
  3. 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 }

image.png

最后能犯,再回顧看下 main.funcAlloc1 函數(shù),這個函數(shù)是只有棧操作犬耻,非常簡單踩晶。

下一篇,繼續(xù)講述插入寫屏障究竟是什么東西枕磁?


堅(jiān)持思考渡蜻,方向比努力更重要。微信公眾號關(guān)注我:奇伢云存儲

掃碼_搜索聯(lián)合傳播樣式-白色版.png
最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
  • 序言:七十年代末计济,一起剝皮案震驚了整個濱河市茸苇,隨后出現(xiàn)的幾起案子,更是在濱河造成了極大的恐慌沦寂,老刑警劉巖学密,帶你破解...
    沈念sama閱讀 216,402評論 6 499
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件,死亡現(xiàn)場離奇詭異传藏,居然都是意外死亡腻暮,警方通過查閱死者的電腦和手機(jī),發(fā)現(xiàn)死者居然都...
    沈念sama閱讀 92,377評論 3 392
  • 文/潘曉璐 我一進(jìn)店門毯侦,熙熙樓的掌柜王于貴愁眉苦臉地迎上來哭靖,“玉大人,你說我怎么就攤上這事侈离∈杂模” “怎么了?”我有些...
    開封第一講書人閱讀 162,483評論 0 353
  • 文/不壞的土叔 我叫張陵霍狰,是天一觀的道長抡草。 經(jīng)常有香客問我,道長蔗坯,這世上最難降的妖魔是什么康震? 我笑而不...
    開封第一講書人閱讀 58,165評論 1 292
  • 正文 為了忘掉前任,我火速辦了婚禮宾濒,結(jié)果婚禮上腿短,老公的妹妹穿的比我還像新娘。我一直安慰自己,他們只是感情好橘忱,可當(dāng)我...
    茶點(diǎn)故事閱讀 67,176評論 6 388
  • 文/花漫 我一把揭開白布赴魁。 她就那樣靜靜地躺著,像睡著了一般钝诚。 火紅的嫁衣襯著肌膚如雪颖御。 梳的紋絲不亂的頭發(fā)上,一...
    開封第一講書人閱讀 51,146評論 1 297
  • 那天凝颇,我揣著相機(jī)與錄音潘拱,去河邊找鬼。 笑死拧略,一個胖子當(dāng)著我的面吹牛芦岂,可吹牛的內(nèi)容都是我干的。 我是一名探鬼主播垫蛆,決...
    沈念sama閱讀 40,032評論 3 417
  • 文/蒼蘭香墨 我猛地睜開眼禽最,長吁一口氣:“原來是場噩夢啊……” “哼!你這毒婦竟也來了袱饭?” 一聲冷哼從身側(cè)響起川无,我...
    開封第一講書人閱讀 38,896評論 0 274
  • 序言:老撾萬榮一對情侶失蹤,失蹤者是張志新(化名)和其女友劉穎宁赤,沒想到半個月后舀透,有當(dāng)?shù)厝嗽跇淞掷锇l(fā)現(xiàn)了一具尸體栓票,經(jīng)...
    沈念sama閱讀 45,311評論 1 310
  • 正文 獨(dú)居荒郊野嶺守林人離奇死亡决左,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內(nèi)容為張勛視角 年9月15日...
    茶點(diǎn)故事閱讀 37,536評論 2 332
  • 正文 我和宋清朗相戀三年,在試婚紗的時候發(fā)現(xiàn)自己被綠了走贪。 大學(xué)時的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片佛猛。...
    茶點(diǎn)故事閱讀 39,696評論 1 348
  • 序言:一個原本活蹦亂跳的男人離奇死亡,死狀恐怖坠狡,靈堂內(nèi)的尸體忽然破棺而出继找,到底是詐尸還是另有隱情,我是刑警寧澤逃沿,帶...
    沈念sama閱讀 35,413評論 5 343
  • 正文 年R本政府宣布婴渡,位于F島的核電站,受9級特大地震影響凯亮,放射性物質(zhì)發(fā)生泄漏边臼。R本人自食惡果不足惜,卻給世界環(huán)境...
    茶點(diǎn)故事閱讀 41,008評論 3 325
  • 文/蒙蒙 一假消、第九天 我趴在偏房一處隱蔽的房頂上張望柠并。 院中可真熱鬧,春花似錦、人聲如沸臼予。這莊子的主人今日做“春日...
    開封第一講書人閱讀 31,659評論 0 22
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽粘拾。三九已至窄锅,卻和暖如春,著一層夾襖步出監(jiān)牢的瞬間缰雇,已是汗流浹背酬滤。 一陣腳步聲響...
    開封第一講書人閱讀 32,815評論 1 269
  • 我被黑心中介騙來泰國打工, 沒想到剛下飛機(jī)就差點(diǎn)兒被人妖公主榨干…… 1. 我叫王不留寓涨,地道東北人盯串。 一個月前我還...
    沈念sama閱讀 47,698評論 2 368
  • 正文 我出身青樓,卻偏偏與公主長得像戒良,于是被迫代替她去往敵國和親体捏。 傳聞我的和親對象是個殘疾皇子,可洞房花燭夜當(dāng)晚...
    茶點(diǎn)故事閱讀 44,592評論 2 353