C++ CPU 亲和性设置:将线程绑定到特定 CPU 核

各位好,欢迎来到今天的“C++ CPU 亲和性:让你的线程找到真爱”讲座。今天我们要聊聊一个听起来高深莫测,但实际上非常实用的小技巧:CPU 亲和性。

什么是CPU亲和性?

简单来说,CPU亲和性就是让你的线程或进程“爱上”某个特定的CPU核心。默认情况下,操作系统会尽力均衡各个核心的负载,线程可能会在不同的核心之间跳来跳去。这就像一个花心的家伙,一会儿喜欢这个,一会儿喜欢那个,最终导致性能下降(因为缓存失效)。

CPU亲和性就像是给线程找了个“真爱”,告诉它:“你就待在这个核心里,别乱跑了!” 这样可以减少线程在不同核心之间迁移的次数,提高缓存命中率,从而提升性能。

为什么要设置CPU亲和性?

想象一下,你正在玩一个大型游戏。游戏需要大量的计算,而这些计算被分配到多个线程上。如果没有设置CPU亲和性,这些线程可能会在不同的CPU核心上运行。

  • 缓存失效: 当线程从一个核心迁移到另一个核心时,之前核心上的缓存数据就失效了,需要重新加载。这会增加延迟,降低性能。
  • NUMA问题: 在NUMA(Non-Uniform Memory Access)架构的系统中,访问本地内存比访问远程内存更快。如果线程在不同的NUMA节点之间迁移,会增加内存访问延迟。
  • 资源竞争: 当多个线程竞争同一个资源时,如果它们在不同的核心上运行,可能会增加锁的竞争,导致性能下降。

设置CPU亲和性可以解决这些问题,让线程稳定地在一个核心上运行,提高缓存命中率,减少内存访问延迟,降低资源竞争。

如何设置CPU亲和性?

C++本身并没有直接设置CPU亲和性的功能,我们需要借助操作系统提供的API。不同的操作系统有不同的API,下面我们分别介绍Linux和Windows下的设置方法。

1. Linux下的CPU亲和性设置

在Linux下,我们可以使用sched_setaffinity函数来设置线程的CPU亲和性。

  • 头文件: <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核心。
  • cpu_set_t类型: 这是一个位掩码,每一位代表一个CPU核心。如果某一位被设置为1,表示允许线程在该核心上运行;如果设置为0,表示不允许。

  • 相关宏:

    • CPU_SET(cpu, &cpuset): 将cpuset的第cpu位设置为1。
    • CPU_CLR(cpu, &cpuset): 将cpuset的第cpu位设置为0。
    • CPU_ZERO(&cpuset): 将cpuset的所有位设置为0。
    • CPU_COUNT(&cpuset): 返回cpuset中被设置为1的位的数量。
    • CPU_ISSET(cpu, &cpuset): 检查cpuset的第cpu位是否被设置为1。

示例代码:

#define _GNU_SOURCE
#include <iostream>
#include <pthread.h>
#include <sched.h>
#include <unistd.h>
#include <sys/syscall.h> // For gettid()

using namespace std;

// 获取线程ID,兼容不同系统
pid_t get_thread_id() {
#ifdef __linux__
    return syscall(SYS_gettid);
#else
    return pthread_self();
#endif
}

void* thread_func(void* arg) {
    int cpu_id = *(int*)arg;
    cpu_set_t cpuset;
    CPU_ZERO(&cpuset);
    CPU_SET(cpu_id, &cpuset);

    pthread_t current_thread = pthread_self();

    int rc = pthread_setaffinity_np(current_thread, sizeof(cpu_set_t), &cpuset);
    if (rc != 0) {
        cerr << "Error setting affinity for thread " << get_thread_id() << ": " << rc << endl;
        return NULL;
    }

    cout << "Thread " << get_thread_id() << " is running on CPU " << cpu_id << endl;

    // 模拟一些工作
    for (int i = 0; i < 100000000; ++i) {
        // 啥也不干,就消耗CPU
    }

    return NULL;
}

int main() {
    pthread_t threads[4];
    int cpu_ids[4] = {0, 1, 2, 3};

    for (int i = 0; i < 4; ++i) {
        int rc = pthread_create(&threads[i], NULL, thread_func, &cpu_ids[i]);
        if (rc != 0) {
            cerr << "Error creating thread " << i << ": " << rc << endl;
            return 1;
        }
    }

    for (int i = 0; i < 4; ++i) {
        pthread_join(threads[i], NULL);
    }

    return 0;
}

代码解释:

  1. #define _GNU_SOURCE: 这个宏定义是必需的,因为它启用了sched.h中的某些功能,比如pthread_setaffinity_np

  2. pthread_setaffinity_np: 这个函数是POSIX线程库提供的,用于设置线程的CPU亲和性。它的参数和sched_setaffinity类似,但是它接受的是pthread_t类型的线程ID,而不是进程ID。

  3. get_thread_id(): 这个函数用于获取当前线程的ID,在Linux下使用syscall(SYS_gettid),在其他系统下使用pthread_self()

  4. cpu_set_t: 这是一个位掩码,用于指定允许线程运行的CPU核心。我们使用CPU_ZERO宏将其初始化为0,然后使用CPU_SET宏将指定的CPU核心对应的位设置为1。

  5. 错误处理: 代码中加入了错误处理,当设置CPU亲和性失败时,会打印错误信息。

  6. 模拟工作: 在线程函数中,我们模拟了一些工作,让线程占用CPU一段时间,以便观察CPU亲和性的效果。

编译和运行:

g++ -o affinity affinity.cpp -pthread
./affinity

运行结果应该类似于:

Thread 140737353246720 is running on CPU 0
Thread 140737353254912 is running on CPU 1
Thread 140737353263104 is running on CPU 2
Thread 140737353271296 is running on CPU 3

你可以使用tophtop命令来观察CPU的使用情况,你会发现每个线程都运行在指定的CPU核心上。

2. Windows下的CPU亲和性设置

在Windows下,我们可以使用SetThreadAffinityMask函数来设置线程的CPU亲和性。

  • 头文件: <windows.h>

  • 函数原型:

    DWORD_PTR SetThreadAffinityMask(HANDLE hThread, DWORD_PTR dwThreadAffinityMask);
    • hThread: 线程句柄。可以使用GetCurrentThread()函数获取当前线程的句柄。
    • dwThreadAffinityMask: 一个位掩码,用于指定允许线程运行的CPU核心。
  • 位掩码: 和Linux类似,Windows也使用位掩码来表示允许线程运行的CPU核心。每一位代表一个CPU核心。如果某一位被设置为1,表示允许线程在该核心上运行;如果设置为0,表示不允许。

示例代码:

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

using namespace std;

unsigned int __stdcall thread_func(void* arg) {
    DWORD_PTR cpu_mask = (DWORD_PTR)(1ULL << *(int*)arg); // 1ULL ensures 64-bit integer
    HANDLE current_thread = GetCurrentThread();

    if (SetThreadAffinityMask(current_thread, cpu_mask) == 0) {
        cerr << "Error setting affinity: " << GetLastError() << endl;
        return 1;
    }

    cout << "Thread is running on CPU " << *(int*)arg << endl;

    // 模拟一些工作
    for (int i = 0; i < 100000000; ++i) {
        // 啥也不干,就消耗CPU
    }

    return 0;
}

int main() {
    HANDLE threads[4];
    int cpu_ids[4] = {0, 1, 2, 3};

    for (int i = 0; i < 4; ++i) {
        threads[i] = (HANDLE)_beginthreadex(NULL, 0, thread_func, &cpu_ids[i], 0, NULL);
        if (threads[i] == NULL) {
            cerr << "Error creating thread " << i << ": " << GetLastError() << endl;
            return 1;
        }
    }

    WaitForMultipleObjects(4, threads, TRUE, INFINITE);

    for (int i = 0; i < 4; ++i) {
        CloseHandle(threads[i]);
    }

    return 0;
}

代码解释:

  1. 1ULL << *(int*)arg: 这行代码将CPU核心的ID转换为位掩码。1ULL表示一个无符号长长整型常量1,然后左移*(int*)arg位,得到一个只有指定CPU核心对应的位为1的位掩码。 确保使用64位整数,避免核心数量过多导致溢出。

  2. GetCurrentThread(): 这个函数用于获取当前线程的句柄。

  3. SetThreadAffinityMask(): 这个函数用于设置线程的CPU亲和性。

  4. _beginthreadex(): 这个函数是Windows提供的创建线程的函数。

  5. WaitForMultipleObjects(): 这个函数用于等待所有线程结束。

  6. CloseHandle(): 这个函数用于关闭线程句柄。

  7. 错误处理: 代码中加入了错误处理,当设置CPU亲和性失败时,会打印错误信息。

  8. 模拟工作: 在线程函数中,我们模拟了一些工作,让线程占用CPU一段时间,以便观察CPU亲和性的效果。

编译和运行:

使用Visual Studio编译即可。

运行结果应该类似于:

Thread is running on CPU 0
Thread is running on CPU 1
Thread is running on CPU 2
Thread is running on CPU 3

你可以使用任务管理器来观察CPU的使用情况,你会发现每个线程都运行在指定的CPU核心上。

注意事项:

  • 核心数量: 在设置CPU亲和性之前,需要先确定系统的CPU核心数量。可以使用std::thread::hardware_concurrency()函数来获取系统的CPU核心数量。

  • 超线程: 超线程技术可以将一个物理核心模拟成两个逻辑核心。在设置CPU亲和性时,需要注意区分物理核心和逻辑核心。通常情况下,应该将线程绑定到不同的物理核心上,而不是同一个物理核心的两个逻辑核心上。

  • NUMA架构: 在NUMA架构的系统中,应该将线程绑定到与线程访问的内存区域相同的NUMA节点上,以减少内存访问延迟。

  • 不要过度限制: 不要过度限制线程的CPU亲和性。如果将所有线程都绑定到同一个核心上,会导致该核心负载过重,而其他核心空闲,反而会降低性能。

  • 测试: 设置CPU亲和性后,一定要进行充分的测试,以确保性能得到提升,而不是下降。

案例分析:优化图像处理应用

假设我们有一个图像处理应用,需要对大量的图像进行处理。图像处理的过程可以分解为多个独立的任务,每个任务可以分配给一个线程来执行。

如果没有设置CPU亲和性,这些线程可能会在不同的CPU核心上运行,导致缓存失效和NUMA问题。为了优化性能,我们可以将每个线程绑定到一个特定的CPU核心上。

// 假设我们有8个CPU核心
const int NUM_CORES = 8;

// 线程函数,处理图像
void* process_image(void* arg) {
    int image_index = *(int*)arg;
    int cpu_id = image_index % NUM_CORES; // 将图像索引映射到CPU核心

    // 设置CPU亲和性 (Linux)
    cpu_set_t cpuset;
    CPU_ZERO(&cpuset);
    CPU_SET(cpu_id, &cpuset);
    pthread_t current_thread = pthread_self();
    pthread_setaffinity_np(current_thread, sizeof(cpu_set_t), &cpuset);

    cout << "Thread processing image " << image_index << " is running on CPU " << cpu_id << endl;

    // 实际的图像处理代码
    // ...

    return NULL;
}

int main() {
    // 假设我们有16张图像需要处理
    const int NUM_IMAGES = 16;
    pthread_t threads[NUM_IMAGES];
    int image_indices[NUM_IMAGES];

    // 创建线程
    for (int i = 0; i < NUM_IMAGES; ++i) {
        image_indices[i] = i;
        pthread_create(&threads[i], NULL, process_image, &image_indices[i]);
    }

    // 等待线程结束
    for (int i = 0; i < NUM_IMAGES; ++i) {
        pthread_join(threads[i], NULL);
    }

    return 0;
}

在这个例子中,我们将每个图像处理任务分配给一个线程,并将线程绑定到一个特定的CPU核心上。我们使用图像索引对CPU核心数量取模,将图像均匀地分配到各个核心上。

通过设置CPU亲和性,我们可以提高缓存命中率,减少内存访问延迟,从而加速图像处理过程。

总结

CPU亲和性是一个强大的工具,可以用来优化多线程应用程序的性能。但是,它并不是万能的。在设置CPU亲和性之前,需要仔细分析应用程序的特点,并进行充分的测试,以确保性能得到提升。

记住,CPU亲和性就像婚姻,找对了就是幸福,找错了就是痛苦。所以,在给你的线程找“真爱”之前,一定要慎重考虑!

进阶话题:NUMA感知的CPU亲和性设置

在NUMA架构的系统中,更精细的控制可以带来更好的性能。我们需要考虑将线程绑定到与线程访问的内存区域相同的NUMA节点上。

  • numactl 工具 (Linux): Linux下可以使用numactl工具来运行程序,并指定程序使用的NUMA节点。这可以确保程序分配的内存位于指定的NUMA节点上。

    numactl --cpunodebind=0 --membind=0 ./my_program

    这条命令将my_program绑定到NUMA节点0,并确保程序分配的所有内存都位于NUMA节点0上。

  • GetNumaNodeProcessorMask (Windows): Windows API提供了GetNumaNodeProcessorMask函数,可以获取指定NUMA节点的CPU核心掩码。然后可以使用SetThreadAffinityMask函数将线程绑定到该NUMA节点的CPU核心上。

总而言之,CPU亲和性是一个值得学习和掌握的技术。希望今天的讲座能帮助大家更好地理解和使用CPU亲和性,让你的C++程序跑得更快、更稳!感谢各位的聆听!

发表回复

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