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 |
从设计上看,这个布局其实可以分为几块:
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
中不包含string
和slice
等变长字段时,我们甚至可以做到0额外内存消耗。
PS. 这样做的同时,你将会失去配置文件的可读性。
PPS. 在加载配置文件时,加载失败往往比解析成错误的数据更好。因此最好在bin的头部,将字段名和偏移量写入,以便在Unmarshal
时进行校验,以确保不会因为编译器或字段的改变,而默默的解析成错误数据。