C++ CPU 亲和性设置:`sched_setaffinity` 与 `SetThreadAffinityMask` 的高级应用

哈喽,各位好!今天咱们来聊聊C++里那些跟CPU“谈恋爱”的技巧:sched_setaffinitySetThreadAffinityMask。这两个家伙听起来挺高大上,其实就是让你的程序线程指定在哪个CPU核心上跑,说白了就是“霸占”CPU资源!

咱们先从基础说起,然后慢慢深入,最后搞点高级应用。准备好了吗?Let’s go!

一、为啥要搞CPU亲和性?(Why Bother?)

想象一下,你是一个繁忙的厨师(程序),厨房里有很多灶台(CPU核心)。如果你到处乱窜,一会儿用这个灶台,一会儿用那个灶台,是不是效率不高?因为每次切换灶台,你都要搬运食材(数据),还得适应新的温度(缓存)。

CPU亲和性就是让你固定在一个或几个灶台上,减少切换的损耗,提高效率。主要有以下几个好处:

  • 提升性能: 减少上下文切换带来的开销,尤其是在多线程、高并发的场景下效果显著。
  • 降低延迟: 某些对延迟非常敏感的应用(比如实时音视频处理、高性能计算),固定在特定核心上可以减少抖动。
  • 隔离任务: 将不同类型的任务分配到不同的CPU核心上,避免相互干扰。例如,可以将UI线程和计算线程分开,保证UI的流畅性。
  • NUMA优化: 在NUMA(Non-Uniform Memory Access)架构下,将线程分配到靠近其访问内存的CPU核心上,减少内存访问延迟。

二、主角登场:sched_setaffinitySetThreadAffinityMask

这两个函数分别用于Linux/Unix-like系统和Windows系统,用来设置线程的CPU亲和性。

1. sched_setaffinity (Linux/Unix-like)

  • 头文件: #include <sched.h>

  • 函数原型:

    int sched_setaffinity(pid_t pid, size_t cpusetsize, const cpu_set_t *mask);
  • 参数:

    • pid: 线程ID。如果是当前线程,可以传0。
    • cpusetsize: mask的大小,通常是sizeof(cpu_set_t)
    • mask: 一个cpu_set_t类型的位掩码,用来指定允许线程运行的CPU核心。
  • 返回值: 成功返回0,失败返回-1,并设置errno

cpu_set_t 是什么?

cpu_set_t 是一个位图,每一位代表一个CPU核心。例如,如果设置了第0位和第2位,就表示线程可以在CPU 0和CPU 2上运行。

代码示例:

#define _GNU_SOURCE // 必须定义这个宏才能使用CPU_SET等宏
#include <iostream>
#include <sched.h>
#include <pthread.h>
#include <unistd.h>
#include <errno.h>

void* thread_func(void* arg) {
    int core_id = *(int*)arg;
    cpu_set_t cpuset;
    CPU_ZERO(&cpuset); // 初始化cpu_set_t
    CPU_SET(core_id, &cpuset); // 设置指定核心

    pthread_t thread = pthread_self();
    int s = pthread_setaffinity_np(thread, sizeof(cpu_set_t), &cpuset);
    if (s != 0) {
        std::cerr << "pthread_setaffinity_np error: " << strerror(s) << std::endl;
        return nullptr;
    }

    std::cout << "Thread " << pthread_self() << " is running on core " << core_id << std::endl;

    // 模拟一些工作
    for (int i = 0; i < 100000000; ++i) {
        // do something
    }

    return nullptr;
}

int main() {
    pthread_t thread1, thread2;
    int core1 = 0;
    int core2 = 1;

    pthread_create(&thread1, nullptr, thread_func, &core1);
    pthread_create(&thread2, nullptr, thread_func, &core2);

    pthread_join(thread1, nullptr);
    pthread_join(thread2, nullptr);

    return 0;
}

代码解释:

  1. #define _GNU_SOURCE: 这个宏必须定义,才能使用CPU_SET等宏。
  2. CPU_ZERO(&cpuset): 初始化cpu_set_t,将所有位都设置为0。
  3. CPU_SET(core_id, &cpuset): 设置指定核心的位,例如CPU_SET(0, &cpuset)表示允许线程在CPU 0上运行。
  4. pthread_setaffinity_np: 这个函数是pthread库提供的,用来设置线程的CPU亲和性。注意,它不是POSIX标准,所以名字里有个_np(non-portable)。
  5. pthread_self(): 获取当前线程ID。

重要提示:

  • sched_setaffinity 通常需要root权限才能设置其他进程的亲和性。
  • 如果指定的CPU核心不存在,sched_setaffinity会返回错误。
  • 在多线程程序中,最好使用pthread_setaffinity_np来设置线程的亲和性,而不是直接使用sched_setaffinity

2. SetThreadAffinityMask (Windows)

  • 头文件: #include <windows.h>

  • 函数原型:

    DWORD_PTR SetThreadAffinityMask(HANDLE hThread, DWORD_PTR dwThreadAffinityMask);
  • 参数:

    • hThread: 线程句柄。可以用GetCurrentThread()获取当前线程的句柄。
    • dwThreadAffinityMask: 一个位掩码,用来指定允许线程运行的CPU核心。
  • 返回值: 成功返回线程之前的亲和性掩码,失败返回0。

代码示例:

#include <iostream>
#include <windows.h>
#include <process.h>  // For _beginthreadex

unsigned __stdcall thread_func(void* arg) {
    DWORD_PTR mask = (DWORD_PTR)arg;
    DWORD_PTR result = SetThreadAffinityMask(GetCurrentThread(), mask);

    if (result == 0) {
        std::cerr << "SetThreadAffinityMask error: " << GetLastError() << std::endl;
        return 1;
    }

    std::cout << "Thread " << GetCurrentThreadId() << " affinity mask set to: " << mask << std::endl;

    // 模拟一些工作
    for (int i = 0; i < 100000000; ++i) {
        // do something
    }

    return 0;
}

int main() {
    HANDLE thread1, thread2;
    DWORD threadID1, threadID2;
    DWORD_PTR core1Mask = 0x1; // CPU 0
    DWORD_PTR core2Mask = 0x2; // CPU 1

    thread1 = (HANDLE)_beginthreadex(nullptr, 0, thread_func, (void*)core1Mask, 0, (unsigned*)&threadID1);
    thread2 = (HANDLE)_beginthreadex(nullptr, 0, thread_func, (void*)core2Mask, 0, (unsigned*)&threadID2);

    WaitForSingleObject(thread1, INFINITE);
    WaitForSingleObject(thread2, INFINITE);

    CloseHandle(thread1);
    CloseHandle(thread2);

    return 0;
}

代码解释:

  1. GetCurrentThread(): 获取当前线程的句柄。
  2. SetThreadAffinityMask: 设置线程的CPU亲和性掩码。
  3. 0x1 (CPU 0), 0x2 (CPU 1): 使用十六进制表示位掩码。0x1表示二进制00000001,只有第0位是1,所以表示CPU 0。0x2表示二进制00000010,只有第1位是1,所以表示CPU 1。
  4. _beginthreadex: Windows下创建线程的函数。

重要提示:

  • Windows的CPU核心编号从0开始。
  • SetThreadAffinityMask返回的是之前的亲和性掩码,可以用来恢复之前的设置。

三、高级应用:NUMA优化

NUMA(Non-Uniform Memory Access)是一种多处理器架构,其中每个处理器都有自己的本地内存,访问本地内存的速度比访问其他处理器的内存快得多。

问题: 如果线程运行在一个CPU核心上,但频繁访问另一个CPU核心的本地内存,性能会很差。

解决方案: 将线程分配到靠近其访问内存的CPU核心上。

如何确定哪个CPU核心靠近哪个内存?

这取决于你的硬件和操作系统。可以使用一些工具来获取这些信息,例如:

  • Linux: numactl --hardware
  • Windows: GetNumaNodeProcessorMask 函数

示例(Linux):

假设numactl --hardware 输出如下:

available: 2 nodes (0-1)
node 0 cpus: 0 2 4 6
node 0 size: 8117 MB
node 1 cpus: 1 3 5 7
node 1 size: 8192 MB

这表示有两个NUMA节点:

  • 节点0的CPU核心:0, 2, 4, 6
  • 节点1的CPU核心:1, 3, 5, 7

如果你的线程主要访问节点0上的内存,就应该将它分配到CPU核心0, 2, 4, 6中的一个。

代码示例(Linux):

#define _GNU_SOURCE
#include <iostream>
#include <sched.h>
#include <pthread.h>
#include <unistd.h>
#include <errno.h>
#include <numa.h> // 引入numa.h头文件

void* thread_func(void* arg) {
    int numa_node = *(int*)arg;
    cpu_set_t cpuset;
    CPU_ZERO(&cpuset);

    // 获取指定NUMA节点上的CPU核心列表
    unsigned long long nodemask = 1ULL << numa_node; // 创建一个nodemask,指定NUMA节点
    struct bitmask* cpumask = numa_allocate_cpumask(); // 分配一个bitmask来存储CPU核心列表
    if (cpumask == nullptr) {
        std::cerr << "numa_allocate_cpumask error" << std::endl;
        return nullptr;
    }

    if (numa_node_to_cpus(numa_node, cpumask) != 0) {
        std::cerr << "numa_node_to_cpus error" << std::endl;
        numa_free_cpumask(cpumask);
        return nullptr;
    }

    // 遍历NUMA节点上的CPU核心,并添加到cpuset中
    for (int i = 0; i < numa_num_possible_cpus(); ++i) {
        if (numa_bitmask_isbitset(cpumask, i)) {
            CPU_SET(i, &cpuset);
            std::cout << "Adding CPU core " << i << " to cpuset for NUMA node " << numa_node << std::endl;
        }
    }

    numa_free_cpumask(cpumask); // 释放bitmask

    pthread_t thread = pthread_self();
    int s = pthread_setaffinity_np(thread, sizeof(cpu_set_t), &cpuset);
    if (s != 0) {
        std::cerr << "pthread_setaffinity_np error: " << strerror(s) << std::endl;
        return nullptr;
    }

    std::cout << "Thread " << pthread_self() << " is running on NUMA node " << numa_node << std::endl;

    // 模拟一些工作
    for (int i = 0; i < 100000000; ++i) {
        // do something
    }

    return nullptr;
}

int main() {
    if (numa_available() == -1) {
        std::cerr << "NUMA is not available on this system" << std::endl;
        return 1;
    }

    pthread_t thread1, thread2;
    int numa_node1 = 0;
    int numa_node2 = 1;

    pthread_create(&thread1, nullptr, thread_func, &numa_node1);
    pthread_create(&thread2, nullptr, thread_func, &numa_node2);

    pthread_join(thread1, nullptr);
    pthread_join(thread2, nullptr);

    return 0;
}

代码解释:

  1. #include <numa.h>: 引入numa.h头文件,使用libnuma库。需要安装libnuma库。
  2. numa_available(): 检查系统是否支持NUMA。
  3. numa_node_to_cpus(numa_node, cpumask): 获取指定NUMA节点上的CPU核心列表,存储到cpumask中。
  4. numa_bitmask_isbitset(cpumask, i): 检查cpumask中第i位是否被设置,如果被设置,表示CPU核心i属于该NUMA节点。

注意: NUMA优化是一个复杂的话题,需要根据具体的应用场景和硬件环境进行调整。

四、性能测试与注意事项

光说不练假把式,设置了CPU亲和性之后,一定要进行性能测试,看看是否真的有提升。

性能测试工具:

  • Linux: perf, top, htop
  • Windows: Performance Monitor, Resource Monitor

注意事项:

  • 不要过度优化: CPU亲和性不是万能的,过度优化可能会导致其他问题,比如线程饥饿。
  • 考虑任务的特点: 对于IO密集型的任务,CPU亲和性的效果可能不明显。
  • 动态调整: 在某些情况下,可能需要根据系统负载动态调整CPU亲和性。
  • 确保CPU核心数量足够: 如果你的CPU核心数量不够,设置CPU亲和性可能会导致性能下降。
  • 验证亲和性是否生效: 使用工具(例如top)验证线程是否真的运行在指定的CPU核心上。

五、总结

sched_setaffinitySetThreadAffinityMask是C++中设置CPU亲和性的利器。通过合理地使用它们,可以提高程序的性能、降低延迟、隔离任务,并进行NUMA优化。但是,一定要记住,CPU亲和性不是银弹,需要根据具体的应用场景和硬件环境进行调整,并通过性能测试来验证效果。

希望今天的讲解对你有所帮助!记住,编程就像谈恋爱,需要耐心和技巧,才能让你的程序和CPU“幸福地在一起”!下次再见!

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注