内存管理:内存池设计、避免缺页中断、大页内存与CPU Cache行对齐
做市商系统里,内存管理从来不是「随便 new 一下」的事。我见过太多团队,代码写得挺漂亮,一到高并发场景就卡顿、抖动、延迟飙升。查到最后,问题往往出在内存上。
这一章,我们专门聊内存。四个核心点:内存池、缺页中断、大页内存、Cache 行对齐。每一个都是实战中踩过的坑。
1. 内存池(Memory Pool)设计
先问个问题:为什么不用 malloc 或 new?
原因很简单——慢。而且是不确定的慢。做市商系统里,订单来了,你需要在微秒级内完成分配。但 malloc 背后可能触发系统调用、锁竞争、内存碎片整理。这些都会让延迟失控。
我个人习惯的做法是:预分配一块大内存,然后自己管理。
核心思路就三个字:预分配、复用、无锁。
内存池的基本结构
- 一块连续的大内存(比如 64MB)
- 一个空闲链表,记录哪些块可用
- 分配时从链表头部取,释放时放回链表
- 线程私有池,避免锁竞争
代码示例,一个简单的定长内存池:
class FixedSizePool {
private:
struct Block {
Block* next;
};
Block* free_list_;
char* pool_;
size_t block_size_;
size_t pool_size_;
public:
FixedSizePool(size_t block_size, size_t block_count)
: block_size_(block_size), pool_size_(block_size * block_count) {
pool_ = static_cast<char*>(std::aligned_alloc(64, pool_size_));
free_list_ = reinterpret_cast<Block*>(pool_);
Block* current = free_list_;
for (size_t i = 1; i < block_count; ++i) {
current->next = reinterpret_cast<Block*>(pool_ + i * block_size_);
current = current->next;
}
current->next = nullptr;
}
void* allocate() {
Block* block = free_list_;
if (block) {
free_list_ = block->next;
}
return block;
}
void deallocate(void* ptr) {
Block* block = static_cast<Block*>(ptr);
block->next = free_list_;
free_list_ = block;
}
};
嗯,这里要注意:定长池只适合固定大小的对象。如果对象大小不一,那就得用更复杂的伙伴系统或 slab 分配器。我在项目中遇到过,订单对象和报价对象大小不同,最后用了两个独立的定长池,反而比一个通用池更高效。
实战建议:每个线程维护自己的内存池,避免全局锁。线程间通信用无锁队列,而不是共享内存池。
2. 避免缺页中断(mlockall)
你想想看,一个内存页被换到磁盘上,程序访问时触发缺页中断,然后从磁盘读回来——这个过程少说几百微秒。做市商系统里,几百微秒足够让行情变几个来回。
所以,关键内存必须锁在物理内存里。
Linux 下用 mlockall 系统调用。我建议在程序启动时就调用:
#include <sys/mman.h>
// 锁定当前和未来的所有内存页
if (mlockall(MCL_CURRENT | MCL_FUTURE) != 0) {
perror("mlockall failed");
exit(1);
}
这里有个坑:mlockall 需要 root 权限,或者设置 CAP_IPC_LOCK 能力。我曾经在生产环境上忘了配权限,结果程序启动时静默失败,内存照样被换出。排查了整整两天才找到原因。
避坑指南:我曾经在压测时发现延迟偶尔飙升到毫秒级,查了所有代码都没问题。最后用 /proc/self/status 一看,VmLck 是 0——mlockall 根本没生效。从那以后,我每次启动都会主动检查 mlockall 的返回值。
另外,mlockall 锁的是整个进程的地址空间。如果内存占用太大,可能会触发 OOM。所以,合理规划内存用量,别贪多。
3. 大页内存(HugePages)
为什么需要大页?
标准页大小是 4KB。TLB(页表缓存)条目有限,如果进程访问的内存范围很大,TLB 会频繁 miss,每次都要查页表——这又是一次延迟抖动。
大页内存把页大小提升到 2MB 甚至 1GB。同样的 TLB 条目数,能覆盖的内存范围大了 512 倍。说白了,就是让 CPU 少跑几次内存。
配置大页内存的步骤:
- 检查当前大页配置:
cat /proc/meminfo | grep HugePages - 设置大页数量:
echo 1024 > /proc/sys/vm/nr_hugepages(分配 1024 个 2MB 大页) - 挂载 hugetlbfs:
mount -t hugetlbfs hugetlbfs /dev/hugepages - 程序中使用
mmap分配大页内存
代码示例:
#include <sys/mman.h>
#include <fcntl.h>
// 使用大页内存分配共享内存
int fd = open("/dev/hugepages/myapp", O_CREAT | O_RDWR, 0755);
if (fd < 0) {
perror("open hugepages failed");
exit(1);
}
void* addr = mmap(nullptr, 2 * 1024 * 1024, // 2MB
PROT_READ | PROT_WRITE,
MAP_SHARED | MAP_HUGETLB,
fd, 0);
if (addr == MAP_FAILED) {
perror("mmap hugepages failed");
exit(1);
}
性能对比:我在一个订单簿引擎上做过测试,启用大页后,TLB miss 减少了约 70%,整体延迟降低了 15%-20%。对于做市商系统来说,这个提升非常可观。
不过要注意,大页内存是稀缺资源。分配太多会浪费物理内存,分配太少又不够用。我一般按峰值内存需求的 1.2 倍来配置。
4. CPU Cache 行对齐与伪共享(False Sharing)
这是多线程编程里最容易踩的坑之一。
CPU Cache 是按行加载的,一行通常是 64 字节。如果两个线程各自修改不同的变量,但这两个变量恰好在同一个 Cache 行里——那么 CPU 的缓存一致性协议会让它们互相失效。每次写操作都要通知另一个核心,性能直接崩掉。
这就是伪共享。
解决办法很简单:让每个线程的变量独占一个 Cache 行。
C++ 里可以用 alignas 或 __attribute__((aligned(64))):
struct alignas(64) PerThreadData {
int64_t counter; // 每个线程自己的计数器
char padding[64 - sizeof(int64_t)]; // 填充到 64 字节
};
// 或者用 C++17 的 std::hardware_destructive_interference_size
struct alignas(std::hardware_destructive_interference_size) PerThreadData {
int64_t counter;
};
Java 里可以用 @Contended 注解(需要 JVM 参数 -XX:-RestrictContended):
public class PerThreadData {
@Contended
public volatile long counter;
}
实战经验:我曾经优化过一个报价生成模块,8 个线程各自维护一个计数器。没对齐前,每秒只能处理 200 万笔。加上 Cache 行对齐后,直接飙到 600 万笔。你想想看,就改了一行代码,性能翻了三倍。
检测伪共享的方法:用 perf 工具看 cache-misses 和 cache-references 的比例。如果 miss 率异常高,而且程序是多线程密集写操作,那大概率是伪共享。
注意:不要过度对齐。每个变量都对齐到 64 字节会浪费大量内存。只对高频写操作的共享变量做对齐就够了。
知识体系总览
下面这张图,把本章四个核心知识点串起来了:
这四个技术点,每一个单独拿出来都能优化不少。但真正的高手,是把它们组合起来用。内存池保证分配速度,mlockall 保证不被换出,大页保证 TLB 命中率,Cache 行对齐保证多核不打架。四管齐下,延迟才能压到极致。
嗯,这一章就到这里。内容不多,但每个点都值得你在自己的系统里试一试。
公众号:蓝海资料掘金营,微信deep3321