chikaku

且听风吟

永远是深夜有多好。
github
email

Go 標準庫實驗性包 arena 源碼解析

arena 是 Go 標準庫提供的一個實驗性包,在 #51317 中提出,目的是在用戶層提供一個自主可控的內存分配和釋放方式,以便更細粒度的控制 GC

arena 實現方式是提供一個 Arena 對象,用戶可以通過此對象來進行內存分配和手動的內存釋放,等這個包穩定以後 Go 應用和庫可以通過自適應調整,使 GC 得到進一步的改善。

arena 的一個使用場景是:假設有一個樹狀數據結構,樹中每個節點都是一個堆對象,可以使用 Arena 分配每個節點對象,當整個樹結構不需要時,可以通過 Arena 的 free 接口一次釋放掉所有的節點對象(由於是同一個 Arena 連續分配,所有節點對象的內存空間是連續的,釋放也相對會比較快)。

懶得畫圖也不太擅長大段的文字描述,所以下面就用代碼加註釋這樣稍硬一點的方式進行分析。

Arena 使用示例#

使用 arena 的最低 Go 版本是 1.20 且需要開啟 GOEXPERIMENT

$ go env -w GOEXPERIMENT='arenas'

寫個測試,看下 arena 包的使用方法以及一些注意事項

func Test_arena(t *testing.T) {
    ar := arena.NewArena() // 創建一個 arena 對象

    // 使用 arena.MakeSlice 創建切片提供 len 和 cap
    // 類似於原生的 makeslice
    s1 := arena.MakeSlice[int](ar, 0, 8)
    s1 = append(s1, 1, 2, 3, 4, 5)
    t.Log(s1) // [1 2 3 4 5]

    // 使用 arena.Clone 克隆一個對象
    s2 := []string{"🐱", "🐭"}
    s3 := arena.Clone(s2)
    t.Log(s3) // [🐱 🐭]

    // 使用 arena.New 創建一個對象類似於原生的 new 返回的是 *T
    s4 := arena.New[map[int]int](ar)
    t.Log((*s4)[0]) // 0

    // 如果不加這個 append 在 Free 後面訪問 s1 會崩潰
    // 因為這裡 append 超過 cap 後實際上使用了 growslice
    // 所以 s1 變成了原生的 mallocgc 分配的內存不再是 arena 管理的區域
    s1 = append(s1, 6, 7, 8, 9)

    // 使用 Free 釋放掉 arena 申請的所有的內存空間
    ar.Free()

    // 由於 growslice 現在的 s1 跟 ar 沒關係訪問不會崩潰
    t.Log(s1)
  
    // 由於 ar 已經釋放掉,原本的內存區域被設置成不可訪問(參考後面的實現源碼)這裡會崩潰
    // accessed data from freed user arena 0xc0047fffc0
    // fatal error: fault
    // [signal SIGSEGV: segmentation violation code=0x2 addr=0xc004000000 pc=0x1133494]
    t.Log(((*s4)[0]))
}

Arena 實現源碼分析#

在讀代碼的時候我會習慣先設想一個使用場景,然後按照流程去讀對應的實現:

  • 首次創建一個 arena
  • arena 首次申請空間(每次申請一段空間)
  • arena 分配對象 1 分配對象 2 ... 直到空間不夠用
  • 再次申請空間,以分配新對象
  • 釋放 arena (大部分情況下此處應有緩存)
  • 再次創建一個 arena 或者申請空間(可能會從緩存中取一些東西)

以下源碼分析跟 Go 內存管理有相當多關聯,需要讀者對 Go 的內存分配器實現(mspan mheap 等)以及 GC 的流程有基本的了解。

從標準庫到 runtime#

標準庫 arena 實際上是 runtime 的封裝通過 linkname 鏈接到 src/runtime/arena.go 直接看 arena.go 就好

func newUserArena() *userArena // 創建 arena 對象
func (a *userArena) new(typ *_type) unsafe.Pointer
func (a *userArena) slice(sl any, cap int)
func (a *userArena) free()
// ...

Arena 的運行時結構#

arenaChunk 是一段堆內存,在由 mheap 分配後通過 mspan 管理(spanClass = 0)

type userArena struct {
    fullList *mspan       // 已經申請的 mspan 雙向鏈表
    active *mspan         // 最近申請的一個 mspan
    refs []unsafe.Pointer // 保存 arenaChunk(mspan) 基址列表
                          // 最後一項總是 ref 到 active mspan 剩下的就是在 fullList 裡面的
    defunct atomic.Bool   // 標記此 userArena 是否被 free 過
}

創建 Arena#

func newUserArena() *userArena {
    a := new(userArena)
    SetFinalizer(a, func(a *userArena) {
        // 如果沒有主動 free 在 GC 的時候 free 掉
        a.free()
    })
    a.refill() // 創建的時候就申請一段空間
    return a
}

填充 Arena 空間#

func (a *userArena) refill() *mspan {
    // 第一次 refill 的時候 s 肯定是空的
    s := a.active
    var x unsafe.Pointer

    // 非首次 refill
    if s != nil {
        // 如果舊的 active 不為空則把他們放到 fullList 上
        // 然後將之後新申請的 mspan 放到 active
        s.next = a.fullList
        a.fullList = s
        a.active = nil
        s = nil
    }

    // 從全局的重用鏈表中獲取 arenaChunk 直接取最後一個
    // 這個鏈表是在後面 arena free 的時候放進去的
    lock(&userArenaState.lock)
    if len(userArenaState.reuse) > 0 {
        // Pick off the last arena chunk from the list.
        n := len(userArenaState.reuse) - 1
        x = userArenaState.reuse[n].x
        s = userArenaState.reuse[n].mspan
        userArenaState.reuse[n].x = nil
        userArenaState.reuse[n].mspan = nil
        userArenaState.reuse = userArenaState.reuse[:n]
    }
    unlock(&userArenaState.lock)

    if s == nil {
        // 申請一個新的 arenaChunk 實際用 mspan 管理
        x, s = newUserArenaChunk()
    }

    // 把新的 arenaChunk(mspan) 的基址保存在 refs 裡面
    a.refs = append(a.refs, x)
    // 把最近申請的 mspan 放到 acvive
    // 但是不加到 fullList
    a.active = s
    return s
}

// 創建新的 arenaChunk 實際還是使用 mspan 管理內存
func newUserArenaChunk() (unsafe.Pointer, *mspan) {
    // userArena 也要添加輔助 GC 的 credit
    deductAssistCredit(userArenaChunkBytes)

    var span *mspan
    systemstack(func() {
        // 使用 mheap 獲取一個 userArena
        span = mheap_.allocUserArenaChunk()
    })

    // 返回的 x 就是 mspan 管理的一段堆空間的基址(第一個 byte 的地址)
    x := unsafe.Pointer(span.base())

    // 如果在 GC 期間申請直接把對象標記成黑色(因為是全空也無需掃描)
    if gcphase != _GCoff {
        gcmarknewobject(span, span.base(), span.elemsize)
    }

    // 內存採樣相關...

    // 影響了堆內存空間,觸發一次 GC test
    if t := (gcTrigger{kind: gcTriggerHeap}); t.test() {
        gcStart(t)
    }

    return x, span
}

// 使用 mheap 申請 arenaChunk
func (h *mheap) allocUserArenaChunk() *mspan {
    var s *mspan
    var base uintptr

    lock(&h.lock)
    if !h.userArena.readyList.isEmpty() {
        // 先檢查空閒鏈表 readyList 雙向鏈表
        s = h.userArena.readyList.first
        h.userArena.readyList.remove(s)
        base = s.base()
    } else {
        // 申請一個新的 arena
        hintList := &h.userArena.arenaHints

        // 這個裡面就比較複雜了,把大小對齊,向 OS 申請空間
        // 以及在 mheap 上記錄 arena 的 metadata 等等
        v, size := h.sysAlloc(userArenaChunkBytes, hintList, false)

        // 如果拿到的大小比請求的要大
        // 則進行分割把剩下的部分放到 readyList 裡面以後用
        // 返回的 size 還是 userArenaChunkBytes 大小
        if size > userArenaChunkBytes {
            for i := uintptr(userArenaChunkBytes); i < size; i += userArenaChunkBytes {
                s := h.allocMSpanLocked()
                s.init(uintptr(v)+i, userArenaChunkPages)
                h.userArena.readyList.insertBack(s)
            }
            size = userArenaChunkBytes
        }
        base = uintptr(v)
    }
    unlock(&h.lock)

    // sysAlloc 返回的是保留地址空間(BRK)需要 mmap 改下權限
    sysMap(unsafe.Pointer(base), userArenaChunkBytes, &gcController.heapReleased)

    // 創建一個 mspan 管理申請到的空間這裡 spanclass 是 0
    spc := makeSpanClass(0, false)
    h.initSpan(s, spanAllocHeap, spc, base, userArenaChunkPages)
    s.isUserArenaChunk = true

    // GC 和 heap profile 的數據統計...

    // 把 mspan 放到 mcentral 的 full spanSet 上
    // 標記沒有可用空間(這個空間只能被 userArena 所有者使用)
    h.central[spc].mcentral.fullSwept(h.sweepgen).push(s)
    s.limit = s.base() + userArenaChunkBytes
    s.freeindex = 1
    s.allocCount = 1

    // 清理 mspan 在 mheap 上的 bitmap
    s.initHeapBits(true)

    // 內存區域清零(也可以增加使用 hugepage 的可能性)
    // 因為 Linux 會統計和預測內存的訪問模式
    // 連續的零值會被判斷成順序掃描模式更可能用 hugepage
    memclrNoHeapPointers(unsafe.Pointer(s.base()), s.elemsize)
    s.needzero = 0
    s.freeIndexForScan = 1

    return s
}

創建對象#

以 MakeSlice 創建一個切片為例

func (a *userArena) slice(sl any, cap int) {
    // 獲取切片本身的類型
    i := efaceOf(&sl)
    typ := i._type
    // 獲取切片元素的類型
    typ = (*slicetype)(unsafe.Pointer(typ)).Elem
    // 省略了一些 typekind 檢查

    // 使用 alloc 申請並構造了一個符合 slice ABI 的結構賦值上去
    *((*slice)(i.data)) = slice{a.alloc(typ, cap), cap, cap}
}

func (a *userArena) alloc(typ *_type, cap int) unsafe.Pointer {
    s := a.active
    var x unsafe.Pointer
    for {
        // 從當前 active 的 mspan 開始嘗試分配空間
        x = s.userArenaNextFree(typ, cap)
        if x != nil {
            break
        }
        // 如果 mspan 不夠分配則申請新的 arenaChunk
        // 如果需要申請的大小超過了一個 arenaChunk 會 fallback 到 mallocgc
        // 全新的 arenaChunk 空間肯定足夠分配
        // 新一次 refill 的時候會把之前的 active 放到 fullList 上
        // 然後把最新的 mspan 放到 active
        s = a.refill()
    }
    return x
}

func (s *mspan) userArenaNextFree(typ *_type, cap int) unsafe.Pointer {
    // ...
    // 根據 typesize 和 cap 計算需要的 size
    // 如果 size 超過了 userArenaChunkMaxAllocBytes 只能通過 mallocgc 創建
    // 因為 arenaChunk 不一定連續連續的所以不能使用多個 arenaChunk 構造超出大小的結構
    // 目前 userArenaChunkMaxAllocBytes 為 8M
    if size > userArenaChunkMaxAllocBytes {
        if cap >= 0 {
            return newarray(typ, cap)
        }
        return newobject(typ)
    }

    // typ.PtrBytes 表示這個類型前多少個字節可能包含指針
    // 計算的時候用的是最後一個指針字段的偏移量加上指針大小
    // https://github.com/golang/go/blob/master/src/reflect/type.go#L2618
    // 這個值在 GC 掃描結構體的時候會用到 如果 PtrBytes 為零表示此類型不包含指針

    // takeFromBack 指的是從 areanChunk 的尾部開始分配空間
    // takeFromFront 指的是從 areanChunk 的頭部開始分配空間 
    // 對於沒有指針的類型從後往前分配 反之則從前往後分配
    // 這樣在 GC 掃描的時候從前往後掃到空或者掃到無指針類型的時候就不需要往後繼續掃描了

    var ptr unsafe.Pointer
    if typ.PtrBytes == 0 {
        // Allocate pointer-less objects from the tail end of the chunk.
        v, ok := s.userArenaChunkFree.takeFromBack(size, typ.Align_)
        if ok {
            ptr = unsafe.Pointer(v)
        }
    } else {
        v, ok := s.userArenaChunkFree.takeFromFront(size, typ.Align_)
        if ok {
            ptr = unsafe.Pointer(v)
        }
    }
    if ptr == nil {
        // 當前 active 的 arenaChunk(mspan) 空間不足返回空讓上層創建新的 arenaChunk
        return nil
    }

    // 對於帶指針類型需要在對應 mheap 的 bitmap 上標記
    // 如果是切片類型(cap >= 0)需要逐個元素進行標記
    if typ.PtrBytes != 0 {
        if cap >= 0 {
            userArenaHeapBitsSetSliceType(typ, cap, ptr, s.base())
        } else {
            userArenaHeapBitsSetType(typ, ptr, s.base())
        }
        c := getMCache(mp)
        if cap > 0 {
            // 如果是切片類型只有最後一個元素的最後一段無指針字段不需要要掃描
            // [{*int, int}, {*int, int}, {*int, int}]
            // 上面這個情況只有最後的一个 int 無需掃描
            c.scanAlloc += size - (typ.Size_ - typ.PtrBytes)
        } else {
            // 如果是單個類型,則只算 PtrBytes 即可
            // {int, *int, int, int}
            // 上面這種情況最後的兩個 int 都不需要掃描
            c.scanAlloc += typ.PtrBytes
        }
    }

    // 保證先初始化對象設置好 heap bitmap 然後才會被 GC 觀察到
    // 在一些 weakly ordered 機器上可能會由於亂序有不一致的行為
    // 所以這裡加一層 store/store 屏障
    publicationBarrier()

    return ptr
}

釋放 Arena#

func (a *userArena) free() {
    // fullList 上的對象和 refs 上的地址是一一對應的
    // 除去 refs[len(refs)-1] 表示 active 對象
    s := a.fullList
    i := len(a.refs) - 2
    for s != nil {
        a.fullList = s.next
        s.next = nil
        freeUserArenaChunk(s, a.refs[i])
        s = a.fullList
        i--
    }

    // 把 active 對象放到 reuse 鏈表上
    s = a.active
    if s != nil {
        lock(&userArenaState.lock)
        userArenaState.reuse = append(userArenaState.reuse, liveUserArenaChunk{s, a.refs[len(a.refs)-1]})
        unlock(&userArenaState.lock)
    }

    a.active = nil
    a.refs = nil
}

func freeUserArenaChunk(s *mspan, x unsafe.Pointer) {
    if gcphase == _GCoff {
        // 在沒 GC 的時候把(全局)所有 arenaChunk span 設置成 fault
        lock(&userArenaState.lock)
        faultList := userArenaState.fault
        userArenaState.fault = nil
        unlock(&userArenaState.lock)

        // setUserArenaChunkToFault 函數主要作用是把 arenaChunk 申請時候 mmap 的區域重新設置成不可訪問
        // 這樣如果 free 後還嘗試訪問之前用 arena make 出來的 slice 就會崩潰
        // 而且還會把 mspan 放到 quarantine 等 GC worker sweep

        s.setUserArenaChunkToFault()
        for _, lc := range faultList {
            lc.mspan.setUserArenaChunkToFault()
        }

        // 由於 mspan 可能還保留有指針所以需要 KeepAlive 住等 GC 清理
        // 只有 GC 清理完畢後才會將對應的 mspan 移動到 readyList 上重用
        KeepAlive(x)
        KeepAlive(faultList)
    } else {
        // 在 GC 進行過程中就直接把對應的 arenaChunk mspan 放到 fault 列表上
        // 等下一次 freeUserArenaChunk 且 _GCoff 的時候再清理
        lock(&userArenaState.lock)
        userArenaState.fault = append(userArenaState.fault, liveUserArenaChunk{s, x})
        unlock(&userArenaState.lock)
    }
}

一些想法#

  • 目前的 arena 實現還比較粗糙,像 userArenaState 這種共享數據是放在全局變量且有全局鎖。
  • arenaChunk 的大小也值得關注,目前是 8M 由於 arena 的 free 並不是真的立即回收到內存分配器或者 OS 還需要 GC sweep 如果隨意創建 arena 可以想象每個 arena 至少一個 8M 大小的 arenaChunk 很容易把 heap 撐爆。
  • 空間浪費問題,如果是一些比較大的數據結構比如 4+M 可能會有 40% 的空間浪費,不過一般也不會這麼大,需要按使用場景考慮這個問題。不知道後續會不會可以自定義 areaChunk 的大小。
  • 性能問題,測試下來似乎提升並沒有想象中那麼高,也有可能是我 benchmark 代碼有問題,這裡就不貼出來了,有需要的時候針對特定場景測試會比較準確。
載入中......
此文章數據所有權由區塊鏈加密技術和智能合約保障僅歸創作者所有。