TL;DR: 这不是最佳实践,除非你真的有内存问题,否则请不要使用它。
随着新开服务器数量越来越多,老服的日活越来越少。一般会选择在老服的ECS上部署新服进程, 以便可以充分利用CPU。
这就有一个不得不去面对的问题, 随着服务器进程的增加,XML配置表所占用的内存会线性增加。
如果XML配置占用内存为500MByte, 那么10个进程就占用5GByte内存, 这是一笔很可观的内存开销。
由于XML表都是只读的,因此我一直在找一种方法能让各进程共享同一份内存。
然而由于Go的CSP并发模型, 他用一层厚厚的抽象屏蔽了进程间共享内存的可能。
我们当然可以用goroutine来代替进程以便可以共享这部分XML所带来的内存, 随之而来的是隔离性的丧失, 还有大规模的重构。
这两年每每需要优化XML时, 我都会再次思考一下跨进程共享内存的可能性, 一直没有找到办法。
直到最近,我翻看最新Go的unsafe包时,发现了不知道从哪个版本开始已经增加了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
我从这两组函数中看到了契机。
我们先来回顾一下string和slice的定义:
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 |
从设计上看,这个布局其实可以分为几块:
lengthplain_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中不包含string和slice等变长字段时,我们甚至可以做到0额外内存消耗。
PS. 这样做的同时,你将会失去配置文件的可读性。
PPS. 在加载配置文件时,加载失败往往比解析成错误的数据更好。因此最好在bin的头部,将字段名和偏移量写入,以便在Unmarshal时进行校验,以确保不会因为编译器或字段的改变,而默默的解析成错误数据。