NUMA 即非统一内存访问架构,是现代多处理器服务器的主流硬件设计,其核心特点是每个处理器拥有本地内存,访问本地内存的速度远快于访问其他处理器的远程内存。C# 应用运行在依托 NUMA 架构的硬件上时,如果忽略内存和线程的节点分配,很容易出现大量远程内存访问,导致性能下降。理解 NUMA 架构的特性并结合 C# 的相关 API 进行调优,是提升高负载应用性能的重要手段。

NUMA 架构基础原理
NUMA 架构将服务器划分为多个节点,每个节点包含若干个 CPU 核心和对应的本地内存。节点内的 CPU 访问本地内存的延迟通常在几十纳秒,而访问其他节点的远程内存延迟可能增加一倍甚至更多。当 C# 应用分配的内存跨节点访问时,不仅会增加内存访问耗时,还会占用节点间的互联总线带宽,影响整体性能。
在 Windows 系统中,可以通过 PowerShell 命令 Get-NumaNode 查看当前服务器的 NUMA 节点信息,包括每个节点的 CPU 核心范围和内存大小。在 Linux 系统中,可以通过 numactl -H 命令查看相关信息。
C# 中 NUMA 相关的系统 API 调用
.NET 本身没有内置直接的 NUMA 管理 API,但可以通过调用系统层面的原生 API 来实现 NUMA 相关的调优操作。以下是 Windows 系统中常用的 NUMA 相关 API 的 C# 调用示例。
获取 NUMA 节点信息
首先需要通过 P/Invoke 声明获取 NUMA 节点信息的原生函数,代码如下:
using System;
using System.Runtime.InteropServices;
public class NumaHelper
{
// 获取当前系统的 NUMA 节点数量
[DllImport("kernel32.dll", SetLastError = true)]
private static extern ushort GetNumaHighestNodeNumber(out ulong highestNodeNumber);
// 获取指定节点对应的处理器掩码
[DllImport("kernel32.dll", SetLastError = true)]
private static extern bool GetNumaNodeProcessorMask(ushort node, out ulong processorMask);
// 获取指定进程当前运行的 NUMA 节点
[DllImport("kernel32.dll", SetLastError = true)]
private static extern bool GetProcessNumaNode(IntPtr processHandle, out ushort nodeNumber);
// 设置当前线程的首选 NUMA 节点
[DllImport("kernel32.dll", SetLastError = true)]
private static extern bool SetThreadIdealProcessorEx(IntPtr threadHandle, ref PROCESSOR_NUMBER processorNumber, IntPtr previousProcessorNumber);
[StructLayout(LayoutKind.Sequential)]
public struct PROCESSOR_NUMBER
{
public ushort Group;
public byte Number;
public byte Reserved;
}
// 获取 NUMA 节点总数
public static ulong GetNumaNodeCount()
{
if (GetNumaHighestNodeNumber(out ulong highestNode))
{
// 节点编号从0开始,所以总数为最高编号+1
return highestNode + 1;
}
return 0;
}
// 获取指定节点的 CPU 核心掩码
public static ulong GetNodeProcessorMask(ushort node)
{
if (GetNumaNodeProcessorMask(node, out ulong mask))
{
return mask;
}
return 0;
}
}
设置进程和线程的 NUMA 亲和性
进程亲和性决定了进程可以在哪些 CPU 核心上运行,将进程绑定到同一个 NUMA 节点的核心上,可以减少远程内存访问。以下是设置进程亲和性的代码示例:
using System;
using System.Diagnostics;
using System.Runtime.InteropServices;
public class ProcessAffinityHelper
{
// 设置进程亲和性,processorMask 为 CPU 核心掩码
[DllImport("kernel32.dll", SetLastError = true)]
private static extern bool SetProcessAffinityMask(IntPtr processHandle, IntPtr processorMask);
// 获取当前进程的句柄
public static IntPtr GetCurrentProcessHandle()
{
return Process.GetCurrentProcess().Handle;
}
// 将当前进程绑定到指定 NUMA 节点的 CPU 核心
public static bool BindProcessToNumaNode(ushort node)
{
ulong processorMask = NumaHelper.GetNodeProcessorMask(node);
if (processorMask == 0)
{
return false;
}
IntPtr handle = GetCurrentProcessHandle();
// 将 ulong 转换为 IntPtr,注意 32 位和 64 位系统的差异
IntPtr mask = (IntPtr)processorMask;
return SetProcessAffinityMask(handle, mask);
}
}
内存分配优化策略
NUMA 架构下内存分配的优化核心是尽量让内存分配在进程运行的本地节点。C# 的托管内存分配由 CLR 的垃圾回收器管理,默认情况下不会特意考虑 NUMA 节点,但可以通过以下方式间接优化:
- 尽量在应用启动时预分配核心业务所需的大对象,减少运行时的跨节点内存分配
- 对于需要频繁访问的大数组,可以使用
GC.TryStartNoGCRegion方法在指定区域分配,减少 GC 带来的内存移动 - 如果使用的是 .NET 5 及以上版本,可以开启
System.GC.NumaAware配置,让 GC 在分配内存时考虑 NUMA 节点,该配置默认在服务器 GC 模式下开启
以下是一个预分配大对象的示例,将数组分配在指定节点的本地内存中:
public class MemoryOptimizeHelper
{
// 预分配指定大小的字节数组,尽量分配在当前线程所在的 NUMA 节点
public static byte[] PreAllocateLargeArray(int size)
{
// 先绑定当前线程到目标 NUMA 节点,再分配内存
// 假设目标节点为 0
ushort targetNode = 0;
// 设置当前线程的首选处理器为节点 0 的第一个核心
NumaHelper.PROCESSOR_NUMBER processorNumber = new NumaHelper.PROCESSOR_NUMBER
{
Group = 0,
Number = 0, // 对应节点 0 的第一个核心
Reserved = 0
};
// 获取当前线程句柄
IntPtr threadHandle = System.Diagnostics.Process.GetCurrentProcess().Threads[0].ThreadHandle;
NumaHelper.SetThreadIdealProcessorEx(threadHandle, ref processorNumber, IntPtr.Zero);
// 分配大数组,此时内存大概率分配在节点 0 的本地内存
return new byte[size];
}
}
线程调度优化
线程的运行位置直接影响内存访问的效率,将线程绑定到对应的 NUMA 节点核心,可以让线程优先访问本地内存。除了前面提到的设置线程首选处理器,还可以通过以下方式优化:
- 为每个 NUMA 节点创建独立的工作线程池,线程处理的数据尽量预分配在该节点的内存中
- 避免线程在不同 NUMA 节点的核心之间频繁迁移,减少缓存失效和远程内存访问
- 对于计算密集型任务,将任务拆分到不同 NUMA 节点,每个节点处理本地内存中的数据
以下是一个简单的 NUMA 感知线程池示例:
using System;
using System.Collections.Generic;
using System.Threading;
public class NumaAwareThreadPool
{
private Dictionary<ushort, List<Thread>> nodeThreads = new Dictionary<ushort, List<Thread>>();
// 初始化线程池,为每个 NUMA 节点创建指定数量的工作线程
public void Initialize(ushort threadCountPerNode)
{
ulong nodeCount = NumaHelper.GetNumaNodeCount();
for (ushort node = 0; node < nodeCount; node++)
{
List<Thread> threads = new List<Thread>();
for (int i = 0; i < threadCountPerNode; i++)
{
Thread thread = new Thread(WorkerMethod);
thread.Start(node);
threads.Add(thread);
}
nodeThreads[node] = threads;
}
}
// 工作线程方法,接收对应的 NUMA 节点编号
private void WorkerMethod(object obj)
{
ushort node = (ushort)obj;
// 绑定线程到对应 NUMA 节点的核心
ulong processorMask = NumaHelper.GetNodeProcessorMask(node);
// 这里省略线程亲和性设置的具体代码,可参考前面的示例
// 线程循环处理该节点对应的本地任务
while (true)
{
// 处理任务逻辑
Thread.Sleep(100);
}
}
}
调优效果验证
完成 NUMA 调优后,需要验证调优效果。可以通过以下方式对比调优前后的性能:
- 使用性能计数器监控应用的 CPU 使用率、内存访问延迟
- 通过
numactl --hardware(Linux)或性能监视器(Windows)查看节点的内存访问分布,确认远程内存访问占比是否下降 - 运行核心业务场景的压测,对比调优前后的吞吐量、响应时间
实际测试表明,对于内存访问密集型的 C# 应用,合理的 NUMA 调优可以带来 10% 到 30% 的性能提升,尤其是在高并发、大数据量处理的场景下效果更为明显。