03 · 内存的本质:虚拟地址、页表与局部性
“每个进程都以为自己独占 128TB 内存。它们都被骗了——而这个骗局,是现代计算的地基。“
开场:一个”假”地址
老陈:写段代码给我看看。
小林:(写)
package main import "fmt" func main() { a := make([]int, 4) fmt.Printf("%p\n", &a[0]) }输出
0xc0000120a0。老陈:这个
0xc0000120a0是什么?小林:
a[0]的地址啊。老陈:物理地址还是虚拟地址?
小林:虚拟地址。
老陈:那它对应的物理地址是什么?
小林:……这没法知道吧,OS 又不告诉我们。
老陈:对,OS 确实不告诉你。但我想问的是——这个地址,是真的吗?
小林:什么意思?
老陈:我现在让你写两个程序,同时运行,都打印自己的某个变量地址。如果两个程序打印出了完全相同的地址,这意味着什么?
小林:意味着……它们指向同一块物理内存?那不就互相踩了吗?
老陈:(把手边的杯子往桌上一放)那为什么实际上没有互相踩?
小林:……因为它们打印的是虚拟地址,两个进程的虚拟地址空间是独立的。
老陈:“独立”是怎么做到的?谁在中间做了转换?转换的表放在哪?查表要几次内存访问?如果查表本身要 4 次内存访问,那每次读写变量都要 5 次访存,程序岂不是要慢 5 倍?
小林:…………我不知道。
正解拆解:一场精心设计的骗局
1. 先看证据
写两个一样的程序同时跑:
// vaddr.go
package main
import (
"fmt"
"os"
"time"
)
var global [1024]byte
func main() {
fmt.Fprintf(os.Stdout, "pid=%d &global=%p &local=%p\n",
os.Getpid(), &global[0], &time.Now)
time.Sleep(30 * time.Second)
}$ go build -o vaddr vaddr.go
$ ./vaddr & ./vaddr & ./vaddr &
[1] pid=12041 &global=0x57ea00 &local=0xc000010028
[2] pid=12042 &global=0x57ea00 &local=0xc000010028
[3] pid=12043 &global=0x57ea00 &local=0xc000010028三个进程,完全相同的地址。
它们显然不是在共享同一块内存(你可以试着让一个进程往 global[0] 写 0xFF,另一个进程读出来还是 0x00)。
结论:这 3 个进程看到的 0x57ea00 是三个不同的物理地址。
这就是虚拟内存。
2. 骗局的设计目标
虚拟内存要在不告诉应用程序的前提下,达成四个目标:
| 目标 | 说明 | 没有它会怎样 |
|---|---|---|
| ① 隔离 | 进程 A 无法访问进程 B 的内存 | 一个程序崩溃拖垮整个系统(DOS 时代就是这样) |
| ② 简化 | 每个程序都看到”从 0 开始的连续大内存” | 程序员要自己处理内存碎片和重定位 |
| ③ 超额分配 | 实际用的内存可以比分配的少 | 装 8GB 内存跑 20 个各分配 2GB 的程序 |
| ④ 权限控制 | 代码段只读、数据段可写、栈不可执行 | 缓冲区溢出攻击轻易得手 |
老陈:注意这四个目标里,只有 ③ 是”省资源”,其余三个全是”安全和简化”。虚拟内存最初被发明出来,主要动机不是让内存变大,而是为了让多程序共存不出事。
3. 骗局的执行者:MMU 和页表
核心机制:CPU 里有一个硬件单元叫 MMU(Memory Management Unit,内存管理单元)。
CPU 核心
│
│ 发出虚拟地址 0x57ea00
▼
┌─────────────┐
│ MMU │ ← 硬件,在 CPU 芯片里
│ (查页表) │
└──────┬──────┘
│ 输出物理地址 0x1a4e2ea00
▼
内存总线 → DRAM
MMU 靠什么查? 靠一张表,叫 页表(Page Table)。
页表长什么样?
首先,内存被切成固定大小的块,叫页(Page),x86-64 上通常是 4KB。
虚拟地址空间也被切成 4KB 的块。页表的作用,就是记录:
“虚拟页 N” ↔ “物理页 M”
4. 如果页表是”一张大数组”
最朴素的设计:一个数组,第 N 项存着虚拟页 N 对应的物理页号。
虚拟页号 → 物理页号
0 → 8291
1 → 412
2 → (未分配)
3 → 10233
...
每一项 8 字节。48 位虚拟地址空间 = 2^48 字节 / 4KB = 2^36 个虚拟页。
2^36 × 8 字节 = 512 GB。
光是页表就要 512GB,比物理内存还大。显然不行。
5. 多级页表:真正的做法
x86-64 用 4 级页表(5 级是较新的扩展):
虚拟地址(48 位有效)
┌────────┬────────┬────────┬────────┬──────────────┐
│ PGD │ PUD │ PMD │ PTE │ Offset │
│ 9 bit │ 9 bit │ 9 bit │ 9 bit │ 12 bit │
└────────┴────────┴────────┴────────┴──────────────┘
│ │ │ │ │
│ │ │ │ └─ 页内偏移(4KB = 2^12)
│ │ │ └─ Page Table Entry 索引
│ │ └─ Page Middle Directory 索引
│ └─ Page Upper Directory 索引
└─ Page Global Directory 索引
每一级都是 512 项 × 8 字节 = 4KB(正好一页)。
查找过程:
CR3 寄存器 ──► PGD (4KB)
│ [PGD index]
▼
PUD (4KB)
│ [PUD index]
▼
PMD (4KB)
│ [PMD index]
▼
PTE (4KB)
│ [PTE index]
▼
物理页框号 + 页内偏移 = 物理地址
关键洞察:多级页表为什么会省空间?
因为只有实际用到的那部分地址空间,才需要分配下级页表。
一个典型的 Go 程序:
- 代码段:2MB
- 数据段:1MB
- 堆:100MB
- 栈:几十个 goroutine × 8KB
总共可能只用了 200MB 的地址空间,分散在几块连续区域。
需要的页表数量:
- 1 个 PGD(4KB,必须)
- 几个 PUD(每 512GB 地址空间一个)
- 几个 PMD(每 1GB 一个)
- 几百个 PTE(每 2MB 一个)
总共不到 1MB。 而不是 512GB。
老陈:多级页表的本质,是把”稠密数组”变成”稀疏树”。代价是查找从 1 次访存变成 4 次。这是一个典型的”用时间换空间”——只不过后面我们会看到,这个时间代价被另一个硬件巧妙地抹掉了。
6. TLB:让 4 次访存变成 0.1 次
如果每次读写变量都要 4 次额外的内存访问来查页表,程序会慢 5 倍。
解决方案:TLB(Translation Lookaside Buffer,快表)。
TLB 是 MMU 里的一块高速缓存,缓存”虚拟页号 → 物理页框号”的映射。
虚拟页号
│
┌─────┴─────┐
│ TLB │ ← 命中:1 个时钟周期(约 0.3ns)
│ 64~1536 │
│ 项 │
└─────┬─────┘
│ 未命中
▼
遍历 4 级页表(4 次访存,约 400ns)
│
▼
把结果填入 TLB
TLB 的典型参数(Intel Skylake 级别):
| 层级 | 条目数 | 4KB 页覆盖 | 2MB 大页覆盖 |
|---|---|---|---|
| L1 TLB (iTLB) | 64 | 256 KB | — |
| L1 TLB (dTLB) | 64 | 256 KB | — |
| L2 TLB (STLB) | 1536 | 6 MB | 3 GB |
这里有个巨大的数字:6 MB vs 3 GB。
如果用 4KB 页,二级 TLB 只能覆盖 6MB 的地址空间。你的 Go 服务有 40GB 的堆,TLB 覆盖率是 0.015%——几乎每次访问都要走 4 级页表遍历。
如果改用 2MB 大页,覆盖率变成 3GB,提升 512 倍。
这就是为什么数据库(MySQL、PostgreSQL、Oracle)、JVM、DPDK 这些对内存性能敏感的系統,都会推荐使用 HugePage。
7. 页表项里藏着的权限
页表项不只有物理地址,还有一堆标志位:
PTE (64 bit)
┌──┬───┬───┬───┬───┬───┬───┬───┬──────────────┬──────┐
│NX│ 保 │ 物│ 理│ 页│ 框│ 号│ │ 标志位 │ │
│ │ 留 │ 52 bit │ │ │ │ │ │
└──┴───┴─────────────┴───┴───┴───┴───┴──────────────┴──────┘
标志位:
P (Present) 页是否在内存中(0 = 缺页异常)
RW (Read/Write) 0=只读, 1=可写
US (User/Supervisor) 0=仅内核, 1=用户可访问
PWT/PCD/PAT 缓存策略控制
A (Accessed) 是否被访问过(页面置换算法用)
D (Dirty) 是否被写过(换出时是否需要写回磁盘)
NX (No Execute) 禁止执行(防缓冲区溢出攻击)
这些标志位是虚拟内存目标 ④(权限控制)的实现。
举几个实际效果:
- 代码段标记 RW=0 → 写入代码段触发 SIGSEGV。这就是为什么
const数据被修改时程序会崩。 - 代码段标记 NX=0,数据段和栈标记 NX=1 → 栈上注入的 shellcode 无法执行。这是 DEP/NX 保护,现代操作系统标配。
- 用户态代码访问 US=0 的页 → 触发段错误。这就是内核内存的保护。
8. 缺页异常的三种类型
当 PTE 的 P 位为 0,或者权限不足时,CPU 触发缺页异常(Page Fault pf),陷入内核。
内核的缺页处理程序要区分三种情况:
| 类型 | 触发条件 | 内核的处理 |
|---|---|---|
| Minor Fault(次缺页) | 页在物理内存中,但页表没建立映射 | 建立映射,不读磁盘。开销约 1~5 μs |
| Major Fault(主缺页) | 页不在内存中,需要从磁盘/swap 读入 | 读磁盘。开销约 1~10 ms(慢 1000 倍!) |
| Invalid Fault(无效访问) | 访问了完全未分配的地址,或权限不足 | 发 SIGSEGV 信号,进程崩溃 |
第一种最常见的情况:按需分配
a := make([]byte, 1<<30) // 申请 1GB这行代码执行的瞬间,内核只做了:在进程的虚拟地址空间里划出 1GB 的范围,标记为”已分配但未映射”。一个物理页都没分配。
a[0] = 1 // 触发 minor fault:内核分配第 1 个物理页
a[1<<20] = 1 // 触发 minor fault:内核分配第 257 个物理页所以 make([]byte, 1<<30) 是 O(1) 的,不是 O(1GB)。
老陈:这就是为什么 Go 的堆可以”先申请后使用”。Go runtime 启动时
mmap了很大的地址空间(heap arena),但 RSS(实际驻留内存)增长是渐进的——因为你真正 touch 多少,内核才给你多少。这也解释了监控上一个经典困惑:
top里看到 Go 进程 VIRT 是 40GB,RSS 只有 3GB。VIRT 是虚拟地址空间(画的大饼),RSS 是实际占用的物理内存(真金白银)。监控要盯 RSS,不是 VIRT。
动手实验:把上面这些全部测出来
实验 1:观察按需分配(VIRT vs RSS)
package main
import (
"fmt"
"os"
"os/signal"
"runtime"
"time"
)
func readRssKB() int {
// 从 /proc/self/status 读 VmRSS
data, err := os.ReadFile("/proc/self/status")
if err != nil {
return -1
}
var rss int
fmt.Sscanf(string(data), "Name:\t%*s\n%*s\n%*s\n%*s\n%*s\n%*s\n%*s\n%*s\n%*s\n%*s\n%*s\n%*s\n%*s\n%*s\n%*s\n%*s\n%*s\n%*s\n%*s\n%*s\nVmRSS:\t%d kB", &rss)
return rss
}
func readVirtKB() int {
data, _ := os.ReadFile("/proc/self/status")
var virt int
// 简化:直接找 VmSize
lines := splitLines(string(data))
for _, l := range lines {
var v int
if n, _ := fmt.Sscanf(l, "VmSize:\t%d kB", &v); n == 1 {
virt = v
}
if n, _ := fmt.Sscanf(l, "VmRSS:\t%d kB", &v); n == 1 {
rssGlobal = v
}
}
return virt
}
var rssGlobal int
func splitLines(s string) []string {
var res []string
cur := ""
for _, c := range s {
if c == '\n' {
res = append(res, cur)
cur = ""
} else {
cur += string(c)
}
}
return append(res, cur)
}
func report(stage string) {
var m runtime.MemStats
runtime.ReadMemStats(&m)
fmt.Printf("%-28s VIRT=%-8d KB RSS=%-8d KB HeapAlloc=%-8d KB Sys=%-8d KB\n",
stage,
readVirtKB(),
rssGlobal,
m.HeapAlloc/1024,
m.Sys/1024,
)
}
func main() {
// 让程序不要退出,方便我们从外部观察
go func() {
c := make(chan os.Signal, 1)
signal.Notify(c, os.Interrupt)
<-c
os.Exit(0)
}()
report("① 程序刚启动")
// 申请 1GB,但不碰它
buf := make([]byte, 1<<30)
report("② make 1GB(未 touch)")
time.Sleep(500 * time.Millisecond)
// 只 touch 每 4KB 页的第一个字节 —— 这会让所有页都被映射
for i := 0; i < len(buf); i += 4096 {
buf[i] = 1
}
report("③ 逐页 touch 完 1GB")
time.Sleep(500 * time.Millisecond)
// 再申请 1GB 不碰
_ = make([]byte, 1<<30)
runtime.GC()
report("④ 又申请 1GB(未 touch)")
fmt.Println("\n保持运行 30 秒,可以用 top -p <pid> 观察")
time.Sleep(30 * time.Second)
}典型输出:
① 程序刚启动 VIRT=440000 KB RSS=632 KB HeapAlloc=120 KB Sys=7400 KB
② make 1GB(未 touch) VIRT=440000 KB RSS=672 KB HeapAlloc=1048576 KB Sys=1049000 KB
③ 逐页 touch 完 1GB VIRT=440000 KB RSS=1049200 KB HeapAlloc=1048700 KB Sys=1049000 KB
④ 又申请 1GB(未 touch) VIRT=440000 KB RSS=1049300 KB HeapAlloc=1048700 KB Sys=2097000 KB
重点看第 ② 行:HeapAlloc 显示已经分配了 1GB,但 RSS 只有 672 KB!
这就是按需分页的实证。
实验 2:False Sharing —— 缓存行的诅咒
这是最能体现”缓存行”存在的实验。
package main
import (
"fmt"
"runtime"
"sync"
"testing"
"time"
)
// 场景:多个 goroutine 各自累加自己的计数器
// 版本 A:计数器紧密排列(共享缓存行)
// 版本 B:计数器按 64 字节对齐(独占缓存行)
const (
numGoroutines = 8
iterations = 10_000_000
)
// ---- 版本 A:紧凑排列,会有 false sharing ----
type CountersPacked struct {
counters [numGoroutines]int64
}
func benchmarkPacked(b *testing.B) {
var c CountersPacked
var wg sync.WaitGroup
for round := 0; round < b.N; round++ {
wg.Add(numGoroutines)
for i := 0; i < numGoroutines; i++ {
go func(idx int) {
defer wg.Done()
for j := 0; j < iterations/numGoroutines; j++ {
c.counters[idx]++ // ← 不同的 idx,但可能在同一缓存行
}
}(i)
}
wg.Wait()
}
}
// ---- 版本 B:64 字节对齐(padding),无 false sharing ----
type CountersPadded struct {
counters [numGoroutines]struct {
v int64
_ [56]byte // 补齐到 64 字节 = 一个缓存行
}
}
func benchmarkPadded(b *testing.B) {
var c CountersPadded
var wg sync.WaitGroup
for round := 0; round < b.N; round++ {
wg.Add(numGoroutines)
for i := 0; i < numGoroutines; i++ {
go func(idx int) {
defer wg.Done()
for j := 0; j < iterations/numGoroutines; j++ {
c.counters[idx].v++
}
}(i)
}
wg.Wait()
}
}
func main() {
fmt.Printf("CPU 核数: %d, GOMAXPROCS: %d\n", runtime.NumCPU(), runtime.GOMAXPROCS(0))
fmt.Printf("缓存行大小假设: 64 字节\n\n")
// 手动计时(不用 testing.B,方便直接运行)
run := func(name string, fn func()) time.Duration {
t0 := time.Now()
fn()
d := time.Since(t0)
fmt.Printf("%-30s %v\n", name, d)
return d
}
var packedTime, paddedTime time.Duration
packedTime = run("❌ 紧凑排列(false sharing)", func() {
var c CountersPacked
var wg sync.WaitGroup
wg.Add(numGoroutines)
for i := 0; i < numGoroutines; i++ {
go func(idx int) {
defer wg.Done()
for j := 0; j < iterations/numGoroutines; j++ {
c.counters[idx]++
}
}(i)
}
wg.Wait()
})
paddedTime = run("✅ 64 字节对齐(padding)", func() {
var c CountersPadded
var wg sync.WaitGroup
wg.Add(numGoroutines)
for i := 0; i < numGoroutines; i++ {
go func(idx int) {
defer wg.Done()
for j := 0; j < iterations/numGoroutines; j++ {
c.counters[idx].v++
}
}(i)
}
wg.Wait()
})
fmt.Printf("\n性能差距: %.2fx (padding 快 %.1f%%)\n",
float64(packedTime)/float64(paddedTime),
(1-float64(paddedTime)/float64(packedTime))*100)
}典型结果(8 核机器):
CPU 核数: 8, GOMAXPROCS: 8
缓存行大小假设: 64 字节
❌ 紧凑排列(false sharing) 1.847s
✅ 64 字节对齐(padding) 0.312s
性能差距: 5.92x (padding 快 83.1%)
接近 6 倍的差距。
为什么会这样?
CountersPacked 的内存布局:
counters[0] counters[1] counters[2] ... counters[7]
8B 8B 8B 8B
└────────── 全部 64 字节,在同一个缓存行 ──────────┘
CPU 核心 0 修改 counters[0]:
- 缓存一致性协议(MESI)把整个缓存行的所有权给核心 0
- 核心 0 的 L1 缓存里这一行变成 Modified 状态
- 其他所有核心的 L1 缓存里,这一行被标记为 Invalid
- 核心 1 想改
counters[1]→ 发现自己的副本失效 → 必须从核心 0 的缓存(或内存)重新读 - 核心 1 改完,核心 0 的副本又失效
- 循环往复,8 个核心在互相使对方的缓存失效
虽然 8 个 goroutine 改的是 8 个不同的变量,但因为它们在同一个缓存行,硬件层面等同于”8 个核心在抢同一个变量”。
这就是 False Sharing(伪共享)—— 数据本身没有共享,但缓存行共享了。
实验 3:大页(HugePage)的威力
# 1. 先看当前系统的大页配置
cat /proc/meminfo | grep -i huge
# HugePages_Total: 0 ← 默认没开
# Hugepagesize: 2048 kB ← 2MB
# 2. 开启 2048 个 2MB 大页(共 4GB)
sudo sysctl -w vm.nr_hugepages=2048
# 3. 挂载 hugetlbfs
sudo mkdir -p /mnt/huge
sudo mount -t hugetlbfs nodev /mnt/huge
# 4. 用大页跑一个内存密集程序
# (Go 里用 madvise 或者 mmap MAP_HUGETLB)用 Go 申请大页内存:
package main
import (
"fmt"
"os"
"syscall"
"time"
)
func main() {
const size = 256 << 20 // 256MB
// 方式 1:普通 mmap
normal, err := syscall.Mmap(-1, 0, size,
syscall.PROT_READ|syscall.PROT_WRITE,
syscall.MAP_PRIVATE|syscall.MAP_ANONYMOUS)
if err != nil {
panic(err)
}
// 方式 2:带 MADV_HUGEPAGE 提示(需要 THP 开启)
thp, err := syscall.Mmap(-1, 0, size,
syscall.PROT_READ|syscall.PROT_WRITE,
syscall.MAP_PRIVATE|syscall.MAP_ANONYMOUS)
if err != nil {
panic(err)
}
// 告诉内核:我这块内存想用大页
if err := syscall.Madvise(thp, syscall.MADV_HUGEPAGE); err != nil {
fmt.Println("madvise hugepage failed:", err)
}
// 随机访问测试
bench := func(name string, buf []byte) {
// 构造随机访问序列
n := len(buf) / 64
idx := make([]int, n)
for i := range idx {
idx[i] = i * 64
}
// Fisher-Yates 打乱
for i := n - 1; i > 0; i-- {
j := int(time.Now().UnixNano()) % (i + 1)
idx[i], idx[j] = idx[j], idx[i]
}
t0 := time.Now()
var sum byte
for round := 0; round < 20; round++ {
for _, off := range idx {
buf[off]++ // 读改写,必须真的访问
sum += buf[off]
}
}
d := time.Since(t0)
fmt.Printf("%-24s %v (sum=%d)\n", name, d, sum)
}
bench("普通 4KB 页", normal)
bench("MADV_HUGEPAGE (2MB)", thp)
// 查看大页使用情况
data, _ := os.ReadFile("/proc/meminfo")
fmt.Println("\n=== /proc/meminfo HugePage 相关 ===")
lines := splitLines(string(data))
for _, l := range lines {
if len(l) > 5 && (l[:5] == "HugeP" || l[:5] == "AnonH" || l[:5] == "Shmem") {
fmt.Println(" ", l)
}
}
}
func splitLines(s string) []string {
var res []string
cur := ""
for _, c := range s {
if c == '\n' {
res = append(res, cur)
cur = ""
} else {
cur += string(c)
}
}
return append(res, cur)
}典型结果(256MB 随机访问,20 轮):
普通 4KB 页 1.42s
MADV_HUGEPAGE (2MB) 0.87s
提升约 39%
原理:随机访问模式下,每次访问都大概率 TLB miss。大页让 TLB 覆盖率提升 512 倍,miss 率大幅下降。
老陈的补充:Go 1.21+ 在 Linux 上会自动对大于一定尺寸的堆分配使用
MADV_HUGEPAGE。 也就是说,Go runtime 已经在帮你做这件事了。但如果你在做数据库这类应用,显式管理大页仍然是值得的。
实验 4:页表遍历的可视化
如果你想”看见”页表,可以用 Go 写一个简化的页表模拟器:
package main
import "fmt"
// 这是一个教学用的 4 级页表模拟器
// 真实硬件的页表由内核维护,我们模拟它的查找过程
const (
pageSize = 4096
entrySize = 8
entries = 512 // 每级页表 512 项 = 4096 字节
)
type PTE struct {
Present bool
Writable bool
UserMode bool
NoExec bool
Dirty bool
Accessed bool
FrameNum uint64 // 物理页框号
}
type PageTableLevel struct {
entries [entries]*PTE
// 真实实现里这是一个 4KB 的物理页
}
// MMU 模拟
type MMU struct {
cr3 *PageTableLevel // PGD
tlb map[uint64]uint64
tlbHits int
tlbMiss int
memAccess int
}
func NewMMU() *MMU {
return &MMU{
cr3: &PageTableLevel{},
tlb: make(map[uint64]uint64),
}
}
// 拆分虚拟地址
type VAddrParts struct {
PGDIdx uint64
PUDIdx uint64
PMDIdx uint64
PTEIdx uint64
Offset uint64
}
func splitVA(va uint64) VAddrParts {
return VAddrParts{
Offset: va & 0xFFF, // 低 12 位
PTEIdx: (va >> 12) & 0x1FF, // 中间 4 段各 9 位
PMDIdx: (va >> 21) & 0x1FF,
PUDIdx: (va >> 30) & 0x1FF,
PGDIdx: (va >> 39) & 0x1FF,
}
}
func (m *MMU) Translate(va uint64) (uint64, error) {
vpn := va &^ 0xFFF // 虚拟页号
// 先查 TLB
if pa, ok := m.tlb[vpn]; ok {
m.tlbHits++
return pa | (va & 0xFFF), nil
}
m.tlbMiss++
// TLB 未命中,走 4 级页表
parts := splitVA(va)
pud := m.cr3.entries[parts.PGDIdx]
m.memAccess++
if pud == nil || !pud.Present {
return 0, fmt.Errorf("PGD entry %d not present", parts.PGDIdx)
}
// 为了简化模拟,我们用一个 map 存下一级
// 真实实现里 pud.FrameNum 指向一个物理页
pmdTable := getLevel(pud.FrameNum)
pmd := pmdTable.entries[parts.PUDIdx]
m.memAccess++
if pmd == nil || !pmd.Present {
return 0, fmt.Errorf("PUD entry %d not present", parts.PUDIdx)
}
pteTable := getLevel(pmd.FrameNum)
pte := pteTable.entries[parts.PMDIdx]
m.memAccess++
if pte == nil || !pte.Present {
return 0, fmt.Errorf("PMD entry %d not present", parts.PMDIdx)
}
finalTable := getLevel(pte.FrameNum)
final := finalTable.entries[parts.PTEIdx]
m.memAccess++
if final == nil || !final.Present {
return 0, fmt.Errorf("PTE entry %d not present", parts.PTEIdx)
}
// 权限检查
if !final.UserMode {
return 0, fmt.Errorf("permission denied: supervisor-only page")
}
pa := (final.FrameNum << 12) | parts.Offset
m.tlb[vpn] = pa &^ 0xFFF // 填入 TLB
return pa, nil
}
// 模拟物理内存:页框号 → 页表
var levelStore = make(map[uint64]*PageTableLevel)
var nextFrame uint64 = 1
func getLevel(frame uint64) *PageTableLevel {
if l, ok := levelStore[frame]; ok {
return l
}
l := &PageTableLevel{}
levelStore[frame] = l
return l
}
func (m *MMU) MapPage(va, pa uint64, writable, executable bool) {
parts := splitVA(va)
// 逐级建立页表项
pud := m.cr3.entries[parts.PGDIdx]
if pud == nil {
pud = &PTE{Present: true, Writable: true, UserMode: true, FrameNum: nextFrame}
nextFrame++
m.cr3.entries[parts.PGDIdx] = pud
}
pmdTable := getLevel(pud.FrameNum)
pmd := pmdTable.entries[parts.PUDIdx]
if pmd == nil {
pmd = &PTE{Present: true, Writable: true, UserMode: true, FrameNum: nextFrame}
nextFrame++
pmdTable.entries[parts.PUDIdx] = pmd
}
pteTable := getLevel(pmd.FrameNum)
pte := pteTable.entries[parts.PMDIdx]
if pte == nil {
pte = &PTE{Present: true, Writable: true, UserMode: true, FrameNum: nextFrame}
nextFrame++
pteTable.entries[parts.PMDIdx] = pte
}
finalTable := getLevel(pte.FrameNum)
finalTable.entries[parts.PTEIdx] = &PTE{
Present: true,
Writable: writable,
UserMode: true,
NoExec: !executable,
FrameNum: pa >> 12,
}
}
func main() {
mmu := NewMMU()
// 映射几个页面
mmu.MapPage(0x400000, 0x1000000, true, true) // 代码段:可写可执行
mmu.MapPage(0x600000, 0x2000000, true, false) // 数据段:可写不可执行
mmu.MapPage(0x800000, 0x3000000, false, false) // 只读段
fmt.Println("=== 页表翻译模拟 ===\n")
tests := []uint64{
0x400000, // 代码段
0x400123, // 代码段内偏移
0x600000, // 数据段
0x800000, // 只读段
0xA00000, // 未映射
}
for _, va := range tests {
// 第一次访问(TLB miss)
pa, err := mmu.Translate(va)
status := "OK"
if err != nil {
status = fmt.Sprintf("FAULT: %v", err)
}
fmt.Printf("VA 0x%-10x → PA 0x%-12x %s\n", va, pa, status)
}
// 第二次访问同样地址(TLB hit)
fmt.Println("\n=== 重复访问(TLB 命中)===")
for _, va := range tests[:4] {
pa, _ := mmu.Translate(va)
fmt.Printf("VA 0x%-10x → PA 0x%-12x (from TLB)\n", va, pa)
}
fmt.Printf("\n=== 统计 ===\n")
fmt.Printf("TLB 命中: %d, TLB 未命中: %d\n", mmu.tlbHits, mmu.tlbMiss)
fmt.Printf("页表遍历导致的额外内存访问: %d 次\n", mmu.memAccess)
fmt.Printf("若无 TLB,需 %d 次访存;有 TLB,实际 %d 次\n",
mmu.tlbMiss*4+mmu.tlbHits*4, mmu.memAccess)
}运行它会输出:
=== 页表翻译模拟 ===
VA 0x400000 → PA 0x1000000 OK
VA 0x400123 → PA 0x1000123 OK
VA 0x600000 → PA 0x2000000 OK
VA 0x800000 → PA 0x3000000 OK
VA 0xa00000 → PA 0x0 FAULT: PGD entry 1 not present
=== 重复访问(TLB 命中)===
VA 0x400000 → PA 0x1000000 (from TLB)
VA 0x400123 → PA 0x1000000 (from TLB) ← 注意:同一页
VA 0x600000 → PA 0x2000000 (from TLB)
VA 0x800000 → PA 0x3000000 (from TLB)
=== 统计 ===
TLB 命中: 4, TLB 未命中: 5
页表遍历导致的额外内存访问: 20 次
若无 TLB,需 36 次访存;有 TLB,实际 20 次
注意一个小细节:0x400000 和 0x400123 翻译后得到相同的物理页框 —— 因为它们在同一个 4KB 页里,只有 offset 不同。这说明 TLB 缓存的是”页”的映射,不是”字节”的映射。 一个 TLB 条目覆盖 4KB(或 2MB)的地址范围。
第三层追问:Go 的内存布局和这些有什么关系
老陈:现在把这些串起来。你知道 Go 的堆是怎么组织的吗?
小林:好像是按 size class 分?
老陈:对,具体说说。
小林:Go 把对象按大小分成 67 个 size class,从 8 字节到 32KB。每个 size class 有一批 span,span 是一组连续的页……
老陈:好。那我问你:为什么 Go 要搞 size class?直接按需分配不行吗?
小林:……减少碎片?
老陈:这是原因之一。但更重要的原因,跟这一节讲的缓存行有关。你想想:同一个 size class 的对象会被分配在相邻的内存地址。这意味着什么?
小林:……它们可能在同一个缓存行?或者在相邻的缓存行,能利用空间局部性?
老陈:对。而且还有一层:Go 的
mcache是每个 P(Processor)独享的,无锁分配。这意味着同一个 P 上分配的、大小相近的对象,物理上是挨着的。当你的代码遍历一个链表或者切片时,这些对象很可能已经被预取进了同一个缓存行或者相邻缓存行。小林:所以 size class 不只是为了减少碎片,还是为了让相关的数据物理上靠近?
老陈:没错。这就是我在第 01 节说的那个统一法则的又一次体现:让相关的东西物理上靠近。Go 的内存分配器在设计时就把缓存局部性考虑进去了。
Go 内存分配的完整层次
┌─────────────────────────────────────────────────────┐
│ 用户代码: obj := &MyStruct{} │
└────────────────────┬────────────────────────────────┘
▼
┌─────────────────────────────────────────────────────┐
│ ① 微小对象 (< 16B, 无指针) → Tiny allocator │
│ 多个微小对象塞进同一个 16B 块,极致省空间 │
└────────────────────┬────────────────────────────────┘
▼
┌─────────────────────────────────────────────────────┐
│ ② 小对象 (16B ~ 32KB) → mcache (per-P, 无锁) │
│ 按 size class 分配,从 mcentral 批量补充 │
│ ★ 这一层是无锁的,是 Go 分配快的关键 │
└────────────────────┬────────────────────────────────┘
▼
┌─────────────────────────────────────────────────────┐
│ ③ 大对象 (> 32KB) → mheap (全局,需加锁) │
│ 直接从 heap arena 分配 span │
└────────────────────┬────────────────────────────────┘
▼
┌─────────────────────────────────────────────────────┐
│ ④ 向 OS 要内存 → mmap / sysAlloc │
│ 一次申请 64MB 的 heap arena │
│ ★ 这里就是虚拟内存分配,物理页按需分配 │
└─────────────────────────────────────────────────────┘
每一层都在做同一件事:把”向更底层要资源”的次数降到最低。
- mcache:避免每次分配都访问 mcentral(加锁)
- mcentral 批量补充 span:避免每次都访问 mheap
- mheap 一次 mmap 64MB:避免频繁系统调用
这跟 CPU 的 L1/L2/L3/内存/磁盘 层次结构,是同一种思想的同一套打法。
老陈:记住这个模式:当”向下一层请求资源”的成本很高时,就在本层做一个批量缓存。这个模式你会在这本书里看到至少十次:CPU 缓存、TLB、Go 的 mcache、数据库的 Buffer Pool、Kafka 的批量发送、LSM 的 MemTable。 当你设计自己的系统时,第一个该问的问题就是——我的”下一层”在哪?请求它的成本是多少?我要缓存多少?
更深层的发问
问题 A:为什么虚拟地址是 48 位,不是 64 位?
x86-64 的指针是 64 位,但硬件实际只用低 48 位(或 57 位,5 级页表)。高 16 位必须是”符号扩展”(全 0 或全 1)。
为什么不做满 64 位?
老陈的提示:
- 4 级页表下,48 位地址 = 2^48 = 256TB。这已经够用了。做满 64 位需要 6 级页表,每次翻译多 2 次访存。
- 更重要的是:高 16 位可以复用。 比如 ARM 的 Top Byte Ignore(TBI)特性,允许软件在地址的高字节存标签。Go 的某些调试工具、以及硬件内存标记(MTE)就在用这个。
- 还有一个隐蔽的好处:操作系统可以把内核空间的地址放在”高半部分”(全 1 扩展),用户空间放在”低半部分”(全 0 扩展)。判断一个地址属于内核还是用户,只需要看最高位。 这在某些快速路径检查里很有用。
延伸思考:Go 的 unsafe.Pointer 是 64 位。如果硬件只用 48 位,我们能不能在指针里”偷”几位存数据?
答案是可以的,而且已经有人这么做了——某些 lock-free 数据结构把计数器塞进指针的高位(需要 128 位 CAS 支持)。Go 标准库没这么干,因为可移植性差。
问题 B:为什么 Go 的堆可以随便增长,而栈不行?
func deep(n int) {
if n > 0 { deep(n-1) }
}
// 递归 100 万次会 stack overflowGo 的 goroutine 栈是连续增长的(初始 2KB,最大 1GB,可配置)。栈增长需要分配新栈 + 拷贝旧栈。
为什么栈必须是连续的?
老陈的提示:
- 寄存器里存的是栈指针(SP)。函数调用时,硬件的
call指令会自动push返回地址到[SP]。如果栈不连续,SP就没法简单地 +8 / -8。- 局部变量通过
[SP+offset]访问。如果栈是不连续的,编译器就要为每次访问生成额外的地址计算。- C 语言 ABI 依赖连续栈。cgo 调用要求栈布局兼容。
所以 Go 的栈必须连续,但 goroutine 的堆分配不需要。这就是栈和堆最本质的区别之一。
有意思的是:Go 曾经考虑过”分段栈”(segmented stack),每个 goroutine 的栈是一串不连续的小块,用链表串起来。这个方案在 Go 1.2 被废弃了,原因是 hot split 问题——如果一个循环里反复调用一个小函数,恰好在栈边界附近,就会反复触发”分裂/合并”,性能灾难。
这个历史教训值得记住:一个优化如果在最坏情况下会退化成 O(n²),那它就不算优化。
问题 C:如果让你给 Go runtime 加一个”自动避免 false sharing”的功能,你会怎么做?
假设你发现公司的 Go 服务里有大量 false sharing,你想做一个工具自动检测并修复。
老陈的提示:想想这几个层次:
编译期方案:编译器分析结构体字段的访问模式,自动插入 padding。问题:编译器很难知道运行时的并发访问模式。可行的近似——如果一个字段是
atomic类型或被sync/atomic操作,就给它独占缓存行。这个启发式其实很有效,因为原子操作几乎总是高并发访问的。运行期方案:用硬件性能计数器(PMU)检测 cache line 的 HITM(Hit Modified)事件。
perf c2c(cache-to-cache)工具就是干这个的:perf c2c record -- ./app perf c2c report它能直接指出”哪一行代码、哪个变量在跨核争抢缓存行”,甚至显示出争抢的两个核。
语言层方案:提供一个
cacheline.Aligned[T]泛型包装:package cacheline type Aligned[T any] struct { value T _ [64 - unsafe.Sizeof(*new(T))%64]byte // 伪代码 }实际上 Go 社区已经有类似库(如
github.com/templexxx/cpu提供的X86缓存行对齐原语)。我的建议:先做运行期检测(perf c2c),定位到具体位置,再手工加 padding。自动优化容易误伤(padding 会浪费内存),而 false sharing 只在极少数热点位置才值得处理。
更一般的教训:性能优化的正确顺序永远是:测量 → 定位 → 优化 → 再测量。 跳过测量直接优化,90% 的情况下你在优化一个不是瓶颈的东西。这跟第 01 节小林的教训一模一样——他查了一周 Go 代码,因为他在没有测量的情况下假设瓶颈在应用层。
思考题
题 1 ·【理解层】
make([]byte, 1<<30) 之后立即 runtime.GC(),这个 1GB 内存会被回收吗?如果不用 make 而是 mmap 然后 munmap 呢?请解释这两种方式在虚拟内存层面的差异。
参考答案
先说结论:
| 方式 | GC 后虚拟地址空间 | GC 后物理内存 (RSS) |
|---|---|---|
make([]byte, 1<<30) + 丢弃引用 | 保留(Go 的 arena 不轻易归还) | 释放(返回给 Go 的空闲列表) |
mmap + munmap | 释放 | 释放 |
详细解释:
情况 1:make 分配的内存
func alloc1GB() {
buf := make([]byte, 1<<30)
_ = buf
// 函数返回,buf 变成垃圾
}
alloc1GB()
runtime.GC()make([]byte, 1<<30) 走的是 Go 的大对象分配路径(> 32KB),从 mheap 分配一个 span。
GC 之后:
- Go 判断这个 span 完全空闲 → 把它标记为空闲 span
- 但这个 span 所属的 heap arena(64MB)不会立即归还给 OS
- Go 会通过
madvise(MADV_DONTNEED)告诉内核”这些物理页我不要了”,内核会回收物理页 - 但虚拟地址空间保留,下次分配可以直接复用,不用重新
mmap
所以:RSS 会下降(物理内存还给 OS),VIRT 基本不变(虚拟地址空间还占着)。
想强制还给 OS:
runtime.GC()
debug.FreeOSMemory() // 强制把所有空闲 span 归还给 OSdebug.FreeOSMemory() 的代价:
- 触发一次完整 GC(STW)
- 对所有空闲 span 调用
madvise - 下次分配要重新
mmap+ 缺页
通常不建议手动调用,除非你知道自己在做什么(比如一个批处理任务,刚处理完 10GB 数据,后面要长时间空闲)。
Go 1.16+ 的改进:Go 引入了页回收器(page scavenger),后台 goroutine 会周期性地把长时间空闲的物理页还给 OS。默认策略是:保留最近用到的,归还 5 分钟以上没碰过的。参数:
debug.SetMemoryLimit(...) // Go 1.19+ 的软内存上限
debug.SetGCPercent(...)情况 2:直接 mmap/munmap
buf, _ := syscall.Mmap(-1, 0, 1<<30,
syscall.PROT_READ|syscall.PROT_WRITE,
syscall.MAP_PRIVATE|syscall.MAP_ANONYMOUS)
// 使用 ...
syscall.Munmap(buf) // 立即释放munmap 是立即、彻底的:
- 虚拟地址空间立即释放(VMA 从进程的地址空间移除)
- 所有已分配的物理页立即回收
- TLB 中相关条目失效(需要 TLB shootdown,见下文)
代价:
munmap是系统调用(约 1~2μs),比 Go 的堆分配慢几个数量级- TLB shootdown:多核系统上,修改页表后要通知所有核心刷新 TLB。这是一次跨核中断(IPI),开销可能达到几十微秒
- 频繁 mmap/munmap 会造成虚拟地址空间碎片
什么时候该用 mmap?
适合场景:
- 超大块内存(几百 MB 以上),生命周期明确
- 需要特殊内存属性:大页(
MAP_HUGETLB)、锁定内存(MAP_LOCKED)、共享内存(MAP_SHARED) - 自己实现内存池,绕过 Go 的 GC
典型例子:
- 数据库的 Buffer Pool(TiDB、MySQL 都自己管)
- 高性能网络框架的 ring buffer(DPDK)
- 需要
mlock防止 swap 的场景
Go 的实际做法:Go runtime 自己在用 mmap,但不暴露给用户。用户应该用 make + sync.Pool,让 runtime 帮你管理。
一个重要的陷阱:
// ❌ 危险:忘记 munmap 会泄漏虚拟地址空间
for {
buf, _ := syscall.Mmap(...) // 没有 munmap
// ...
}这个循环会耗尽虚拟地址空间(x86-64 上是 128TB,但容器里可能有更严格的限制),最终 mmap 返回 ENOMEM。
而 Go 的堆分配不会有这个问题,因为 GC 会回收。这就是为什么默认的 make 比裸 mmap 安全得多。
题 2 ·【应用层】
你在优化一个 Go 写的高频交易系统。核心是一个订单簿(Order Book),需要多个 goroutine 并发更新不同价格档位的挂单量。你发现吞吐量上不去,perf c2c 显示大量的 HITM 事件。请设计你的数据结构,并解释每一个设计决策在缓存/内存层面的理由。
参考答案
需求分析
订单簿的典型结构:
买盘 (Bid) 卖盘 (Ask)
价格 数量 价格 数量
100.5 1000 100.6 800
100.4 2000 100.7 1500
100.3 500 100.8 300
操作模式:
- 更新:某个价格档位的数量变化(高频,每秒百万次)
- 查询:获取最优买卖价、某个档位的深度
- 并发:多个 goroutine 分别处理不同股票或不同价格区间
设计
决策 1 · 用数组而不是链表,用价格索引而不是哈希
// ❌ 错误示范:用 map
type OrderBookBad struct {
levels map[int64]*Level // 价格 → 档位
}
// ✅ 正确:用数组 + 价格索引
type OrderBook struct {
// 价格是离散的(比如最小变动单位 0.01)
// 把价格映射成数组下标:idx = (price - basePrice) / tickSize
bidLevels []Level
askLevels []Level
basePrice int64
tickSize int64
}理由(内存层面):
| map | 数组 | |
|---|---|---|
| 内存布局 | 节点分散在堆上,指针跳转 | 连续 |
| 缓存命中 | 每次查找都是随机访问,L1/L2 大概率 miss | 顺序访问,预取器能提前加载 |
| 预取 | 无法预取(指针不可预测) | 硬件预取器自动预取后续元素 |
| TLB | 大 map 跨很多页,TLB miss 严重 | 连续页,TLB 友好 |
关键数字:一次 L3 miss 约 40ns,而一次 L1 hit 只要 1ns。高频交易系统里,一笔订单处理的预算可能只有 1μs,几十次 cache miss 就吃掉了一半预算。
决策 2 · 档位数据按缓存行对齐,避免 false sharing
const cacheLineSize = 64
type Level struct {
Price int64
Size int64 // 挂单量,高频更新
OrderCount int32
_ [36]byte // 填充到 64 字节
}
// 验证:
var _ [0]byte = [unsafe.Sizeof(Level{}) - 64]byte{} // 编译期断言理由:
如果 Level 是 24 字节,那么一个 64 字节缓存行能装 2.67 个 Level。goroutine A 更新档位 0,goroutine B 更新档位 1——它们在同一缓存行,产生 false sharing。
对齐到 64 字节后,每个档位独占一行,浪费 40 字节/档位,但消除了跨核缓存争抢。
权衡:
- 一个订单簿如果有 10000 个档位,padding 会浪费 400KB
- 但这 400KB 换来的是消除每秒百万次的缓存行失效
- 在延迟敏感场景,这个交换是划算的;在内存敏感场景(比如要维护 10 万个股票的订单簿)就不划算
这里体现的判断力:不要无脑 padding。先测量,确认真的有 false sharing,再针对热点位置处理。
决策 3 · 按 CPU 核分片(Sharding),不是按股票分片
// ❌ 天真的做法:全局一把锁
type OrderBookGlobal struct {
mu sync.RWMutex
levels []Level
}
// ✅ 分片:每个 shard 有自己的缓存行对齐的计数器
type Shard struct {
_ [cacheLineSize]byte // 前导填充
seqNum atomic.Uint64 // 该 shard 的序列号
levels []Level
mu sync.Mutex // 每个 shard 独立锁
_ [cacheLineSize]byte // 尾部填充,防止相邻 shard 互相影响
}
type OrderBook struct {
shards []Shard // 按 CPU 核数分片
}理由:这一节讲的 false sharing 不只发生在数组元素之间,也发生在相邻的 struct 之间。如果两个 Shard 在内存里挨着,即使每个 Shard 内部对齐了,Shard A 的尾部和 Shard B 的头部仍可能在同一缓存行。
所以要前后都填充,确保任意两个 shard 的任何字段都不在同一缓存行:
// 完整的对齐方案
type Shard struct {
_ [cacheLineSize]byte
// ↓ 真正的热数据
seqNum atomic.Uint64
// ↓ 低频数据可以不用严格对齐
levels []Level
mu sync.Mutex
_ [cacheLineSize]byte
}
// 分配时也要对齐
func NewShards(n int) []Shard {
// Go 的 make 不保证 64 字节对齐,要手动处理
raw := make([]byte, n*int(unsafe.Sizeof(Shard{}))+cacheLineSize)
addr := uintptr(unsafe.Pointer(&raw[0]))
offset := (cacheLineSize - addr%cacheLineSize) % cacheLineSize
return unsafe.Slice(
(*Shard)(unsafe.Pointer(&raw[offset])), n)
}决策 4 · 用原子操作替代锁,但只在真正需要的地方
// 更新单个档位的挂单量(最高频操作)
func (s *Shard) UpdateSize(idx int32, delta int64) {
atomic.AddInt64(&s.levels[idx].Size, delta)
}
// 读取最优价(次高频,只读)
func (b *OrderBook) BestBid() (int64, int64) {
// 无锁读,从最高价往低扫,找到第一个 Size > 0 的档位
for i := len(b.bidLevels) - 1; i >= 0; i-- {
size := atomic.LoadInt64(&b.bidLevels[i].Size)
if size > 0 {
return b.basePrice + int64(i)*b.tickSize, size
}
}
return 0, 0
}内存层面的考虑:
atomic.AddInt64 在 x86-64 上编译成 LOCK XADD 指令。这条指令会:
- 锁定缓存行(不是锁定总线!现代 CPU 用缓存锁定)
- 如果缓存行是 Shared 状态,发 RFO(Request For Ownership)让其他核心的副本失效
- 执行加法
- 写回
代价:一次 LOCK 操作约 20~40 个时钟周期,而普通加法只要 1 个周期。差 30 倍。
所以:能用普通加法就不要用原子操作。 如果每个 shard 只有一个 goroutine 写(通过绑核保证),那就可以用普通加法。
// 如果通过绑核保证了单写者,可以退化成普通操作
func (s *Shard) UpdateSizeSingleWriter(idx int32, delta int64) {
s.levels[idx].Size += delta // 普通加法,快 30 倍
// 但需要内存屏障保证可见性
atomic.StoreInt64(&s.seqNum, s.seqNum.Load()+1) // 发布
}决策 5 · 内存预分配 + 对象池,消除 GC
type OrderBook struct {
levels []Level // 预分配,永不释放
orders Pool // 订单对象池
}
func NewOrderBook(maxLevels int) *OrderBook {
return &OrderBook{
levels: make([]Level, maxLevels), // 一次性分配,之后不再分配
}
}
// 订单对象从池里取,用完归还
var orderPool = sync.Pool{
New: func() any { return new(Order) },
}理由:
- GC 的标记阶段要遍历整个堆。堆越大,标记越慢。高频交易系统不能有 GC 抖动。
- 避免分配 = 避免 false sharing 的新来源——新分配的对象地址随机,可能落在任何缓存行。
- 预分配的数组地址固定,CPU 的缓存和 TLB 可以长期保持热度。
决策 6 · 用 HugePage
# 系统层
echo 1024 > /proc/sys/vm/nr_hugepages
# 应用层(用 mmap 分配订单簿内存)
levels, _ := syscall.Mmap(-1, 0, size,
PROT_READ|PROT_WRITE,
MAP_PRIVATE|MAP_ANONYMOUS|MAP_HUGETLB)理由:订单簿可能几十 MB,随机访问模式。4KB 页下 TLB 覆盖不了,2MB 大页能覆盖。
整体架构图
┌──────────────────────────────────────────────────────┐
│ OrderBook │
├──────────────────────────────────────────────────────┤
│ Shard 0 (CPU 0) Shard 1 (CPU 1) │
│ ┌────────────────┐ ┌────────────────┐ │
│ │ [64B padding] │ │ [64B padding] │ │
│ │ seqNum (atomic)│ │ seqNum (atomic)│ │
│ │ levels[] │ │ levels[] │ │
│ │ ├ Level 0 64B │ │ ├ Level 0 64B │ │
│ │ ├ Level 1 64B │ │ ├ Level 1 64B │ │
│ │ └ ... │ │ └ ... │ │
│ │ mu │ │ mu │ │
│ │ [64B padding] │ │ [64B padding] │ │
│ └────────────────┘ └────────────────┘ │
│ ▲ ▲ │
│ │ │ │
│ 绑核到 CPU 0 绑核到 CPU 1 │
└──────────────────────────────────────────────────────┘
▲
│ 分配在 2MB HugePage 上
验证方法
# 1. 确认 false sharing 消除了
perf c2c record -- ./trading-engine
perf c2c report
# 期望:HITM 事件从几十万降到接近 0
# 2. 确认缓存命中率
perf stat -e cache-references,cache-misses,L1-dcache-loads,L1-dcache-load-misses \
./trading-engine
# 3. 确认 TLB
perf stat -e dTLB-loads,dTLB-load-misses ./trading-engine
# 4. 延迟分布(P99/P999 才是关键)
# 不要只看平均延迟!一句话总结
这套设计的每一个决策,都对应这一节讲的一个硬件事实:
| 决策 | 对应的硬件事实 |
|---|---|
| 数组而非 map | 空间局部性 + 硬件预取 |
| 档位 64B 对齐 | 缓存行是 64 字节,false sharing |
| Shard 前后填充 | 相邻结构体也会 false sharing |
| 绑核 + 单写者 | 消除原子操作(LOCK 指令慢 30 倍) |
| 预分配 + 对象池 | 消除 GC(标记要遍历堆) |
| HugePage | TLB 覆盖率 |
这就是为什么”懂底层”能直接转化为性能。 每一个优化都不是猜的,而是对着硬件的真实行为做出的针对性设计。
题 3 ·【创造层】
虚拟内存的本质是”一层间接寻址”(indirection)。计算机科学有句名言:“任何问题都可以通过增加一个间接层来解决”——但这句话还有后半句(通常被省略):”……除了间接层太多的问题”。请举出至少 4 个计算机系统中的间接层例子,分析每个间接层解决了什么问题、引入了什么新问题,并总结:什么时候应该加间接层,什么时候应该去掉?
参考答案
四个间接层案例
① 虚拟内存(Virtual Memory)
- 解决的:进程隔离、地址空间简化、内存超额分配、权限控制
- 引入的:
- 地址翻译开销(靠 TLB 缓解,但没有完全消除)
- 内存占用(页表本身要占内存)
- 复杂度(TLB shootdown、缺页异常处理、页置换算法)
- 不可预测性(缺页异常的延迟是随机的,实时系统很头疼)
② 文件系统(File System)
- 解决的:把”磁盘上的扇区”抽象成”命名的字节流”,提供持久化和共享
- 引入的:
- 一次读写要经过:VFS → 具体文件系统 → 页缓存 → 块层 → IO 调度 → 设备驱动 → 硬件
- 元数据一致性问题(断电怎么办?所以有 journaling、fsync)
- 缓存一致性(page cache 和 mmap 的交互)
③ 接口/抽象类型(Interface)
type Storage interface {
Get(key string) ([]byte, error)
Put(key string, val []byte) error
}-
解决的:解耦、可测试、可替换实现
-
引入的:
- 动态分发开销(itab 查找 + 间接跳转)
- 阻碍内联(编译器不知道具体类型,无法内联)
- 分支预测失败(间接跳转是分支预测器的噩梦)
- 逃逸分析失效(接口方法调用通常会让参数逃逸到堆上)
实测数字:Go 里通过接口调用一个空方法,比直接调用慢 3~5 倍(约 1.5ns vs 0.3ns)。如果方法体很小,这个开销占比会非常高。
④ 消息队列(Message Queue)
- 解决的:解耦生产者和消费者、削峰填谷、异步化、广播
- 引入的:
- 延迟(多一跳网络 + 持久化)
- 一致性问题(消息丢失、重复、乱序)
- 运维复杂度(要维护一个分布式系统)
- 调试困难(调用链断开了,你不知道一条消息被谁消费了)
共同模式
所有间接层都遵循同一个模板:
┌──────────────────────────────┐
│ 客户端(使用方) │
└──────────────┬───────────────┘
│ 统一的接口
┌──────────────▼───────────────┐
│ 间接层(翻译/路由) │
│ · 维护映射表 │
│ · 做权限检查 │
│ · 做优化(缓存/批处理) │
└──────────────┬───────────────┘
│ 真实的实现
┌──────────────▼───────────────┐
│ 资源(物理的) │
└──────────────────────────────┘
间接层的价值 = 它提供的抽象能力 + 它在中间能做的优化
- 虚拟内存在中间能做:COW、按需分配、内存共享、swap
- 文件系统在中间能做:缓存、预读、延迟写、快照
- 消息队列在中间能做:削峰、重试、广播、持久化
间接层的成本 = 每次访问的额外开销 + 维护映射表的成本 + 失效时的惩罚
什么时候该加间接层?
判据 1 · 映射关系会变化,且变化频率不低
虚拟内存的”虚拟页 → 物理页”映射会变(swap、页面迁移)——所以间接层有价值。 如果映射是固定不变的,间接层就是纯粹浪费。
判据 2 · 需要在中间插入横切逻辑
内存访问需要权限检查 → 页表项里的标志位 方法调用需要日志/监控/重试 → 中间件、AOP 网络请求需要负载均衡 → Service Mesh
判据 3 · 客户端数量 >> 资源数量,或者反过来需要多路复用
100 个进程共享 8GB 内存 → 虚拟内存 1000 个生产者共享 1 个数据库 → 连接池
判据 4 · 抽象带来的收益 > 映射表的维护成本
页表要占内存、要维护、TLB miss 有惩罚——但换来的是进程隔离。在有多进程需求的系统里,这笔交易划算。在单片机的裸机程序里,就不划算。
什么时候该去掉间接层?
信号 1 · 间接层的优化能力用不上
如果你只有一个进程,虚拟内存的隔离能力就没用——但它已经在硬件里了,去不掉。 但软件层面的间接层可以去掉。比如:
- 明知道只有一个实现,就不要定义接口(Go 社区常说的 “accept interfaces, return structs”)
- 明知道没有多租户,就不要加租户隔离层
信号 2 · 访问频率极高,且间接层无法缓存
这就是这一节的核心场景:
- 高频交易的热路径上,不要有接口调用(用具体类型或泛型)
- 数据库的热点查询,不要经过 ORM(手写 SQL)
- 网络包处理,不要经过内核协议栈(DPDK 直接操作网卡)
信号 3 · 间接层让调试变得不可能
消息队列把调用链断开了,一次请求跨越 5 个服务,出问题时你不知道在哪一环。 这时候需要加的不是更多间接层,而是可观测性(这也是第 6 章的主题)。
信号 4 · 间接层的性能损耗出现在 P99 上(而不仅是平均值)
虚拟内存的缺页异常、GC 的 STW、消息队列的 rebalance——这些不会体现在平均延迟上,但会造成 P99 尖刺。 P99 尖刺往往来自间接层的”失效路径”,而不是正常路径。
一个实用的决策框架
遇到”要不要加一层”的时候,问这四个问题:
Q1: 这一层能让我在中间做什么?
(答不上来 → 不加)
Q2: 这个"中间能做的事",多久用一次?
(几乎不用 → 不加)
Q3: 这一层的失效路径是什么?失效时代价多大?
(代价不可接受 → 要加降级方案,或者不加)
Q4: 半年后,这一层还在吗?
(大概率被删掉 → 现在就别加)
Q4 是最狠的一个问题。 大部分过度设计,都是因为人们高估了”未来会用到”的概率。
延伸:Go 语言本身对间接层的态度
Go 的设计里有一些很有意思的取舍:
去掉的间接层:
- 没有继承(继承是一种强耦合的间接层)
- 没有异常(异常是一种隐式的控制流跳转,相当于间接层)
- 没有注解/装饰器(这些是运行时的元编程间接层)
- 没有泛型(Go 1.18 之前,虽然这个后来加上了)
保留的间接层:
- 接口(但设计成隐式实现,减少耦合)
- goroutine(调度是一个间接层,但换来的是并发的简化)
- GC(内存管理的间接层)
- defer(延迟执行的间接层,Go 1.14 后做了 open-coded defer 优化,把大部分 defer 的开销降到了接近零)
Go 1.14 的 open-coded defer 是个绝佳案例:
- 原本
defer要在运行时把 defer 记录压到链表上,开销约 50ns - 优化后,编译器在函数末尾直接内联展开 defer 调用,开销降到约 1ns
- 只有在循环里的 defer(数量不确定)才走慢路径
这就是”去掉间接层”的典范操作:保留语言特性,但把间接层的成本从 50ns 降到 1ns。
给你的启发:当你发现某个抽象很慢时,第一反应不应该是”去掉这个抽象”,而是”能不能让编译器/系统把这个间接层优化掉”。这才是最高级的解法。
小结:这一节你应该带走的东西
-
虚拟内存是一场骗局,但这场骗局带来了四个巨大的收益:隔离、简化、超额分配、权限控制。
-
多级页表把稠密数组变成稀疏树,用 4 次访存换来了 512GB → 1MB 的空间节省。TLB 又把这 4 次访存摊薄到接近 0。这是一个”用空间换时间,再用缓存把时间换回来”的完整闭环。
-
缓存行是 64 字节,false sharing 能让多线程程序慢 6 倍。判断标准:数据是否真的共享,还是只是”碰巧在同一个缓存行”。
-
按需分页解释了为什么
make([]byte, 1<<30)是 O(1),也解释了 VIRT 和 RSS 的巨大差异。 -
统一法则再次出现:Go 的 mcache、CPU 缓存、TLB、数据库的 Buffer Pool——当向下一层请求的成本很高时,就在本层做批量缓存。这个模式会在后面反复出现。
-
间接层的判断力:知道什么时候加,什么时候去掉。这需要你能说出”这一层在中间能做什么”,以及”失效时代价多大”。
下一节
理论讲够了。现在我们要造一个东西。
老陈的预告:
malloc是 C 语言里最常用的函数之一,但几乎没人读过它的实现。这一节,我们用 Go 从零写一个内存分配器——从mmap开始,到 size class、free list、span 管理,最后跟 glibc 的 malloc 做个性能对比。写完之后,你就再也不会觉得内存分配是个黑盒了。 而且你会明白,为什么 Go 的分配器要设计成那个样子——不是因为它”应该”是这样,而是因为每一个设计点,都是被某个真实的约束逼出来的。