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感知分配拆成三个层次:
- 节点绑定分配:分配器知道当前线程运行在哪个NUMA节点上,优先从该节点分配内存。
- 内存亲和性:分配的内存页尽量与访问它的线程在同一个节点。
- 跨节点回退:本地节点内存不足时,优雅地回退到其他节点,而不是直接失败。
说白了,就是让数据离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分配了内存,但后续的mprotect或mlock操作导致内核重新迁移了页面。嗯,这里要注意,NUMA感知分配不是一次性的,你需要持续关注内存的物理位置。
常用的绑定手段包括:
- mbind():设置内存区域的NUMA策略,可以指定绑定到某个节点或节点集。
- move_pages():显式迁移页面到指定节点,适合动态负载均衡。
- set_mempolicy():设置进程或线程的默认内存分配策略。
我个人建议,在内存池初始化时,直接使用mbind将整个池子绑定到对应节点,这样后续分配就不需要再操心物理位置了。
跨节点访问优化:当本地内存不够时
现实世界不总是完美的。本地节点内存可能被其他进程占满,或者你的工作集本身就很大。这时候,跨节点访问不可避免。
怎么优化?我总结了几条经验:
- 优先使用同一die内的节点:现代CPU通常有多个die,die内跨节点延迟比die间低。分配器应该知道拓扑结构。
- 批量迁移:如果发现大量跨节点访问,不要逐页迁移,而是用
move_pages批量迁移一个内存区域。 - 复制而非共享:对于只读数据,如果多个节点频繁访问,干脆在每个节点复制一份。这就是所谓的“副本优化”。
注意:跨节点访问的代价不仅仅是延迟。它还会占用QPI/UPI总线带宽,影响其他跨节点通信。所以,能避免就避免。
NUMA感知分配器的整体架构
下面我用一张SVG图来展示NUMA感知内存池的核心流程。这张图我画得比较简洁,但涵盖了关键路径。
实战中的NUMA调优技巧
最后,分享几个我在生产环境中用过的NUMA调优技巧:
| 场景 | 问题 | 解决方案 |
|---|---|---|
| 多线程共享数据 | 频繁跨节点访问 | 在每个节点复制一份只读数据 |
| 内存池初始化 | 页面未绑定到指定节点 | 使用mbind绑定整个池子 |
| 线程迁移 | 线程被调度到其他节点 | 使用pthread_setaffinity_np绑定CPU |
| 大页内存 | 透明大页导致NUMA失衡 | 关闭透明大页,手动管理大页分配 |
一个小技巧:我曾经在项目中用numactl --membind来测试不同NUMA策略对性能的影响。先跑一遍默认策略,再跑一遍绑定策略,对比延迟和吞吐量。这个办法简单粗暴,但非常有效。
好了,关于NUMA感知分配的内容就讲到这里。记住,NUMA不是洪水猛兽,只要你的分配器设计得当,它反而能帮你榨干硬件的每一分性能。