惯性聚合 高效追踪和阅读你感兴趣的博客、新闻、科技资讯
阅读原文 在惯性聚合中打开

推荐订阅源

C
CERT Recently Published Vulnerability Notes
U
Unit 42
T
The Blog of Author Tim Ferriss
H
Hackread – Cybersecurity News, Data Breaches, AI and More
B
Blog RSS Feed
Microsoft Azure Blog
Microsoft Azure Blog
Threat Intelligence Blog | Flashpoint
Threat Intelligence Blog | Flashpoint
S
Securelist
L
Lohrmann on Cybersecurity
Blog — PlanetScale
Blog — PlanetScale
Recorded Future
Recorded Future
D
DataBreaches.Net
Spread Privacy
Spread Privacy
T
Threat Research - Cisco Blogs
I
Intezer
P
Palo Alto Networks Blog
Simon Willison's Weblog
Simon Willison's Weblog
cs.CL updates on arXiv.org
cs.CL updates on arXiv.org
I
InfoQ
宝玉的分享
宝玉的分享
Security Latest
Security Latest
Cyber Security Advisories - MS-ISAC
Cyber Security Advisories - MS-ISAC
T
Threatpost
Cisco Talos Blog
Cisco Talos Blog
P
Proofpoint News Feed
博客园 - 司徒正美
H
Hacker News: Front Page
Y
Y Combinator Blog
爱范儿
爱范儿
让小产品的独立变现更简单 - ezindie.com
让小产品的独立变现更简单 - ezindie.com
cs.AI updates on arXiv.org
cs.AI updates on arXiv.org
NISL@THU
NISL@THU
月光博客
月光博客
有赞技术团队
有赞技术团队
Cloudbric
Cloudbric
酷 壳 – CoolShell
酷 壳 – CoolShell
G
Google Developers Blog
A
Arctic Wolf
博客园 - 【当耐特】
W
WeLiveSecurity
V
Visual Studio Blog
Exploit-DB.com RSS Feed
Exploit-DB.com RSS Feed
OSCHINA 社区最新新闻
OSCHINA 社区最新新闻
cs.CV updates on arXiv.org
cs.CV updates on arXiv.org
V
V2EX
C
Cyber Attacks, Cyber Crime and Cyber Security
S
SegmentFault 最新的问题
The GitHub Blog
The GitHub Blog
The Cloudflare Blog
Stack Overflow Blog
Stack Overflow Blog

重归混沌的BLOG

给silly实现了一个ernro模块 | 重归混沌的BLOG 给silly实现了一个ernro模块 | 重归混沌的BLOG 第一次在生产环境使用 Vibe Coding | 重归混沌的BLOG 第一次在生产环境使用 Vibe Coding | 重归混沌的BLOG API 设计的艰难抉择 | 重归混沌的BLOG API 设计的艰难抉择 | 重归混沌的BLOG 十年 | 重归混沌的BLOG 十年 | 重归混沌的BLOG 在Go语言中如何使XML加载内存无限趋近于0 | 重归混沌的BLOG 对跨服玩法中的分布式一致性问题进行简单抽象 | 重归混沌的BLOG 对跨服玩法中的分布式一致性问题进行简单抽象 | 重归混沌的BLOG Go语言逃逸分析之slice和map | 重归混沌的BLOG Go语言逃逸分析之slice和map | 重归混沌的BLOG 谈谈观测 | 重归混沌的BLOG 谈谈观测 | 重归混沌的BLOG 写了个AI Agent服务端 | 重归混沌的BLOG 写了个AI Agent服务端 | 重归混沌的BLOG 谈谈代码设计中“严丝合缝” | 重归混沌的BLOG 谈谈代码设计中“严丝合缝” | 重归混沌的BLOG 一次艰难的线上游戏服务器内存排查经历 | 重归混沌的BLOG 一次艰难的线上游戏服务器内存排查经历 如何基于LanguageServerProtocol来编写lint工具 谈谈游戏服务器中RPC模块的设计 谈谈游戏服务器代码抽象 最近碰到的一个分布式一致性问题 谈谈游戏服务器的自动化测试 对Raft协议的一点理解 使用mmap来学习/proc/pid/smaps 2023(完) 再次实现了一个Lua性能分析器 终于给Silly的定时器增加了取消功能 一次虚拟内存排查经历 游戏服务器分布式数据的一种同步的思路 为silly增加了互斥锁 2022(完) Go语言之闭包篇 一例误用unsafe包引起的内存问题 Go语言之内存篇 初识Go语言 重新抽象图形API 给Lua实现了一个数学库 谈谈跨平台图形API的抽象 寻路和Flocking算法的结合 行为树的一种高效实现 内测过程中Shader出现的问题 彻底解决多国语言 谈谈数据库的选型 再谈Lua热更新(终) 初窥Rust 关于游戏服务器的服务拆分 ECS的初步实现 ECS初探 屏幕空间(SreenSpace)的想象力 一些对辐射度量学的理解 深度缓冲和半透明渲染 Mysql的间隙锁 更新一些GPU相关知识 2020 地形渲染之爬过的坑 Lua5.3 GC源码阅读(5) 实现一个数据库存储队列 再学计算机图形学入门 再谈分布式服务架构 游戏上线一个月后的反思 一次并发Bug 双向链表的三种实现 谈谈随机数的使用 再谈性能优化 2019 Lua中的函数式编程 重构登录逻辑 Unity资源管理(续) 谈谈Unity的资源管理 一次关于Cache的性能分析 历史之2018 DC3算法 移动平台native代码遭遇的坑 从CPU层面谈谈优化 开卷有益(UNIX编程艺术篇) GC竞争问题 通过Mesh投影来实现贴花系统 谈谈我对数据同步的理解 又一个类型提升引起的Bug Lua5.3 GC源码阅读(4) Lua5.3 GC源码阅读(3) Lua5.3 GC源码阅读(2) Lua5.3 GC源码阅读(1) 三角形光栅化时遇到的坑 一次git事故 再见2017 又一个lua调试器 客户端缓存落地方案 Paxos算法 HTTP服务器的特点 一次性能优化经历 关于CPU分支预测 C程序中让两个不同版本的库共存 实现了一个AOI模块 一个高可伸缩的游戏服务器架构 关于网络协议封装的一些新想法
在Go语言中如何使XML加载内存无限趋近于0 | 重归混沌的BLOG
重归混沌 · 2025-07-13 · via 重归混沌的BLOG

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时进行校验,以确保不会因为编译器或字段的改变,而默默的解析成错误数据。