在低延迟、高吞吐的 C++ 高性能计算服务(如交易系统、低延迟缓存、实时推流服务)中,进程间通过共享内存(Shared Memory)传递数据是极其常见的方案。
但是,当共享内存的规模达到数 GB 甚至数十 GB 时,默认的 4KB 页面会导致严重的性能瓶颈:TLB(Translation Lookaside Buffer)缓存命中率急剧下降,CPU 浪费大量周期在 Page Table Walker 的虚拟地址解析上。
为了解决这一痛点,我们通常会引入大页(HugePages,如 2MB 或 1GB 页面)。然而,许多开发者在尝试将常规的 POSIX shm_open 改造为 HugePages 时,会发现一个尴尬的现实:POSIX shm_open 并没有提供直接开启 HugePages 的标准标志。
本文将解析这一限制背后的内核机制,并提供两种在 Linux 环境下结合 HugePages 与共享内存的高性能 C++ 实现方案。
为什么 shm_open 不能直接挂载 HugePages?
POSIX shm_open 本质上是在虚拟文件系统 tmpfs(通常挂载在 /dev/shm)中创建文件。默认情况下,tmpfs 是基于内核的普通 4KB 物理页构建的。
虽然从内核 4.8 开始,tmpfs 支持通过透明大页(Transparent Huge Pages, THP)尝试分配大页,但这种方式是由内核异步、启发式决定的,不具有确定性。对于追求绝对确定性和极低延迟的 HPC/金工交易系统而言,我们需要的是显式大页(Explicit HugePages / HugeTLB),即在系统启动时预留、不被 swap、100% 确定物理连续的大内存页。
在 Linux 下,实现显式大页共享内存主要有以下两条演进路径。
方案一:基于 hugetlbfs 的文件共享内存(最经典的 shm_open 替代方案)
既然 shm_open 受限于 /dev/shm,那么我们完全可以绕开它。Linux 提供了专门的 hugetlbfs 文件系统,只要我们将大页文件系统挂载到指定目录,然后使用标准的 open、mmap 替代 shm_open,就能获得完全对等的“命名共享内存”功能。
1. 系统初始化与挂载
首先,需要向内核申请预留大页并挂载 hugetlbfs:
# 预留 1024 个 2MB 的大页(共 2GB 内存)
sudo sysctl -w vm.nr_hugepages=1024
# 创建挂载点
sudo mkdir -p /mnt/hugepages
# 挂载 hugetlbfs,并指定页面大小为 2MB
sudo mount -t hugetlbfs -o pagesize=2M none /mnt/hugepages
为了让非 root 用户运行的 C++ 服务能够读写该目录,建议调整挂载目录的权限:
sudo chmod 777 /mnt/hugepages
2. C++ 生产级代码实现
在 C++ 中,我们打开该挂载点下的一个文件,通过 mmap 进行共享。
需要注意:
hugetlbfs上的文件不支持传统的ftruncate任意扩容,文件大小必须是 HugePage 尺寸(这里是 2MB)的整数倍。- 需要通过
MAP_SHARED标志让多进程可见。
#include <iostream>
#include <string>
#include <fcntl.h>
#include <unistd.h>
#include <sys/mman.h>
#include <sys/stat.h>
#include <cstring>
class HugePageSharedMemory {
public:
HugePageSharedMemory(const std::string& name, size_t size_bytes)
: name_(name), size_(size_bytes), fd_(-1), addr_(nullptr) {
// 2MB 对齐校验(假设使用 2MB 大页)
const size_t HUGE_PAGE_SIZE = 2 * 1024 * 1024;
if (size_ % HUGE_PAGE_SIZE != 0) {
size_ = ((size_ / HUGE_PAGE_SIZE) + 1) * HUGE_PAGE_SIZE;
}
}
bool Initialize(bool create_new) {
std::string path = "/mnt/hugepages/" + name_;
if (create_new) {
// 创建并打开大页文件
fd_ = open(path.c_str(), O_CREAT | O_RDWR | O_TRUNC, 0666);
} else {
// 仅打开已有大页文件
fd_ = open(path.c_str(), O_RDWR);
}
if (fd_ < 0) {
perror("Failed to open hugetlbfs file");
return false;
}
// 使用 MAP_SHARED 映射共享内存
// MAP_POPULATE 可以让内核在 mmap 时直接分配物理页,避免运行时 Page Fault 带来延迟抖动
addr_ = mmap(nullptr, size_, PROT_READ | PROT_WRITE,
MAP_SHARED | MAP_POPULATE, fd_, 0);
if (addr_ == MAP_FAILED) {
perror("mmap failed");
close(fd_);
fd_ = -1;
return false;
}
return true;
}
void* GetAddr() const { return addr_; }
size_t GetSize() const { return size_; }
~HugePageSharedMemory() {
if (addr_ && addr_ != MAP_FAILED) {
munmap(addr_, size_);
}
if (fd_ >= 0) {
close(fd_);
}
}
private:
std::string name_;
size_t size_;
int fd_;
void* addr_;
};
int main() {
// 申请一个 4MB 的共享大页内存
HugePageSharedMemory shm("hpc_data_channel", 4 * 1024 * 1024);
if (!shm.Initialize(true)) {
return -1;
}
char* ptr = static_cast<char*>(shm.GetAddr());
std::strcpy(ptr, "Hello, HugePages Shared Memory with Zero-Copy!");
std::cout << "Data written to hugepage memory: " << ptr << std::endl;
// 保持进程,方便其他进程读取或通过 pmap 观察
sleep(30);
return 0;
}
方案二:基于 memfd_create 的匿名共享内存(现代 Linux 的优雅解法)
如果你不想在系统里手动创建目录、挂载 hugetlbfs,并且你的共享内存是在**有亲缘关系的进程之间(如父子进程)**使用,或者你可以通过 UNIX Domain Socket 传递文件描述符(FD),那么 memfd_create 是目前最完美的解决方案。
memfd_create 是自 Linux 3.17 引入的系统调用,它能创建一个只存在于内存中的匿名文件,返回一个普通的文件描述符。更关键的是,它原生支持大页标志(自 Linux 4.14 起引入 MFD_HUGETLB)。
1. 核心系统调用参数
MFD_HUGETLB:指示内核使用 HugePages 分配内存。MFD_HUGE_2MB/MFD_HUGE_1GB:指定具体的大页尺寸。
2. C++ 代码实现
#define _GNU_SOURCE
#include <iostream>
#include <sys/mman.h>
#include <unistd.h>
#include <fcntl.h>
#include <cstring>
// 部分旧版本编译环境可能需要手动定义宏
#ifndef MFD_HUGETLB
#define MFD_HUGETLB 0x0004U
#endif
#ifndef MFD_HUGE_2MB
#define MFD_HUGE_2MB (21 << 26) // 21 表示 2^21 = 2MB
#endif
int main() {
size_t shm_size = 2 * 1024 * 1024; // 2MB
// 创建一个支持大页的匿名共享内存文件描述符
int memfd = memfd_create("hpc_memfd_huge", MFD_HUGETLB | MFD_HUGE_2MB | MFD_CLOEXEC);
if (memfd < 0) {
perror("memfd_create failed. Make sure nr_hugepages is configured.");
return -1;
}
// 与 hugetlbfs 类似,memfd_create 配合 MFD_HUGETLB 时,
// 需要使用 ftruncate 来显式截断或初始化大小,且必须对齐大页尺寸
if (ftruncate(memfd, shm_size) < 0) {
perror("ftruncate failed");
close(memfd);
return -1;
}
// 映射内存
void* addr = mmap(nullptr, shm_size, PROT_READ | PROT_WRITE,
MAP_SHARED | MAP_POPULATE, memfd, 0);
if (addr == MAP_FAILED) {
perror("mmap failed");
close(memfd);
return -1;
}
// 此时,你可以通过 fork() 派生子进程,子进程将自动共享此段 mmap 空间
pid_t pid = fork();
if (pid == 0) {
// 子进程读取
sleep(1);
std::cout << "[Child] Read from memfd: " << static_cast<char*>(addr) << std::endl;
munmap(addr, shm_size);
} else if (pid > 0) {
// 父进程写入
std::strcpy(static_cast<char*>(addr), "High Performance C++ IPC via memfd_create");
std::cout << "[Parent] Written to memory." << std::endl;
sleep(2); // 等待子进程读取
munmap(addr, shm_size);
close(memfd); // 最后一个引用关闭后,匿名文件自动释放
} else {
perror("fork failed");
}
return 0;
}
生产环境调优的避坑指南
在将大页共享内存推向生产环境时,必须特别注意以下几点:
1. 内存锁定(mlock)防止被交换
显式 HugePages 默认不会被系统 Swap,但是为了保险起见,或者在混用普通内存和特殊内存的场景下,建议在 mmap 后调用 mlock,锁定物理内存页,防止发生虚惊一场的换出延迟:
if (mlock(addr, size) != 0) {
perror("mlock failed"); // 生产环境通常需要赋予 CAP_IPC_LOCK 权限
}
2. 避免动态分配(避免运行时内存抖动)
在 C++ HPC 服务中,绝不要在交易时段或高负载计算时段动态创建大页共享内存。
- 原因:大页要求物理内存必须是连续的。如果系统运行时间较长,物理内存碎片化严重,即使空闲内存足够,内核在分配 HugePages 时也可能因为找不到连续物理块而陷入剧烈的内存规整(Memory Compaction),这会带来长达数毫秒甚至数秒的阻塞。
- 对策:必须在系统启动初始化阶段一次性分配完毕,并配合
MAP_POPULATE预先完成 Page Fault。
3. 如何在生产环境中监控大页?
如何确信自己的 C++ 程序真的用上了大页,而不是降级为了 4KB 小页?
可以通过查看内核实时统计信息:
watch -n 1 "cat /proc/meminfo | grep -i Huge"
指标解析:
HugePages_Total: 系统预留的大页总数。HugePages_Free: 当前未被占用的空页数。当你的 C++ HPC 程序启动并成功映射后,该数值应当精确减少相应的页数。Hugepagesize: 确认当前系统大页的基本单位(通常为2048 kB)。
还可以通过 perf 工具监控 TLB Miss 降低带来的 CPU 效能提升:
perf stat -e dTLB-loads,dTLB-load-misses ./your_hpc_service
通常,在使用 HugePages 优化后,dTLB-load-misses 的比例会降低 90% 以上,这对于高频串行计算和超大哈希表检索的性能提升极其显著。