20、内存池与NUMA架构:NUMA感知分配、本地内存优先、跨节点访问优化

各位同学,今天我们来聊一个真正考验内存分配器设计功底的话题——NUMA架构下的内存管理。

说实话,我早年做单机高性能服务时,对NUMA这东西并不上心。直到有一次,我在一个48核的服务器上跑一个内存密集型的消息中间件,性能死活上不去。CPU利用率只有30%,但内存延迟高得离谱。后来一查,发现大量内存分配在远端节点上,跨节点访问的代价直接把吞吐量腰斩了。嗯,从那以后,我再也不敢忽视NUMA了。

NUMA架构:为什么你的内存访问会变慢?

先简单回顾一下NUMA(Non-Uniform Memory Access)的基本概念。

在多路服务器中,每个CPU socket有自己的本地内存控制器。访问本地内存,延迟通常在80-120ns;访问远端内存(另一个socket的内存),延迟可能飙升到200-300ns,甚至更高。你想想看,如果分配器随机分配内存,线程跑在CPU0上,却拿到了CPU1的内存页,每次读写都多花一倍的时间,这谁受得了?

核心原则:NUMA感知分配器的目标只有一个——让线程尽可能使用本地内存,减少跨节点访问。

NUMA感知分配的核心策略

我个人习惯把NUMA感知分配拆成三个层次:

  1. 节点绑定分配:分配器知道当前线程运行在哪个NUMA节点上,优先从该节点分配内存。
  2. 内存亲和性:分配的内存页尽量与访问它的线程在同一个节点。
  3. 跨节点回退:本地节点内存不足时,优雅地回退到其他节点,而不是直接失败。

说白了,就是让数据离CPU近一点,再近一点。

代码实现:一个简单的NUMA感知内存池

下面我给出一个简化版的NUMA感知分配器核心代码。注意,这里我用了Linux的libnuma库,实际项目中你可能需要封装一层抽象。

#include <numa.h>
#include <numaif.h>
#include <stdlib.h>
#include <stdio.h>
#include <pthread.h>

// 每个NUMA节点维护一个内存池
typedef struct {
    void *pool_start;
    size_t pool_size;
    size_t used;
    pthread_mutex_t lock;
    int node_id;
} numa_pool_t;

// 全局NUMA池数组
static numa_pool_t *g_pools = NULL;
static int g_num_nodes = 0;

// 初始化NUMA感知内存池
int numa_aware_pool_init(size_t per_node_size) {
    g_num_nodes = numa_num_configured_nodes();
    if (g_num_nodes <= 0) {
        fprintf(stderr, "NUMA not available\n");
        return -1;
    }

    g_pools = calloc(g_num_nodes, sizeof(numa_pool_t));
    if (!g_pools) return -1;

    for (int i = 0; i < g_num_nodes; i++) {
        // 在指定节点上分配内存
        g_pools[i].pool_start = numa_alloc_onnode(per_node_size, i);
        if (!g_pools[i].pool_start) {
            perror("numa_alloc_onnode failed");
            return -1;
        }
        g_pools[i].pool_size = per_node_size;
        g_pools[i].used = 0;
        g_pools[i].node_id = i;
        pthread_mutex_init(&g_pools[i].lock, NULL);
    }
    return 0;
}

// 获取当前线程的NUMA节点ID
static inline int get_current_node(void) {
    int node = -1;
    // 使用getcpu系统调用获取当前CPU和节点
    unsigned cpu, node_tmp;
    if (getcpu(&cpu, &node_tmp) == 0) {
        node = (int)node_tmp;
    }
    return node;
}

// NUMA感知分配:优先本地节点
void *numa_aware_alloc(size_t size) {
    int current_node = get_current_node();
    if (current_node < 0 || current_node >= g_num_nodes) {
        current_node = 0; // 回退到节点0
    }

    // 先尝试本地节点
    void *ptr = try_alloc_from_node(current_node, size);
    if (ptr) return ptr;

    // 本地节点不足,尝试其他节点(跨节点回退)
    for (int i = 0; i < g_num_nodes; i++) {
        int node = (current_node + i) % g_num_nodes;
        if (node == current_node) continue;
        ptr = try_alloc_from_node(node, size);
        if (ptr) {
            fprintf(stderr, "Warning: cross-node allocation from node %d to %d\n",
                    current_node, node);
            return ptr;
        }
    }

    return NULL; // 所有节点都满了
}

// 从指定节点分配(内部函数)
static void *try_alloc_from_node(int node, size_t size) {
    numa_pool_t *pool = &g_pools[node];
    pthread_mutex_lock(&pool->lock);
    if (pool->used + size <= pool->pool_size) {
        void *ptr = (char *)pool->pool_start + pool->used;
        pool->used += size;
        pthread_mutex_unlock(&pool->lock);
        return ptr;
    }
    pthread_mutex_unlock(&pool->lock);
    return NULL;
}

经验之谈:我在实际项目中,通常会在分配器内部维护一个线程局部缓存(TLS),记录每个线程最近使用的NUMA节点。这样避免频繁调用getcpu系统调用,毕竟系统调用也是有开销的。

本地内存优先:不仅仅是分配,还有绑定

分配只是第一步。更关键的是,你要确保分配出去的内存页,在物理上确实落在目标节点上。

我曾经踩过一个坑:用numa_alloc_onnode分配了内存,但后续的mprotectmlock操作导致内核重新迁移了页面。嗯,这里要注意,NUMA感知分配不是一次性的,你需要持续关注内存的物理位置。

常用的绑定手段包括:

  • mbind():设置内存区域的NUMA策略,可以指定绑定到某个节点或节点集。
  • move_pages():显式迁移页面到指定节点,适合动态负载均衡。
  • set_mempolicy():设置进程或线程的默认内存分配策略。

我个人建议,在内存池初始化时,直接使用mbind将整个池子绑定到对应节点,这样后续分配就不需要再操心物理位置了。

跨节点访问优化:当本地内存不够时

现实世界不总是完美的。本地节点内存可能被其他进程占满,或者你的工作集本身就很大。这时候,跨节点访问不可避免。

怎么优化?我总结了几条经验:

  1. 优先使用同一die内的节点:现代CPU通常有多个die,die内跨节点延迟比die间低。分配器应该知道拓扑结构。
  2. 批量迁移:如果发现大量跨节点访问,不要逐页迁移,而是用move_pages批量迁移一个内存区域。
  3. 复制而非共享:对于只读数据,如果多个节点频繁访问,干脆在每个节点复制一份。这就是所谓的“副本优化”。

注意:跨节点访问的代价不仅仅是延迟。它还会占用QPI/UPI总线带宽,影响其他跨节点通信。所以,能避免就避免。

NUMA感知分配器的整体架构

下面我用一张SVG图来展示NUMA感知内存池的核心流程。这张图我画得比较简洁,但涵盖了关键路径。

NUMA感知内存池分配流程 线程 Thread getcpu() 获取当前NUMA节点 本地节点分配 返回内存 本地不足 跨节点回退分配 遍历其他NUMA节点 返回内存 或 NULL

实战中的NUMA调优技巧

最后,分享几个我在生产环境中用过的NUMA调优技巧:

场景 问题 解决方案
多线程共享数据 频繁跨节点访问 在每个节点复制一份只读数据
内存池初始化 页面未绑定到指定节点 使用mbind绑定整个池子
线程迁移 线程被调度到其他节点 使用pthread_setaffinity_np绑定CPU
大页内存 透明大页导致NUMA失衡 关闭透明大页,手动管理大页分配

一个小技巧:我曾经在项目中用numactl --membind来测试不同NUMA策略对性能的影响。先跑一遍默认策略,再跑一遍绑定策略,对比延迟和吞吐量。这个办法简单粗暴,但非常有效。

好了,关于NUMA感知分配的内容就讲到这里。记住,NUMA不是洪水猛兽,只要你的分配器设计得当,它反而能帮你榨干硬件的每一分性能。

公众号:蓝海资料掘金营,微信 deep3321