C++多线程实现绑定CPU的方法详解
前言
先把话说清楚:C++ 标准里没有任何 CPU 亲和性(CPU affinity)的 API。 标准库的 std::thread 不提供"把这个线程绑到第 N 个核"的能力, std::thread::hardware_concurrency() 也只能告诉你逻辑处理器大概有几个。 所以"用 C++ 绑定 CPU"这个说法的准确含义是:用 std::thread 管理线程, 再通过 std::thread::native_handle() 拿到平台原生句柄, 调用操作系统的亲和性接口完成绑定。离开操作系统 API,这件事在纯标准 C++ 里做不到。
典型误解有两个。第一,以为存在某个 std::this_thread::set_affinity()——不存在的。 第二,以为绑核就是"独占这个核"——不是。亲和性只是限定调度器的候选集合, 那个核上仍然可以跑别的线程,除非你用 cgroup 的 cpuset 或内核的 isolcpus= 把它隔离出来。
本文以 C++17 为基准,分别给出 Linux(glibc)与 Windows 两条路径, 并重点讲清一个容易被忽略的时序问题:std::thread 构造完成时,线程可能已经开始跑了, 此时再去设置亲和性存在竞态。
一、为什么需要绑定,以及它的代价
绑定的收益来自硬件层面的两个事实:
- 缓存局部性:线程被调度器在不同 CPU 之间迁移时,原先在某个核的私有
L1/L2 缓存里预热的数据就"凉"了,迁移后要重新从更远的层级取。
- NUMA 亲和性:在多路服务器上,跨 NUMA 节点访问内存的延迟高于本地节点。
把线程钉在某个节点附近的核上,有助于让它更多地命中本地内存。
此外,实时和低延迟场景希望减少调度抖动,性能测量场景希望降低噪声, 这些都属于"用可控性换取灵活性"的考虑。
反过来,绑定是有代价的,写之前要想清楚:
- 绑得太死,调度器没法把线程挪到空闲核上。如果被绑定的核上还有别的忙线程,
你的线程只能干等,而旁边有核闲着。
- 一个核上绑多个忙线程,会退化成线程间的相互抢占,比不绑定还糟。
二、Linux:pthread_setaffinity_np 与 sched_setaffinity
glibc 提供两个亲和性接口:
int pthread_setaffinity_np(pthread_t thread, size_t cpusetsize, const cpu_set_t *cpuset);
int pthread_getaffinity_np(pthread_t thread, size_t cpusetsize, cpu_set_t *cpuset);
int sched_setaffinity(pid_t pid, size_t cpusetsize, const cpu_set_t *mask);几个必须记住的细节:
- 这两个
*_np函数是 GNU 扩展(non-portable,所以带 np 后缀),
使用前必须在任何 #include 之前 #define _GNU_SOURCE,且需要 -pthread 链接选项。
- 它们返回错误码本身(成功返回 0),不设置
errno。把返回值当 errno 用是常见的错。
cpu_set_t是一张位图。操作宏有CPU_ZERO(&set)、CPU_SET(cpu, &set)、
CPU_CLR(cpu, &set)、CPU_ISSET(cpu, &set)、CPU_COUNT(&set), 都在 <sched.h> 里。这些宏不做边界检查,越界写会破坏栈或相邻内存——是未定义行为。
cpusetsize必须传sizeof(cpu_set_t)(或者你用CPU_ALLOC动态分配得到的字节数)。
sched_setaffinity的pid传 0 表示"调用线程自己"。
这个重载的好处是:在线程函数里调用它没有任何竞态,不需要先拿到自己的线程 ID。
- 查询当前线程实际跑在哪个核上,可以用 glibc 的
sched_getcpu()。
三、Windows:SetThreadAffinityMask 与处理器组
Win32 侧的对应接口:
DWORD_PTR SetThreadAffinityMask(HANDLE hThread, DWORD_PTR dwThreadAffinityMask);
BOOL SetThreadGroupAffinity(HANDLE hThread, const GROUP_AFFINITY *GroupAffinity,
PGROUP_AFFINITY PreviousGroupAffinity);
BOOL GetThreadGroupAffinity(HANDLE hThread, PGROUP_AFFINITY GroupAffinity);要点:
SetThreadAffinityMask成功时返回原来的掩码,失败返回 0。
Windows 没有单独的 "GetThreadAffinityMask",要查询就先按原值设置一次, 返回值就是当前掩码。
- 掩码是位图:第 n 位为 1 表示允许跑在第 n 个逻辑处理器上。
GetCurrentThread()返回的是伪句柄(pseudo handle),它永久有效,
不要对它调用 CloseHandle。
- 当逻辑处理器数量超过 64 时,Windows 引入"处理器组(processor group)"概念,
一个掩码只能覆盖一个组内最多 64 个处理器。跨组要用 SetThreadGroupAffinity; 用 GetActiveProcessorGroupCount() 与 GetActiveProcessorCount(group) 查询规模, GetActiveProcessorMask() 拿到当前组的掩码。
- 用
GetLastError()取失败原因。
四、和 std::thread 拼接:竞态与三种方案
std::thread::native_handle() 返回的就是原生句柄:
- 在 Linux 上,libstdc++ 与 libc++ 把
native_handle_type定义为pthread_t
(这是实现细节,不是标准规定;可用 static_assert(std::is_same<std::thread::native_handle_type, pthread_t>::value, ""); 自行确认)。
- 在 MSVC STL 上,
native_handle_type被定义为void*,实际值就是线程的HANDLE
(同样是实现细节,以本机 <thread> 头文件为准)。
于是有了第一种写法:父线程拿到句柄后设置。
std::thread t(worker);
pthread_setaffinity_np(t.native_handle(), sizeof(set), &set); // 有竞态问题在于:std::thread 的构造函数返回时,新线程可能已经在跑了。 在这行代码执行之前,被绑定的线程可能已经在任意核上工作了很久。 对"只是希望它尽量在某个核上"的普通场景,这通常无所谓; 但对"从第一条指令起就必须在指定核上"的实时场景,这就是个真问题。
三种解法:
- 线程自绑(推荐,无竞态):在线程函数开头调用
pthread_setaffinity_np(pthread_self(), ...) 或 SetThreadAffinityMask(GetCurrentThread(), ...)。 或者用 sched_setaffinity 的 pid 传 0 的写法。
- 加一道门:父线程先设置
native_handle的亲和性,再用互斥量加条件变量把子线程放行。
- 绕开 std::thread:直接用
pthread_create或_beginthreadex创建线程,
在创建前后自己控制一切。可移植性最差。
顺带提醒一个相关话题:绑定 CPU 常常和"让工作线程长期钉在某个核上跑"一起出现, 而 std::thread 没有标准的取消机制。C++20 的 std::jthread 也没有抢占式取消, 它只是提供了一个协作式的 std::stop_token。要让线程停下来,只能自己准备一个 std::atomic<bool> 标志,由线程周期性检查:
std::atomic<bool> stop_flag{false}; // 需要 <atomic>
std::thread worker([&] {
while (!stop_flag.load(std::memory_order_relaxed)) {
do_one_unit_of_work();
}
});
// ... 结束阶段
stop_flag.store(true, std::memory_order_relaxed); // 只是通知停止,不传数据
worker.join();注意两点:volatile 不能替代 std::atomic,它既不保证原子性也不建立 happens-before 关系;另外如果这个标志还要用来"发布"主线程写好的数据, 就必须用 release / acquire 配对,relaxed 只适合纯粹的停止信号。
五、完整示例
Linux 版
// 适用:Linux + glibc + GCC 13 / Clang 17
// 编译:g++ -std=c++17 -O2 -Wall -Wextra -pthread affinity_linux.cpp -o affinity_linux
#define _GNU_SOURCE // 必须在包含 <sched.h> / <pthread.h> 之前定义
#include <pthread.h>
#include <sched.h>
#include <condition_variable>
#include <cstdio>
#include <cstring>
#include <mutex>
#include <thread>
namespace {
// 把「当前线程」绑定到编号为 cpu 的逻辑处理器上,无竞态
bool bind_this_thread(int cpu) {
if (cpu < 0 || cpu >= CPU_SETSIZE) { // CPU_SET 宏不做边界检查,自己先挡
return false;
}
cpu_set_t set;
CPU_ZERO(&set);
CPU_SET(cpu, &set);
const int rc = ::pthread_setaffinity_np(::pthread_self(), sizeof(set), &set);
if (rc != 0) { // 返回错误码本身,不设置 errno
std::fprintf(stderr, "pthread_setaffinity_np 失败: %s\n", std::strerror(rc));
return false;
}
return true;
}
} // namespace
int main() {
if (std::thread::hardware_concurrency() == 0) {
std::fprintf(stderr, "无法确定逻辑处理器数量\n");
return 1;
}
const int target = 0; // 目标逻辑处理器编号
std::mutex m;
std::condition_variable cv;
bool ready = false;
std::thread worker([&] {
{ // 先在这里等,确保父线程已经完成对它 native_handle 的设置
std::unique_lock<std::mutex> lk(m);
cv.wait(lk, [&] { return ready; });
}
// 路径一:线程自绑,重复设置同一掩码是幂等的
if (!bind_this_thread(target)) {
std::fprintf(stderr, "线程自绑失败\n");
}
std::printf("worker 运行在 CPU %d\n", ::sched_getcpu());
});
// 路径二:父线程先用 native_handle 设置亲和性,再放行
cpu_set_t set;
CPU_ZERO(&set);
CPU_SET(target, &set);
const int rc = ::pthread_setaffinity_np(worker.native_handle(), sizeof(set), &set);
if (rc != 0) {
std::fprintf(stderr, "设置子线程亲和性失败: %s\n", std::strerror(rc));
}
{
std::lock_guard<std::mutex> lk(m);
ready = true;
}
cv.notify_one();
worker.join();
return 0;
}Windows 版
// 适用:Windows + MSVC 19.3x(MinGW-w64 亦可),无需额外链接库
// 编译:cl /std:c++17 /EHsc /W4 affinity_win.cpp
#define WIN32_LEAN_AND_MEAN
#include <windows.h>
#include <condition_variable>
#include <cstdio>
#include <mutex>
#include <thread>
namespace {
bool bind_current_thread(unsigned cpu) {
if (cpu >= 64) { // 一个处理器组内最多 64 个逻辑处理器
return false; // 跨组请改用 SetThreadGroupAffinity
}
const DWORD_PTR mask = DWORD_PTR(1) << cpu;
// 成功返回原来的掩码,失败返回 0
return ::SetThreadAffinityMask(::GetCurrentThread(), mask) != 0;
}
} // namespace
int main() {
const unsigned target = 0;
std::mutex m;
std::condition_variable cv;
bool ready = false;
std::thread worker([&] {
{
std::unique_lock<std::mutex> lk(m);
cv.wait(lk, [&] { return ready; });
}
// 路径一:线程自绑,无竞态
if (!bind_current_thread(target)) {
std::fprintf(stderr, "线程自绑失败\n");
}
std::printf("worker 已绑定到 CPU %u\n", target);
});
// 路径二:父线程通过 native_handle 再确认一次(幂等,用于演示句柄的取法)
// MSVC STL 中 native_handle_type 是 void*,值就是线程 HANDLE
HANDLE h = static_cast<HANDLE>(worker.native_handle());
const DWORD_PTR mask = DWORD_PTR(1) << target;
if (::SetThreadAffinityMask(h, mask) == 0) {
std::fprintf(stderr, "SetThreadAffinityMask 失败, 错误码 %lu\n",
static_cast<unsigned long>(::GetLastError()));
}
{
std::lock_guard<std::mutex> lk(m);
ready = true;
}
cv.notify_one();
worker.join();
return 0;
}两个示例都用了同一套"门闩"结构。要验证绑定是否生效:Linux 上用 sched_getcpu() 或外部命令 taskset -p;Windows 上用 SetThreadAffinityMask 传入原掩码,再看它的返回值。
常见坑点
_GNU_SOURCE定义得太晚
❌ 先 #include <sched.h> 再 #define _GNU_SOURCE —— pthread_setaffinity_np 声明不出来,报隐式声明或未定义标识符。
✅ 把 #define _GNU_SOURCE 放在文件最顶部、所有 #include 之前。
- 把
pthread_setaffinity_np的返回值当 errno
❌ if (pthread_setaffinity_np(...) == -1) { perror("bind"); } —— 它成功返回 0、 失败返回错误码本身,从不返回 -1,也不设置 errno。
✅ const int rc = pthread_setaffinity_np(...); if (rc != 0) fprintf(stderr, "%s\n", strerror(rc));
- 父线程设置子线程亲和性时忽略竞态
❌ std::thread t(f); pthread_setaffinity_np(t.native_handle(), ...); —— 构造函数返回时子线程可能已经执行了很久,绑定只对"之后"生效。
✅ 让线程自己绑(pthread_self() 或 GetCurrentThread()), 或者用互斥量加条件变量建一道门,等父线程设置完再放行。
CPU_SET越界
❌ CPU_SET(2048, &set); —— 宏只做位运算不做检查,写穿了 cpu_set_t 的边界, 这是未定义行为,症状可能是栈被破坏。
✅ 先判断编号在 [0, CPU_SETSIZE) 内;核数很多时用 CPU_ALLOC / CPU_FREE 动态分配位图,并把实际字节数传给 cpusetsize。
- Windows 上对
GetCurrentThread()的伪句柄调用CloseHandle
❌ HANDLE h = GetCurrentThread(); ... CloseHandle(h); —— 伪句柄不是真实句柄。
✅ 伪句柄永远有效、永不需要关闭;std::thread::native_handle() 拿到的线程句柄 由标准库在线程对象析构/join/detach 时负责关闭,你也不要手动 CloseHandle。
- 在 Windows 上把掩码移位到 64 位以上
❌ DWORD_PTR(1) << 64 —— 移位量大于等于位宽是未定义行为, 而且 64 位掩码在处理器组模型里本身就不表示"第 64 个核"。
✅ 守卫 cpu < 64,超出时改用 SetThreadGroupAffinity 指定组号和组内掩码。
- 想用
std::thread直接取消一个已绑核的线程
❌ t.kill()、t.cancel()、t.terminate() —— 标准库没有这些接口, 编译都过不了(Windows 上的 TerminateThread 是原生 API,强杀会泄漏资源、 留下锁未释放的线程,几乎总是坏主意)。
✅ 用 std::atomic<bool> 做协作式退出标志,线程周期性检查, 主线程置位后 join() 等待收尾。
- 把
hardware_concurrency()的结果当"可用核数"
❌ const unsigned n = std::thread::hardware_concurrency(); 然后按 n 均分线程 —— 它可能返回 0(标准允许),而且不看 cgroup / 亲和性掩码等限制, 在容器里它常常是宿主机核数而不是配额。
✅ 判 == 0 时退回 1;需要真实可用核数时,Linux 读 cgroup/sched_getaffinity 的结果,Windows 用 GetActiveProcessorCount。
总结
| 平台 / 层面 | 接口 | 关键注意点 |
|---|
| C++ 标准 | 无 | 标准库不提供亲和性,只能靠 native_handle() 借道 |
| Linux(glibc) | pthread_setaffinity_np、sched_setaffinity | GNU 扩展,需 _GNU_SOURCE 与 -pthread;返回错误码而非 errno |
| Linux 自绑 | sched_setaffinity(0, ...) | 无竞态,最省事 |
| Windows | SetThreadAffinityMask | 返回原掩码;GetCurrentThread() 是伪句柄 |
| Windows(多组) | SetThreadGroupAffinity | 超过 64 个逻辑处理器时需要 |
| 竞态处理 | 线程自绑 或 条件变量门闩 | std::thread 构造返回时线程可能已在运行 |
一句话总结:标准 C++ 只管"创建和汇合线程",绑核是操作系统的事。 实践的推荐顺序是——先让线程自己绑(最可靠),必须由外部指定时再用 native_handle() 配合门闩;同时牢记 std::thread 没有取消机制, 停止线程永远是协作式的。
openEuler 是由开放原子开源基金会孵化的全场景开源操作系统项目,面向数字基础设施四大核心场景(服务器、云计算、边缘计算、嵌入式),全面支持 ARM、x86、RISC-V、loongArch、PowerPC、SW-64 等多样性计算架构
更多推荐


所有评论(0)