予焦啦!Golang 记忆体初始化

本节是以 Golang 4b654c0eeca65ffc6588ffd9c99387a7e48002c1 为基准做的实验

予焦啦!在昨日基本地完成 sysAllocsysReserve、与 sysMap 之後,仍然余有问题。看来是 Golang 执行期内部有些机制在防止一般使用者程序错用过於高位的位址。今天我们除了排除这个问题之外,也看看 Golang 在先前几日着墨过的记忆体抽象层之上,究竟是怎麽样利用那些记忆体的。

本节重点概念

  • Golang
    • 作业系统相依的记忆体管理参数
    • 执行期记忆体配置行为

排除问题

根据昨日得到的错误讯息,我们直接以「并非可用位址空间(not in usable address space)」作为关键字搜寻,可以在 src/runtime/malloc.go 之中找到:

        // Check for bad pointers or pointers we can't use.
        {
                var bad string
                p := uintptr(v)
                if p+size < p {
                        bad = "region exceeds uintptr range"
                } else if arenaIndex(p) >= 1<<arenaBits {
                        bad = "base outside usable address space"
                } else if arenaIndex(p+size-1) >= 1<<arenaBits {
                        bad = "end outside usable address space"
                }
                if bad != "" {
                        // This should be impossible on most architectures,
                        // but it would be really confusing to debug.
                        print("runtime: memory allocated by OS [", hex(p), ", ", hex(p+size), ")
 not in usable address space: ", bad, "\n")
                        throw("memory reservation exceeds address space limit")
                }
        }

这一段对使用者程序来说是天经地义。原因是,在 Sv39 的虚拟记忆体转换模式里面,一般来说会让作业系统掌握 0xffffffc0_00000000 以上的位址,并让使用者空间使用 0x40_00000000 以下的位址。在有号整数的领域内,前者属於负数,但後者属於正数。所以这个部分的各种判断,对於 Hoddarla/ethanol 来讲完全不适用。加上一个条件将它整个略过:

        // Check for bad pointers or pointers we can't use.
-       {
+       if GOOS != "opensbi" {
                var bad string
                ...

超出范围?

执行之後,错误变成:

Alloc: 0x2000000 bytes
Reserve: 0x2000000 bytes, at 0x0 but at 0xffffffce00000000
Map: 0x2000000 bytes, at 0xffffffce00000000
fatal error: index out of range

runtime stack:
runtime.throw({0xffffffc000060a28, 0x12})
/home/noner/FOSS/hoddarla/ithome/go/src/runtime/panic.go:965 +0x60 fp=0xffffffc000001a60 sp=0xffffffc000001a38 pc=0xffffffc00002eda0                                                    
runtime.panicCheck1(0xffffffc0000084f0, {0xffffffc000060a28, 0x12}) 
/home/noner/FOSS/hoddarla/ithome/go/src/runtime/panic.go:35 +0xcc fp=0xffffffc000001a88 
sp=0xffffffc000001a60 pc=0xffffffc00002c97c
runtime.goPanicIndexU(0x3ffffff400, 0x400000)
        /home/noner/FOSS/hoddarla/ithome/go/src/runtime/panic.go:92 +0x40 fp=0xffffffc000001ac0 
sp=0xffffffc000001a88 pc=0xffffffc00002cac8
runtime.(*mheap).sysAlloc(0xffffffc0000bcc80, 0x400000)
        /home/noner/FOSS/hoddarla/ithome/go/src/runtime/malloc.go:742 +0x3f0 fp=0xffffffc000001b
70 sp=0xffffffc000001ac0 pc=0xffffffc0000084f0
...

侦测到的错误是超出范围的索引值,出现在 runtime.(*mheap).sysAlloc 函式,

        // Create arena metadata.
        for ri := arenaIndex(uintptr(v)); ri <= arenaIndex(uintptr(v)+size-1); ri++ {
                l2 := h.arenas[ri.l1()]
                if l2 == nil {
                        // Allocate an L2 arena map.
                        ...

先前透过 sys* 等最底层抽象层配置得的 v,在这里透过 arenaIndex 的转换之後,作为 h.arenas 的阵列索引而超出范围。这个阵列代表的是 Golang 记忆体配置器(allocator)当中,作为标记用途的重要结构。

非常简略地概述会是这个样子:Golang 可以动态管理记忆体,并以垃圾回收机制做得还不错闻名。其中许多记忆体的活动会发生在 heap 之上。理论上 heap 的范围可以遍及所有可用之虚拟记忆体空间。这个被使用的范围就被称作竞技场(arena),而这里的 arenas 阵列,就是用以纪录相关资讯的资料结构。

之所以会有索引超标,问题出在从一般位址转换到 arenaIndex 的过程:

// If p is outside the range of valid heap addresses, either l1() or 
// l2() will be out of bounds.
...
func arenaIndex(p uintptr) arenaIdx {
      return arenaIdx((p - arenaBaseOffset) / heapArenaBytes)
}

阵列索引的计算是单纯的线性公式:减去基底再除以系数。系数本身是常数,但问题是先前我们都没有特别去关心 arenaBaseOffset 的值是如何设定的,而就如同注解描述的一样,如果这个值预设是 0 之类的,那这个转换就会变得极大,毕竟我们的正常位址都是极高位的。检查一下这个变数,可以发现:

// On other platforms, the user address space is contiguous                             
// and starts at 0, so no offset is necessary.                                          
arenaBaseOffset = 0xffff800000000000*goarch.IsAmd64 + 0x0a00000000000000*goos.IsAix

是,所以这里我们应该补上,当作业系统指定为 opensbi 之时,就让这个基底偏移量为 0xffffffc000000000。如下:

arenaBaseOffset = 0xffff800000000000*goarch.IsAmd64 + 0x0a00000000000000*goos.IsAix + 0xffffffc000000000*goos.IsOpensbi

之後,重新编译执行的话:

...
Map: 0x10000 bytes, at 0xffffffc500000000
Alloc: 0x10000 bytes 
Reserve: 0x10000 bytes, at 0x0 but at 0xffffffc500010000
Map: 0x10000 bytes, at 0xffffffc500010000
Map: 0x800000 bytes, at 0xffffffd000400000
I000000000000000d
0000000082200000
ffffffc00003dfa0

根据这个例外程序指标之所在可以追溯到,我们在 runtime.goargs 当中触发读取错误。这个时间点,参考 src/runtime/proc.goschedinit 函数的话,

// The bootstrap sequence is:
//
//      call osinit
//      call schedinit       # 当前执行的函数
//      make & queue new G   # 之後会把新的共常式推入排程之中
//      call runtime·mstart  # 正式启用初始执行绪(`m0`)
//
// The new G calls runtime·main.
func schedinit() {
...
        mallocinit()
        fastrandinit() // must run before mcommoninit
        mcommoninit(_g_.m, -1) // 整个第二章的记忆体初始化部分,
                               // 可以说是都在解决这里面发生的事情
        cpuinit()       // must run before alginit
        alginit()       // maps must not be used before this call
        
        // 省略中间一堆 ELF 区段、讯号初始化等的函数

        goargs() // 这是现在出问题的地方
        goenvs()

记忆体初始化的几个重点函数,如 mallocinitmcommoninit 都已经在我们身後了,也就是说,来到了全新的境界了!回顾一下,每一个 sys* 呼叫大致的原因为何,且顺便验证结果是否合理吧。

分析 Golang 记忆体初始化资料

本日上传的修补当中,已经解消了下列的部分行为。

第一段

Memory Base:2147483648
Memory Size:536870912
Alloc: 0x40000 bytes
Reserve: 0x40000 bytes, at 0x0 but at 0xffffffc700000000
Map: 0x40000 bytes, at 0xffffffc700000000
Reserve: 0x20000 bytes, at 0x0 but at 0xffffffc600000000
Reserve: 0x100000 bytes, at 0x0 but at 0xffffffc900000000
Reserve: 0x800000 bytes, at 0x0 but at 0xffffffcc00000000
Reserve: 0x4000000 bytes, at 0x0 but at 0xffffffcf00000000
Reserve: 0x20000000 bytes, at 0x0 but at 0xffffffd200000000

最一开始,直接配置 256KB 的区域,这是来自

// addrRanges is a data structure holding a collection of ranges of
// address space.
//
...
func (a *addrRanges) init(sysStat *sysMemStat) {
        ranges := (*notInHeapSlice)(unsafe.Pointer(&a.ranges))
        ranges.len = 0
        ranges.cap = 16
        ranges.array = (*notInHeap)(persistentalloc(unsafe.Sizeof(addrRange{})*uintptr(ranges.ca
p), goarch.PtrSize, sysStat))
        a.sysStat = sysStat
        a.totalBytes = 0
}

这里的 (*addrRange).init 呼叫 persistentalloc 之後呼叫到的。这函数名就代表了,所配置的记忆体没有对应的释放函数。

之後我们看到一连串连续五笔的保留区域,这是来自 src/runtime/mpagealloc_64bit.go 里面的一个段落:

// sysInit performs architecture-dependent initialization of fields
// in pageAlloc. pageAlloc should be uninitialized except for sysStat
// if any runtime statistic should be updated.
func (p *pageAlloc) sysInit() {
        // Reserve memory for each level. This will get mapped in
        // as R/W by setArenas.
        for l, shift := range levelShift {
                entries := 1 << (heapAddrBits - shift)
                // Reserve b bytes of memory anywhere in the address space.
                b := alignUp(uintptr(entries)*pallocSumBytes, physPageSize)
                r := sysReserve(nil, b)
                ...

每一层保留量相差 8 倍,来自於 heapAddrBits - shift 的差距。

这些分层的保留区域,也是 Golang 记忆体管理机制的一环。若要判别某块记忆体是否已被占用,最节省的做法,也势必需要位元映像(bitmap)对空页面为 0、已占用者为 1 来记录。

但总不成每次检索都扫过整片位元映像的内容。所以,Golang 实作了分为 5 层的 radix tree 来追踪,而这里的五组保留区,是用来当作摘要(summary)使用的资料结构。不仅如此,摘要的型别占据 8 个位元组,其中编码了 3 组可以高达 2^21 的整数内容。至於更细节的部分,已经超过本系列的范围,只能留待日後研究。

如果在这里插入一些印出讯息来观察的话,会看到

heapAddrBits: 48
shift 34
Reserve: 0x20000 bytes, at 0x0 but at 0xffffffc600000000
heapAddrBits: 48
shift 31
Reserve: 0x100000 bytes, at 0x0 but at 0xffffffc900000000
heapAddrBits: 48
shift 28
Reserve: 0x800000 bytes, at 0x0 but at 0xffffffcc00000000
heapAddrBits: 48
shift 25
Reserve: 0x4000000 bytes, at 0x0 but at 0xffffffcf00000000
heapAddrBits: 48
shift 22
...

但说实在的,我们能够用以定址的位元数量最多也就 38 个,这里怎麽禁得起 48 这个数字呢?这其实也是个与架构相依(architecture-dependent)的一个常数,原本定义在 src/runtime/malloc.go 当中,要改它的值的也和先前 arenaBaseOffset 的改法差不多,这里先不改动,分析完一般的记忆体用量再说。

第二段

Reserve: 0x4000000 bytes, at 0xc000000000 but at 0xffffffcf04000000  
Reserve: 0x4000000 bytes, at 0x1c000000000 but at 0xffffffcf08000000 
Reserve: 0x4000000 bytes, at 0x2c000000000 but at 0xffffffcf0c000000
...
Reserve: 0x4000000 bytes, at 0x7dc000000000 but at 0xffffffd0f8000000
Reserve: 0x4000000 bytes, at 0x7ec000000000 but at 0xffffffd0fc000000
Reserve: 0x4000000 bytes, at 0x7fc000000000 but at 0xffffffd100000000
Reserve: 0x8000000 bytes, at 0x0 but at 0xffffffd000000000

先说结论的话,这个部分也是错误的初始状态使然。检视一下为什麽会有这样大范围扫过去的行为,相关的程序码在

// sysAlloc allocates heap arena space for at least n bytes. The
// returned pointer is always heapArenaBytes-aligned and backed by
// h.arenas metadata. The returned size is always a multiple of
// heapArenaBytes. sysAlloc returns nil on failure.
// There is no corresponding free function.
//
// sysAlloc returns a memory region in the Reserved state. This region must
// be transitioned to Prepared and then Ready before use.
//
// h must be locked.
func (h *mheap) sysAlloc(n uintptr) (v unsafe.Pointer, size uintptr) {

虽然一样名为 sysAlloc,但这里是属於 *mheap 型别的函数。总之是要配置竞技场内的空间,并且也没有相对应的释放函数。呼叫到 sysReserve 的地方在:

        // 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<<arenaBits {
                        // Outside addressable heap. Can't use.
                        v = nil
                } else {
                        v = sysReserve(unsafe.Pointer(p), n)
                }

如果提示位址(p)存在的话,就会进到这个控制区块内。进行一些检查都没有成立之後,最後的 else 区,就会试图保留这个提示位址。

之所以这个第二段会连续刷出那麽多保留的尝试,是因为

...
                } else {
                        v = sysReserve(unsafe.Pointer(p), n)
                }
                if p == uintptr(v) {
                        // Success. Update the hint.
                        if !hint.down {
                                p += n
                        }
                        hint.addr = p
                        size = n
                        break
                }
                // Failed. Discard this hint and try the next.
                ...
                                if v != nil {
                        sysFree(v, n, nil)
                }
                h.arenaHints = hint.next
                h.arenaHintAlloc.free(unsafe.Pointer(hint))
        }

从之後的两个主要区块看来,只有在 sysReserve 回传的保留位址同提示位址之时,才能够算是成功。成功之後,这个提示变数(hint)本身会被更新,供下一次需要在 heap 上配置记忆体之时再使用吧。

但我们实作的 sysReserve 无论如何不会满足这个需求的。所以就会进到最後的两行,每次 arenaHints 都会被更新成下一组提示,再重新开始。这也就是为什麽我们会看到一整排的保留尝试。

而且最後一笔的保留尝试就是,当所有的提示都失败之後,难道 Golang 执行期就放弃取得 heap 记忆体了吗?当然不能。所以稍後有一段接受现实的程序,保留作业系统回传的位址,并且依据该位址去重新生成提示。

                // All of the hints failed, so we'll take any
                // (sufficiently aligned) address the kernel will give
                // us.
                v, size = sysReserveAligned(nil, n, heapArenaBytes)
                if v == nil {
                        return nil, 0
                }

所以这里我们必须想办法解决这件事情才行。从 areanaHint 初始化的地方下手,回顾程序的呼叫轨迹(call trace)的话可以发现,那其实也就是稍早时经过的 mallocinit()

        // Create initial arena growth hints.
        if goarch.PtrSize == 8 {
                // On a 64-bit machine, we pick the following hints
                // because:
                //
                // 1. Starting from the middle of the address space
                // makes it easier to grow out a contiguous range
                // without running in to some other mapping.
                //
                // 2. This makes Go heap addresses more easily
                // recognizable when debugging.
                //
                // 3. Stack scanning in gccgo is still conservative,
                // so it's important that addresses be distinguishable
                // from other data.

我们可以找到这段设置 arenaHints 之前的注解。Golang 设计者也不是随意胡乱选择这些提示的位址,而是有三个考量:首先,在位址空间的中间开始的话,应该可以更容易长出一个连续的空间;再者,若是作业系统都能尽量配合这样的设计,这些提示能够看起来有区隔性,出问题的时候也比较容易除错;最後与 gccgo 的堆叠扫描有关,看起来也是区隔性的问题。

後面的注解段落也解释了偏好 00c000c1 这样的模式的原因,这里笔者就跳过了。总之我们都要调整了。

                for i := 0x7f; i >= 0; i-- {
                        var p uintptr
                        switch {
...
                        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 GOOS == "opensbi":
+                               p = uintptr(i)<<26 | uintptr(0xffffffcf00000000)
                        default:
                                p = uintptr(i)<<40 | uintptrMask&(0x00c0<<32)
                        }

原本就是都走到预设的最後一个状况,所以第二段这里的连续保留尝试才会是 c000...1c000... 的模式。这里我们就比照 AIX 作业系统,也创一个适合我们现在状况的。

这个魔术数字 0xffffffcf00000000,是先射箭再画靶。後面第一次需要用到记忆体位址提示的时候,需要配置的页面大小是 64MB,所以使用 cf 部分。进一步使用 d0 的话也会提前错误,抱怨超过定址空间;退一步使用 ce 部分的话,则也没有解决 64MB 区块的配置结果无法匹配到所得结果的问题。这笔债也是先欠着了。

今天上传的修补已经附上了这个调整,所以不会看到这个 i 回圈从 0x7f 到归零都无法满足的状况。

第三段之前,调整重跑

综合这个调整与先前的 headAddrBits 的调整,我们继续检视接下来的记忆体使用吧:

...
Memory Base:2147483648                          
Memory Size:536870912                           
Alloc: 0x40000 bytes 
Reserve: 0x40000 bytes, at 0x0 but at 0xffffffc700000000
Map: 0x40000 bytes, at 0xffffffc700000000
Reserve: 0x1000 bytes, at 0x0 but at 0xffffffc100000000
Reserve: 0x1000 bytes, at 0x0 but at 0xffffffc100001000 
Reserve: 0x1000 bytes, at 0x0 but at 0xffffffc100002000
Reserve: 0x4000 bytes, at 0x0 but at 0xffffffc300000000
Reserve: 0x20000 bytes, at 0x0 but at 0xffffffc600000000
Reserve: 0x4000000 bytes, at 0xffffffcf04000000 but at 0xffffffcf00000000

第一段加第二段的内容变成以上。可以看到原本的 5 层 radix tree 所需要的结构,已经减少非常多了。至於
最後一行的部份,根据提示去保留记忆体区段的动作,也可以尝试一次就成功。

第三段

Alloc: 0x210c10 bytes 
Reserve: 0x210c10 bytes, at 0x0 but at 0xffffffcb00000000
Map: 0x210c10 bytes, at 0xffffffcb00000000
Map: 0x400000 bytes, at 0xffffffcf00000000
Map: 0x1000 bytes, at 0xffffffc100000000
Map: 0x1000 bytes, at 0xffffffc100001000
Map: 0x1000 bytes, at 0xffffffc100002000
Map: 0x1000 bytes, at 0xffffffc60001e000

最後的 4 行追溯回去可以发现它们分别对应到 radix tree 资料结构所需要的前 4 层,都是已经保留的状态,而在这里进行真正的映射。倒数第 5 行的映射位址则对照到,根据前述的提示所保留的 heap 部分。所以,在我们遭遇错误之前的所有 Golang 执行期记忆体活动,就只剩下前 3 行的最後一组,是透过 sysAlloc 进行配置的。

这组配置的需求,来自以下片段的 persistentalloc 函数一路呼叫到 sysAlloc

                var r *heapArena
                r = (*heapArena)(h.heapArenaAlloc.alloc(unsafe.Sizeof(*r), goarch.PtrSize, &memstats.gcMiscSys))
                if r == nil {
                        r = (*heapArena)(persistentalloc(unsafe.Sizeof(*r), goarch.PtrSize, &memstats.gcMiscSys))
                        if r == nil {
                                throw("out of memory allocating heap arena metadata")
                        }
                }

这个 heapArena 资料体的大小,就是这里看到的 0x210c10 这个数量了。这正是配置竞技场的片段。

第四段

Alloc: 0x100 bytes
Reserve: 0x100 bytes, at 0x0 but at 0xffffffc100003000
Map: 0x100 bytes, at 0xffffffc100003000
Alloc: 0x10000 bytes 
Reserve: 0x10000 bytes, at 0x0 but at 0xffffffc500000000
Map: 0x10000 bytes, at 0xffffffc500000000
Alloc: 0x10000 bytes 
Reserve: 0x10000 bytes, at 0x0 but at 0xffffffc500010000
Map: 0x10000 bytes, at 0xffffffc500010000
Map: 0x800000 bytes, at 0xffffffcf00400000
Alloc: 0x100 bytes 
Reserve: 0x100 bytes, at 0x0 but at 0xffffffc100004000
Map: 0x100 bytes, at 0xffffffc100004000
I000000000000000d
0000000082200000
ffffffc00003dfa0

除了一个独立的 sysMap 呼叫取得 8MB 的区块之外,这个部分的重点是 sysAlloc 的呼叫。两个区块大小仅有 256 位元组的配置,前者来自 mpreinit 函数内使用 malg 配置 m0 内讯号处理用的特殊共常式 gsignal 时,配置新物件的需求;後者则是已经渡过大多记忆体初始化阶段来到 goargs 函数时,因为需要 Golang 的动态阵列(切片,slice)而产生的需求。两者都共用 mallocgc 函数作为入口,走访 Golang 自己的记忆体管理层级之後,抵达 sysAlloc 且只进行 256 位元组的配置。

但我们现在的实作来讲,因为以 4KB 为单位,所以这里会有很严重的碎片式浪费。从这里的回溯讯息可以看到,这两笔配置都还是分别占用了一个 4KB 页面。

中间的其余的两笔,也都来自配置 m0.gsignal 时产生的需求,与第一笔 256 位元组的配置相近。最後的话,就是阵亡在 goargs 里面的状态了。

笔者打捞这些配置的来源的方法是,这个部分的记忆体都很固定,因此只要针对记忆体位址进行判断,以决定要不要执行另外插入的 throw 呼叫,即可看到执行时的呼叫回溯。

小结

予焦啦!我们补足了一些 Golang 记忆体的架构相依常数之後,终於可以执行到一个新的地步:schedinitmcommoninit 等记忆体初始化之後的,goargs 函数发生了读取页面错误。至於为什麽?明日便知分晓!

至此,我们完成了现阶段的虚拟记忆体启用的目标,第二章也就告一段落了。明天开始,笔者打算延续 goargs 的错误,小幅度地前进,就像跑者的起跑冲刺总是比较急促,需要再调整节奏一样。各位读者,我们明日再会!


<<:  [Day24] CH11:刘姥姥逛物件导向的世界——抽象、介面

>>:  Day10 跟着官方文件学习Laravel-Migration

【Day07】Git 版本控制 - Sourcetree

什麽是 Sourcetree? 简单来说,就是一个可以用 GUI 介面来管理版本控制内容的软件。 可...

中国银行长城跨境通VISA/万事达国际借记卡申请

中国银行长城跨境通卡,产品全称中国银行长城跨境通国际借记卡,可能是目前唯一能够自由办理的国际借记卡。...

[D11] placeholder for d11

写在前面 placeholder for d11 placeholder for d11 place...

MLOps在金融产业:系统再现

再现性是指在这个模型产生出来以後,由不同的开发者或利益相关者,重新创建相同 ML 模型的能力。这其中...

Azure AutoML02及结语

AutoML得到的结果,说明如下。见图<AZ-exp4MNIST.png> 当看到 [S...