HOOOS

Linux C++ 高性能服务器如何用 HugePages 优化共享内存

0 52 极客研发官 Linux高性能计算
Apple

在低延迟、高吞吐的 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 文件系统,只要我们将大页文件系统挂载到指定目录,然后使用标准的 openmmap 替代 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% 以上,这对于高频串行计算和超大哈希表检索的性能提升极其显著。

点评评价

captcha
健康