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)64256 KB—
L1 TLB (dTLB)64256 KB—
L2 TLB (STLB)15366 MB3 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)    禁止执行(防缓冲区溢出攻击)

这些标志位是虚拟内存目标 ④(权限控制)的实现。

举几个实际效果:

  1. 代码段标记 RW=0 → 写入代码段触发 SIGSEGV。这就是为什么 const 数据被修改时程序会崩。
  2. 代码段标记 NX=0,数据段和栈标记 NX=1 → 栈上注入的 shellcode 无法执行。这是 DEP/NX 保护,现代操作系统标配。
  3. 用户态代码访问 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]:

  1. 缓存一致性协议(MESI)把整个缓存行的所有权给核心 0
  2. 核心 0 的 L1 缓存里这一行变成 Modified 状态
  3. 其他所有核心的 L1 缓存里,这一行被标记为 Invalid
  4. 核心 1 想改 counters[1] → 发现自己的副本失效 → 必须从核心 0 的缓存(或内存)重新读
  5. 核心 1 改完,核心 0 的副本又失效
  6. 循环往复,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 位?

老陈的提示:

  1. 4 级页表下,48 位地址 = 2^48 = 256TB。这已经够用了。做满 64 位需要 6 级页表,每次翻译多 2 次访存。
  2. 更重要的是:高 16 位可以复用。 比如 ARM 的 Top Byte Ignore(TBI)特性,允许软件在地址的高字节存标签。Go 的某些调试工具、以及硬件内存标记(MTE)就在用这个。
  3. 还有一个隐蔽的好处:操作系统可以把内核空间的地址放在”高半部分”(全 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 overflow

Go 的 goroutine 栈是连续增长的(初始 2KB,最大 1GB,可配置)。栈增长需要分配新栈 + 拷贝旧栈。

为什么栈必须是连续的?

老陈的提示:

  1. 寄存器里存的是栈指针(SP)。函数调用时,硬件的 call 指令会自动 push 返回地址到 [SP]。如果栈不连续,SP 就没法简单地 +8 / -8。
  2. 局部变量通过 [SP+offset] 访问。如果栈是不连续的,编译器就要为每次访问生成额外的地址计算。
  3. 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 归还给 OS

debug.FreeOSMemory() 的代价:

  1. 触发一次完整 GC(STW)
  2. 对所有空闲 span 调用 madvise
  3. 下次分配要重新 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,见下文)

代价:

  1. munmap 是系统调用(约 1~2μs),比 Go 的堆分配慢几个数量级
  2. TLB shootdown:多核系统上,修改页表后要通知所有核心刷新 TLB。这是一次跨核中断(IPI),开销可能达到几十微秒
  3. 频繁 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 指令。这条指令会:

  1. 锁定缓存行(不是锁定总线!现代 CPU 用缓存锁定)
  2. 如果缓存行是 Shared 状态,发 RFO(Request For Ownership)让其他核心的副本失效
  3. 执行加法
  4. 写回

代价:一次 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) },
}

理由:

  1. GC 的标记阶段要遍历整个堆。堆越大,标记越慢。高频交易系统不能有 GC 抖动。
  2. 避免分配 = 避免 false sharing 的新来源——新分配的对象地址随机,可能落在任何缓存行。
  3. 预分配的数组地址固定,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(标记要遍历堆)
HugePageTLB 覆盖率

这就是为什么”懂底层”能直接转化为性能。 每一个优化都不是猜的,而是对着硬件的真实行为做出的针对性设计。

题 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。

给你的启发:当你发现某个抽象很慢时,第一反应不应该是”去掉这个抽象”,而是”能不能让编译器/系统把这个间接层优化掉”。这才是最高级的解法。


小结:这一节你应该带走的东西

  1. 虚拟内存是一场骗局,但这场骗局带来了四个巨大的收益:隔离、简化、超额分配、权限控制。

  2. 多级页表把稠密数组变成稀疏树,用 4 次访存换来了 512GB → 1MB 的空间节省。TLB 又把这 4 次访存摊薄到接近 0。这是一个”用空间换时间,再用缓存把时间换回来”的完整闭环。

  3. 缓存行是 64 字节,false sharing 能让多线程程序慢 6 倍。判断标准:数据是否真的共享,还是只是”碰巧在同一个缓存行”。

  4. 按需分页解释了为什么 make([]byte, 1<<30) 是 O(1),也解释了 VIRT 和 RSS 的巨大差异。

  5. 统一法则再次出现:Go 的 mcache、CPU 缓存、TLB、数据库的 Buffer Pool——当向下一层请求的成本很高时,就在本层做批量缓存。这个模式会在后面反复出现。

  6. 间接层的判断力:知道什么时候加,什么时候去掉。这需要你能说出”这一层在中间能做什么”,以及”失效时代价多大”。


下一节

04 · 动手:用 Go 写一个内存分配器

理论讲够了。现在我们要造一个东西。

老陈的预告:malloc 是 C 语言里最常用的函数之一,但几乎没人读过它的实现。这一节,我们用 Go 从零写一个内存分配器——从 mmap 开始,到 size class、free list、span 管理,最后跟 glibc 的 malloc 做个性能对比。

写完之后,你就再也不会觉得内存分配是个黑盒了。 而且你会明白,为什么 Go 的分配器要设计成那个样子——不是因为它”应该”是这样,而是因为每一个设计点,都是被某个真实的约束逼出来的。