在Go语言中如何使XML加载内存无限趋近于0

TL;DR: 这不是最佳实践,除非你真的有内存问题,否则请不要使用它。

随着新开服务器数量越来越多,老服的日活越来越少。一般会选择在老服的ECS上部署新服进程, 以便可以充分利用CPU。

这就有一个不得不去面对的问题, 随着服务器进程的增加,XML配置表所占用的内存会线性增加。

如果XML配置占用内存为500MByte, 那么10个进程就占用5GByte内存, 这是一笔很可观的内存开销。

由于XML表都是只读的,因此我一直在找一种方法能让各进程共享同一份内存。

然而由于Go的CSP并发模型, 他用一层厚厚的抽象屏蔽了进程间共享内存的可能。

我们当然可以用goroutine来代替进程以便可以共享这部分XML所带来的内存, 随之而来的是隔离性的丧失, 还有大规模的重构。

这两年每每需要优化XML时, 我都会再次思考一下跨进程共享内存的可能性, 一直没有找到办法。

直到最近,我翻看最新Gounsafe包时,发现了不知道从哪个版本开始已经增加了4个函数:

func String(ptr *byte, len IntegerType) string
func StringData(str string) *byte
func Slice(ptr *ArbitraryType, len IntegerType) []ArbitraryType
func SliceData(slice []ArbitraryType) *ArbitraryType

我从这两组函数中看到了契机。


我们先来回顾一下stringslice的定义:

type stringStruct struct {
        str unsafe.Pointer
        len int
}
type slice struct {
        array unsafe.Pointer
        len   int
        cap   int
}

将XML中字段映射到内存中的slice[T]时, T一定都是简单(Plain)结构, 即T的成员全部都是值类型, 不可能为引用类型。

我们可以得出一个结论:在这种情况下,slice[T]string 拥有相同的内存布局,均由一个 header 和一个连续的内存块(memory chunk)构成。

这样将一行XML数据的全部内存数据映射到一个 bin 文件中就有了可能。

到目前为止,我们还没有没有改善任何内存问题, 所以还需要我们的主角 mmap 登场。

根据 MMU 的基础知识,当我们使用 mmap 将文件映射到进程地址空间时,内核会通过建立页表,将 Page Cache 中的数据页直接映射到进程的虚拟内存中。

当多个进程同时使用 mmap 将通过只读的方式将同一个 bin 文件映射到自己的地址空间时,他们共享的是同一份数据页内存。

这样我们在Unmarshal bin 文件时, 惟一的内存开销就是在堆上分配string/slice header所占用的内存, 当XML行中不包含变长数据时,他的额外内存开销甚至可以做到为0。


下面来展示一下如何做到这一切。

先来看一个未经处理的原始结构体:


type Pair struct {
    Key uint32
    Value uint32
}

type FooRow struct {
    ID uint32   `json:"id"`
    Group uint16    `json:"group"`
    Name strin  `json:"name"`
    Pairs []Pair
}

经过处理之后,结构体被拆分成如下形式:


type Pair struct {
    Key uint32
    Value uint32
}

type FooRowPlain struct {
    ID uint32 `json:"id"`
    Group uint16    `json:"group"`
}

type FooRow struct {
    *FooRowPlain
    Name string `json:"name"`
    Pairs []Pair
}

假设我们有这样一行 XML 数据:


row = &FooRow {
    FooRowPlain: {
        ID: 1,
        Group: 2,
    },
    Name: "foobar",
    Pairs: []Pair {
        {Key: 1, Value: 2},
        {Key: 3, Value: 4},
    },
}

来看一下将结构体 FooRow 序列化为 bin 文件之后的内存布局:

所有字段均为小端字节序(Little Endian),低字节在前(LSB at lowest address)

8 Bytes 4 Bytes 2 Bytes 2 Bytes 4 Bytes 4 Bytes 6 Bytes 2 Bytes 16 Bytes
length id group PADDING1 len(Name) len(Pairs) Name PADDING2 Pairs
48 0x01 0x02 0x00 0x06 0x05 ‘foobar’ 0x0000 0x00000001, 0x00000002, 0x00000003, 0x00000004

从设计上看,这个布局其实可以分为几块:

  • length
  • plain_chunk(FooRowPlain + PADDING)
  • length_chunk(len(Name), len(Pairs) + PADDING)
  • mem_chunk(Name + PADDING)
  • mem_chunk(Pairs + PADDING)

其中:

  • FooRowPlain 内的字段对齐由编译器自动处理,例如 PADDING1

  • FooRowPlain 中的字段有可能有 uint64,需要确保起始地址是 8 字节对齐。

  • length_chunk 本质是上一个 uint32 的数组,需要确保起始地址是 4 字节对齐。

  • mem_chunk 可能是 slice[T],而 T 中可能有 uint64 的字段,同样需要确保起始地址是 8 字节对齐。

乍一看很复杂,其实我们可以统一成一条规则:

确保 plain_chunk, length_chunk, mem_chunk 占用的内存大小均是 8 的整数倍即可。

有没有一种在写内存池的既视感 😀

下面简单展示一下Unmarshal中如何复用数据页来减少内存开销的。


func Unmarshal(buf *[]byte) *FooRow {
    length := binary.LittleEndian.Uint64(buf[0:8])
    chunk := buf[8:length]
    *buf = buf[length+8:]

    plain := (*FooRowPlain)(unsafe.Pointer(&chunk[0]))
    row := &FooRow{
        FooRowPlain: plain,
    }

    lengthOffset := (unsafe.Sizeof(FooRowPlain{}) + 7) / 8 * 8

    var nameSize, pairsSize uint32
    nameSize = binary.LittleEndian.Uint32(chunk[lengthOffset:lengthOffset+4])
    pairsSize = binary.LittleEndian.Uint32(chunk[lengthOffset+4:lengthOffset+8])

    // name
    chunkOffset := lengthOffset + (2*4+7)/8*8
    namePtr := (*byte)(unsafe.Pointer(&chunk[chunkOffset]))
    row.Name = unsafe.String(namePtr, int(nameSize))

    // pairs
    chunkOffset += (nameSize+7)/8*8
    count := pairsSize / unsafe.Sizeof(Pair{})
    pairsPtr := (*Pair)(unsafe.Pointer(unsafe.SliceData(chunk[chunkOffset:])))
    row.Pairs = unsafe.Slice(pairsPtr, count)

    return row
}

从上面代码可以清晰的看到,我们唯一的内存分配地方,就是在&FooRow{}时从堆内存分配了 50 字节。

如果FooRow中不包含stringslice等变长字段时,我们甚至可以做到0额外内存消耗。

PS. 这样做的同时,你将会失去配置文件的可读性。

PPS. 在加载配置文件时,加载失败往往比解析成错误的数据更好。因此最好在bin的头部,将字段名和偏移量写入,以便在Unmarshal时进行校验,以确保不会因为编译器或字段的改变,而默默的解析成错误数据。

发表评论

thirty + = thirty two