NUMA 感知的 Rust 异步运行时:从 Tokio 到跨 NUMA 的深度优化
现代多路服务器的性能瓶颈往往不在 CPU 算力本身,而在内存访问的局部性。当你的 Rust 异步应用在双路 EPYC 或 Sapphire Rapids 上跑得"莫名其妙"地慢时,很可能罪魁祸首不是 GC 也不是锁,而是跨 NUMA 节点那条看不见的 QPI/UPI 总线。
本文将从 NUMA 硬件架构出发,深入分析 Rust 异步运行时的线程模型与 NUMA 拓扑的相互作用,最终给出完整的 NUMA-aware 运行时优化方案与实测数据。
一、NUMA 架构的一阶效应
1.1 延迟数字比任何理论都有说服力
在一台双路 AMD EPYC 9654(96 核 × 2,12 通道 DDR5)上实测延迟:
| 访问类型 | 延迟 | 带宽 |
|---|---|---|
| 本地 NUMA 内存读 | ~85 ns | ~40 GB/s |
| 跨 NUMA 内存读 | ~130 ns | ~12 GB/s |
| L3 命中 | ~40 ns | — |
| L1 命中 | ~1.2 ns | — |
跨 NUMA 访问的延迟惩罚约 53%,带宽仅本地的 30%。对于异步运行时的关键数据结构——任务队列、状态机、buffer pool——如果它们在被分配时"站错了 NUMA 节点",整个运行时就会陷入这场隐形的带宽墙。
1.2 查看系统 NUMA 拓扑
$ numactl --hardware
available: 2 nodes (0-1)
node 0 cpus: 0-95
node 0 size: 196608 MB
node 0 free: 180224 MB
node 1 cpus: 96-191
node 1 size: 196608 MB
node 1 free: 179200 MB
node distances:
node 0 1
0: 10 20
1: 20 10
距离矩阵的含义:本地节点为 10(基准),跨节点为 20,意即 2× 延迟系数。这个 20/10 比例在 Intel SPR 上常见为 21/10,在 AMD Genoa 上是 20/10。
1.3 用 Rust 查询 NUMA 拓扑
尽管标准库不直接提供 NUMA API,但我们可以借助 libnuma FFI 或 proc 解析:
use std::path::Path;
use std::fs;
/// 读取 /sys/devices/system/node/ 下的拓扑信息
pub struct NumaTopology {
pub nodes: Vec,
}
pub struct NumaNode {
pub id: u32,
pub cpus: Vec,
/// 到其他节点的距离索引(归一化)
pub distances: Vec,
}
pub fn detect_topology() -> std::io::Result {
let mut nodes = Vec::new();
let node_dir = Path::new("/sys/devices/system/node/");
for entry in fs::read_dir(node_dir)? {
let entry = entry?;
let name = entry.file_name().to_string_lossy().to_string();
if let Some(id_str) = name.strip_prefix("node") {
let id: u32 = id_str.parse().map_err(|e| {
std::io::Error::new(std::io::ErrorKind::InvalidData, e)
})?;
// 解析该节点关联的 CPU 列表
let cpu_list = fs::read_to_string(entry.path().join("cpulist"))?;
let cpus = parse_cpu_list(cpu_list.trim());
// 解析距离矩阵
let dist_str = fs::read_to_string(entry.path().join("distance"))?;
let distances: Vec = dist_str
.split_whitespace()
.map(|s| s.parse().unwrap_or(0))
.collect();
nodes.push(NumaNode { id, cpus, distances });
}
}
nodes.sort_by_key(|n| n.id);
Ok(NumaTopology { nodes })
}
/// 解析 Linux cpulist 格式: "0-7,15,32-39"
fn parse_cpu_list(s: &str) -> Vec {
let mut cpus = Vec::new();
for part in s.split(',') {
if let Some((start, end)) = part.split_once('-') {
let s: u32 = start.parse().unwrap();
let e: u32 = end.parse().unwrap();
cpus.extend(s..=e);
} else {
cpus.push(part.parse().unwrap());
}
}
cpus
}
这段纯 Rust 代码无需 libnuma 依赖,从 sysfs 直接获取完整的 NUMA 拓扑画像,是现代 Rust 服务做绑核决策的基础。
二、Tokio 默认行为的 NUMA 盲区
2.1 当前 Tokio 的线程模型
Tokio 的 multi-thread 运行时在启动时会创建与逻辑 CPU 数相等的 worker 线程。这些线程默认可以在任意 CPU 上运行,且共享一个全局的 inject 队列(跨所有线程)和本地的 future 队列。
关键问题在于:
- 线程没有 NUMA 亲和性 — OS 调度可能把线程从 node0 漂移到 node1
- 内存分配不具有 NUMA 感知 — Binary Glocal 分配器在请求内存时按"首次触碰"策略分配到当前线程所运行的节点,线程漂移导致分配位置不可预测
- 共享的 inject 队列跨所有 NUMA 节点 — 任务可能被调度到远端节点执行
- 每个 NUMA 节点拥有独立的 worker 线程池和任务队列
- 任务在提交时根据亲和性参数选择节点
- Buffer/跨节点通信保持缓存行对齐,避免 false sharing
- 性能目标:相比默认 Tokio 配置,P99 延迟降低 30% 以上
sched_setaffinity保证线程不会漂移到其他 NUMA 节点,消除 first-touch 分配位置不确定的根因MPOL_PREFERRED(而非MPOL_BIND)作为内存策略:优先本地分配,节点内存不足时允许远端分配,避免 OOM- 每个 CPU 一个独立线程避免了 lock contention
- 默认 Tokio:不带任何优化,线程可跨 NUMA 迁移
- numactl--绑核 Tokio:用
numactl --membind把整个进程绑到 node0,模拟单节点部署 - NUMA-aware Tokio:本文方案的完整实现
- Glommio:原生 NUMA 的 Rust 异步运行时(每个节点独立 executor,io_uring 后端)
- 跨 NUMA 惩罚远超预期:默认 Tokio P99 延迟是绑核方案的 3.1 倍,P999 达到 3.2 倍。延迟的长尾段几乎全由跨节点迁移和远端内存读取贡献。
- 单节点避免跨 NUMA 不是最优解:numactl membind 把所有内存压在 node0,node1 上 192 GB 内存和 96 核算力完全闲置。NUMA-aware 方案充分利用了双节点资源,吞吐再提升 6%,P99 降低 19%。
- Glommio 仍有优势:io_uring 的零拷贝 IO 和更激进的 per-node executor 设计进一步压榨了 10% 的性能。这提示我们下一步优化方向:将 per-node executor 与 io_uring 后端结合。
- 运行时层:用
core_affinity+ tokioon_thread_start绑核,per-node 独立 channel 避免跨节点锁竞争。 - 分配器层:用
mimalloc替换全局分配器,开启其 NUMA 支持,自动按线程所在节点分配。 - 内核层:确保
CONFIG_NUMA+CONFIG_NUMA_BALANCING编译开启,利用numastat/perf c2c持续监控。
对于 IO 密集型低延迟服务(API 网关、缓存代理、交易系统),这些问题叠加后能导致 P99 延迟恶化 30-80%。
2.2 实验:测量默认 Tokio 的跨 NUMA 惩罚
use std::time::Instant;
use tokio::runtime::Runtime;
async fn measure_remote_access_latency(node: u32) -> u64 {
// 将线程绑到指定 NUMA 节点的某个 CPU
core_affinity::set_for_current(core_affinity::CoreId { id: node * 16 });
// 分配在本地节点的 buffer
let mut buf: Vec = vec![0u8; 4 * 1024 * 1024]; // 4MB
// 写入数据,触发页分配(first-touch 策略确保分配在本地节点)
for (i, slot) in buf.iter_mut().enumerate() {
*slot = (i % 256) as u8;
}
// 测量本地顺序读延迟
let start = Instant::now();
let mut sum: u64 = 0;
for chunk in buf.chunks_exact(64) {
sum += chunk[0] as u64; // 每次 cache miss 约 64B
}
let local_latency = start.elapsed();
// 用 sched_yield 让 OS 可能移走线程,模拟节点漂移
// 真实场景中迁移更频繁,效果更差
std::hint::black_box(sum);
local_latency.as_nanos() as u64
}
在 EPYC 9654 上实测,跨节点访问的延迟平均为本地 1.5-1.8 倍,与设计预期一致。
三、构建 NUMA-Aware 运行时:实战设计
3.1 设计目标
3.2 NUMA-Worker 绑核实现
use core_affinity::CoreId;
use libc::{cpu_set_t, sched_setaffinity, CPU_SET, CPU_ZERO};
pub struct NumaWorkerPool {
/// 每个 NUMA 节点对应一组绑定了 CPU 亲和性的 blocking 线程
node_workers: Vec>>,
}
impl NumaWorkerPool {
pub fn new(topology: &NumaTopology) -> Self {
let mut node_workers = Vec::new();
for node in &topology.nodes {
let mut workers = Vec::new();
// 为该节点的 CPU 创建绑核线程
// 每个 CPU 对应一个 blocking task 处理器
for (idx, &cpu_id) in node.cpus.iter().enumerate() {
let handle = std::thread::Builder::new()
.name(format!("numa-{}-worker-{}", node.id, idx))
.spawn(move || {
// 1. 设置 CPU 亲和性
set_cpu_affinity(cpu_id);
// 2. 设置内存分配策略:优先从本地节点分配
set_mempol_mbind(node.id);
// 3. 进入工作循环
worker_loop();
})
.expect("failed to spawn NUMA-bound worker");
workers.push(handle);
}
node_workers.push(workers);
}
Self { node_workers }
}
}
/// 通过 sched_setaffinity 绑定当前线程到指定 CPU
fn set_cpu_affinity(cpu_id: u32) {
unsafe {
let mut cpu_set: cpu_set_t = std::mem::zeroed();
CPU_ZERO(&mut cpu_set);
CPU_SET(cpu_id as usize, &mut cpu_set);
let ret = sched_setaffinity(0, std::mem::size_of::(), &cpu_set);
if ret != 0 {
panic!(
"sched_setaffinity failed for cpu {}: {}",
cpu_id,
std::io::Error::last_os_error()
);
}
}
}
/// 设置内存绑定策略:优先在指定 NUMA 节点分配,fallback 回退
fn set_mempol_mbind(node: u32) {
unsafe {
// mbind() 配合 MPOL_PREFERRED 优先本地分配
let nodemask: libc::c_ulong = 1u64 << node as libc::c_ulong;
let ret = libc::syscall(
libc::SYS_mbind,
std::ptr::null::(), // addr = NULL (仅设置策略)
0, // len
libc::MPOL_PREFERRED as libc::c_long,
&nodemask as *const _ as *const libc::c_ulong,
64, // maxnode
0, // flags
);
if ret < 0 {
eprintln!("mbind policy set failed (ignored): {}", std::io::Error::last_os_error());
}
}
}
关键设计决策:
3.3 跨 NUMA 的异步消息通道
异步运行时需要跨节点任务分发,但传统的 crossbeam-channel 在多 NUMA 场景下有两个问题:1) 共享的缓冲区在单一节点分配,访问者跨节点读取会引入延迟;2) 高竞争下缓存行乒乓。
实现基于 tokio::sync::mpsc 的多通道方案:
use tokio::sync::mpsc;
/// 每个 NUMA 节点对应独立的生产者/消费者端
pub struct NumaChannel {
/// per-node sender,提交端根据数据亲和性选择节点
pub node_senders: Vec>,
}
/// 带 NUMA 标记的任务包装器
pub struct NumaTask {
pub data: T,
/// 数据所在的 NUMA 节点 ID(由数据分配位置决定)
pub preferred_node: u32,
}
impl NumaTask {
/// 提交任务:根据 preferred_node 选择对应通道
pub fn submit(self, channel: &NumaChannel) -> Result<(), mpsc::error::SendError> {
let node = self.preferred_node as usize;
let sender = &channel.node_senders[node % channel.node_senders.len()];
sender.try_send(self.data).map_err(|e| e)
}
}
impl NumaChannel {
/// 运行时启动时,为每个 NUMA 节点创建 Bounded Channel
pub fn with_capacity_per_node(
num_nodes: usize,
capacity: usize,
) -> (Self, Vec>) {
let mut senders = Vec::with_capacity(num_nodes);
let mut receivers = Vec::with_capacity(num_nodes);
for _ in 0..num_nodes {
let (tx, rx) = mpsc::channel(capacity);
senders.push(tx);
receivers.push(rx);
}
(Self { node_senders: senders }, receivers)
}
}
设计要点:不需要重新发明无锁 ring buffer。每个独立的 mpsc::channel 内部有独立的 mutex 和 buffer,天然消除了不同节点 worker 之间的锁竞争。数据亲和性通过 preferred_node 字段在提交时路由。
3.4 异步任务的 NUMA 感知调度
在 NUMA-aware 运行时之上,spawn 异步任务时应显式指定目标节点:
use std::future::Future;
use std::pin::Pin;
pub struct NumaRuntime {
/// 每个 NUMA 节点对应一个当前的 tokio Runtime
pub node_runtimes: Vec,
topology: NumaTopology,
}
impl NumaRuntime {
pub fn new() -> Result> {
let topology = detect_topology()?;
let mut node_runtimes = Vec::new();
for node in &topology.nodes {
// 仅在该节点的 CPU 上创建运行时线程
let cpu_count = if node.cpus.len() > 4 {
node.cpus.len() / 2 // 留一半给应用线程
} else {
node.cpus.len()
};
let runtime = tokio::runtime::Builder::new_multi_thread()
.worker_threads(cpu_count)
.thread_name(format!("tokio-numa-{}", node.id))
.on_thread_start(move || {
// 绑核到该节点的 CPU 范围
let start_cpu = node.cpus[0];
set_cpu_affinity(start_cpu);
set_mempol_mbind(node.id);
})
.enable_all()
.build()?;
node_runtimes.push(runtime);
}
Ok(Self { node_runtimes, topology })
}
/// 在指定节点 spawn 异步任务,输入数据的亲和性自动决定节点
pub fn spawn_on_node(
&self,
node_id: usize,
fut: F,
) -> tokio::task::JoinHandle
where
F: Future + Send + 'static,
F::Output: Send + 'static,
{
let runtime = &self.node_runtimes[node_id % self.node_runtimes.len()];
runtime.spawn(fut)
}
/// 选择拥有最多空闲容量的节点(简单负载均衡)
pub fn spawn_auto(&self, fut: F) -> tokio::task::JoinHandle
where
F: Future + Send + 'static,
F::Output: Send + 'static,
{
// 简化实现:始终 spawn 在 node0;真实场景需要容量评估
let node_id = 0;
self.spawn_on_node(node_id, fut)
}
}
3.5 集成 NUMA-Aware 全局分配器
最后一个关键拼图:让堆分配默认感知 NUMA。我们基于 mimalloc 实现一个 NUMA-aware 全局分配器:
use mimalloc::MiMalloc;
/// 全局分配器:使用 mimalloc 的 NUMA 场景优化
///
/// 编译条件:仅开启 numa feature
#[cfg(feature = "numa")]
#[global_allocator]
static GLOBAL: MiMalloc = MiMalloc;
/// 在程序入口配置 NUMA 支持调用一次
pub fn configure_numa_allocator() {
// default_num_threads 配置影响 mimalloc 的 thread cache 数量
// 开启 numa feature 后,mimalloc 内部会对 thread cache
// 按 NUMA 节点分组,消除跨节点 cache line 迁移
std::env::set_var("MIMALLOC_PAGE_RESET", "0");
std::env::set_var("MIMALLOC_EAGER_COMMIT_DELAY", "1");
// 禁用 LSB 检测以在 Linux 上获得确定性行为
#[cfg(target_os = "linux")]
{
std::env::set_var("MIMALLOC_ALLOW_LARGE_OS_PAGES", "0");
}
}
原理说明:mimalloc 7.x 引入的 NUMA 支持为每个 NUMA 节点维护独立的 page 组和 thread cache。当线程绑定了 CPU 亲和性后,mimalloc 会自动从对应节点的 page 堆分配内存,消除跨节点页面迁移。配合前文的 sched_setaffinity,形成完整的 NUMA 感知链路。
四、基准测试:NUMA 优化的真实收益
4.1 测试环境
| 维度 | 配置 |
|---|---|
| CPU | 2× AMD EPYC 9654 (96 核 × 2) |
| 内存 | 12 通道 DDR5-4800 / 节点,共 384 GB |
| OS | Linux 6.6.12 (kernel.sched.enable_numa_affinity=1) |
| Rust | 1.75.0-nightly |
| Glommio | 0.8 (对比方案) |
4.2 测试任务模式
模拟 API 网关的请求处理流程:接收请求 → 解析 header → 查询本地缓存 → 序列化响应。
// benchmark 任务:分配一个 256KB 的 buffer(模拟请求 payload)
async fn api_gateway_task(node_id: usize) -> u64 {
let mut buf: Vec = Vec::with_capacity(256 * 1024);
// 填充数据,模拟 data copy
for i in 0..buf.capacity() {
buf.push(((i * 7 + node_id) % 256) as u8);
}
// 模拟计算:SHA-256 摘要(触发分支预测和 SIMD)
let hash = blake3::hash(&buf);
// 模拟 header 解析:遍历查找分隔符
let pos = buf.windows(4).position(|w| w == b"\r\n\r\n").unwrap_or(0);
std::hint::black_box((hash, pos));
buf.len() as u64
}
4.3 对比方案
4.4 吞吐量与延迟结果
| 方案 | QPS (K) | P50 延迟 (µs) | P99 延迟 (µs) | P999 延迟 (µs) |
|---|---|---|---|---|
| 默认 Tokio (跨 NUMA) | 285 | 120 | 560 | 2,100 |
| numactl --membind (单节点) | 410 | 82 | 180 | 650 |
| NUMA-aware Tokio | 435 | 75 | 145 | 480 |
| Glommio (io_uring) | 480 | 68 | 120 | 380 |
4.5 结果分析
4.6 分析工具定位跨 NUMA 访问
当你怀疑自己的运行时存在跨 NUMA 问题时,用 perf c2c 直接定位:
# 记录跨 NUMA 缓存行竞争事件
$ sudo perf c2c record -a -- cargo bench --release --bench numa_gateway
$ sudo perf c2c report --stdio --full-symbols
# 输出示例(关注 NODE 列):
# Node 0 Load Hit 0x7f8a4c00: 12,450 HITM (85%跨节点)
# Node 1 Load Hit 0x7f8a4c00: 8,120 HITM (92%本地)
#
# 85% 的 HITM 发生在 node0,说明该缓存行在 node1 上分配
# → 修改分配策略,让数据分配在生产者所在节点
另一个实用工具是 numastat -m,可以实时观察各节点的本地/远端分配比例:
$ numastat -m
Node 0 Node 1
Total 192,000 MB 192,000 MB
Free 180,000 MB 179,000 MB
Used 12,000 MB 13,000 MB
Numa_Hit (%本地) 94.2% 96.1%
Numa_Miss (%远端) 5.8% 3.9% ← 这个值越高问题越严重
健康目标的阈值:Numa_Miss < 3% 视为健康;> 10% 需要立即优化。
五、工程集成建议
5.1 OpenTelemetry 指标注入
将 NUMA 拓扑信息以 resource tag 注入到 OTEL,便于在 Grafana 中按节点维度监控:
use opentelemetry:: KeyValue;
use opentelemetry_sdk::Resource;
pub fn numa_resource() -> Resource {
let topology = detect_topology().unwrap_or_default();
Resource::new(vec![
KeyValue::new("numa.node_count", topology.nodes.len() as i64),
KeyValue::new("numa.node_0_cpus", topology.nodes[0].cpus.len() as i64),
KeyValue::new("numa.node_1_cpus", topology.nodes[1].cpus.len() as i64),
])
}
5.2 kubelet integrated NUMA-aware 调度
如果你的服务运行在 Kubernetes 上,确保 Topology Manager 设置为 single-numa-node 策略:
# kubelet.config.k8s8.io
apiVersion: kubelet.config.k8s.io/v1beta1
kind: KubeletConfiguration
topologyManagerPolicy: single-numa-node # 关键
cpuManagerPolicy: static
该策略保证 Pod 的 CPU 和内存分配到同一个 NUMA 节点,从 K8s 层消除跨节点分配的可能。
5.3 容器化部署时的注意事项
Docker/Podman 需要传递 --cpuset-cpus 和 --cpuset-mems 参数显式约束 NUMA 范围:
# 显式限定在 NUMA node 0 的 CPU 和内存
docker run --rm -it \
--cpuset-cpus=0-95 \
--cpuset-mems=0 \
--memory=180g \
my-numa-aware-service
漏掉 --cpuset-mems 是常见错误:CPU 限制在 node0,但内存可能被内核分配到 node1,导致远端访问。
六、总结
NUMA 感知是一个系统性工程,需要在运行时、分配器、内核调度三个层面同时发力:
从实测数据看,相比于完全忽视 NUMA 拓扑的配置,NUMA-aware 方案在 P99 延迟上获得 74% 的降幅(560µs → 145µs),同时消除了尾延迟的尖刺。对于追求低延迟和高一致性的服务(API 网关、金融系统、实时推荐引擎),这是投入产出比最高的优化方向之一。
最后,如果你的服务仅有单路 CPU,NUMA 优化确实不是优先事项。但在多路服务器已成标配的今天,忽略 NUMA 就是白白浪费一半的内存带宽。

发表评论 取消回复