PAYATHON 2026

Linux 多线程优化:CPU 绑核会导致线程饥饿吗?

数据小许

结论

CPU 绑核本身通常不会造成严格意义上的线程饥饿,但会限制调度器的选择。如果线程绑定的 CPU 很忙,即使其他 CPU 空闲,它也不能迁移过去执行。

对于普通调度策略下的线程,其他程序在同一个 CPU 上运行时,线程通常会与它们共享 CPU 时间,并非完全得不到执行。是否发生长期饥饿,还取决于调度策略、优先级以及资源限制等条件。

对于你这种使用集中式工作池、任务很短的程序,可以先让调度器自由安排线程,再通过测量判断是否需要绑核。工作线程数量等于在线 CPU 数量,本身不能证明绑核有收益。

绑核限制执行位置,不会独占 CPU

CPU affinity 指定线程可以在哪些逻辑 CPU 上运行。例如,把线程的允许集合设置为 {2},表示它只能在 CPU 2 上执行;设置为 {2, 3},则允许调度器在这两个 CPU 中选择。

这个设置不会为线程预留 CPU。其他允许在 CPU 2 上执行的线程,仍然可以被调度到 CPU 2。

内核不会因为某个线程绑定在这里,就自动禁止其他线程使用这个 CPU。是否选择它,还取决于系统负载、其他线程的允许 CPU 集合、调度策略和拓扑等因素。

假设线程 A 只能运行在 CPU 2,而 CPU 2 上还有其他负载:

  • A 必须等待 CPU 2 上的调度机会。
  • 其他 CPU 即使空闲,也无法帮助 A。
  • 放宽 A 的 affinity 后,调度器才有机会把它迁移到别处,但不保证立即迁移。

这类等待或性能下降是调度空间受限造成的。只有线程长期无法获得执行机会,才适合称为线程饥饿。

如果程序还使用了更高优先级的实时线程,或者线程受 CPU 配额限制,就需要另外分析,不能把这些因素造成的等待全部归因于绑核。

工作线程数量等于 CPU 数量,为什么仍可能不均衡?

如果所有 CPU 都在忙,取消绑核确实不会凭空增加计算资源。但实际负载很少完全均匀。

某些 CPU 可能承担较多中断或后台任务;某些工作线程可能阻塞,或正在执行比其他任务更长的任务。固定的一对一绑定会让调度器更难调整这些不均衡。

还要区分几个数量:

  • 在线逻辑 CPU 数量。
  • 当前进程或线程实际允许使用的 CPU 数量。
  • 物理核心数量。

容器、cpuset 或已有的 affinity 设置,都可能缩小可用范围。启用 SMT 时,多个逻辑 CPU 还可能共享同一物理核心的执行资源。创建与在线逻辑 CPU 数量相同的线程,并不代表获得了同样数量的独立核心。

scheduler tick 不等于切换线程,更不等于清空缓存

你在问题中把“执行被打断”“切换线程”和“缓存被覆盖”联系在了一起,但它们是不同的事件。

进入内核后,可以继续运行原线程

发生定时器中断时,CPU 可能进入内核进行计时和调度相关处理。处理结束后,仍然可以返回原线程继续执行,不一定发生线程上下文切换。

Linux 是否持续产生周期性 tick,还取决于内核配置和运行状态,不能假定任何情况下都有固定周期的调度打断。

内核执行会使用缓存,但不会因此自动覆盖全部缓存

进入内核态通常不会把数据缓存和指令缓存整体清空。内核访问的代码和数据会占用缓存,可能逐出一部分应用数据,但逐出多少取决于访问范围、缓存容量和具体硬件。

短暂的中断处理之后,原线程需要的代码和数据仍可能留在缓存中。不能由“内核执行过一些代码”推导出“应用缓存已经全部失效”。

线程迁移到另一核心时,原核心私有缓存中的数据可能无法直接在新核心的私有缓存中命中。不过,共享末级缓存和缓存一致性机制也会影响实际代价,迁移不意味着所有数据都必须重新从内存加载。

换成另一个工作线程,缓存收益也不一定相同

即使线程执行相同的函数,它们也可能使用不同的栈、任务数据和线程私有状态。工作池也可能共享大量代码和数据,因此切换工作线程不一定造成很大的缓存损失。

需要观察的是任务访问了什么数据、数据能否复用,以及迁移和切换在总耗时中占多大比例。仅凭“任务很短”或“执行相同工作”,无法确定结果。

调度器会考虑局部性,但不会知道每条缓存行属于谁

Linux 调度器会利用运行历史、CPU 拓扑等信息,在相关调度路径中考虑局部性和迁移代价。具体行为取决于调度策略、内核实现和系统配置。

它通常不会精确追踪“线程 A 现在有多少有效缓存行”,再据此选择下一个线程。缓存局部性只是调度目标之一,调度器还需要兼顾公平性、负载均衡和响应时间等目标。

你的两个判断可以同时成立:继续运行原线程可能更有利于缓存复用,而让另一个等待较久的线程运行也可能更符合当前的调度目标。

异构 CPU 上,手动选择核心需要明确目标

将线程限制在低功耗核心或高性能核心上,可以表达明确的执行位置要求,但不保证分别得到最低能耗或最高整体性能。

例如,低功耗核心可能需要更长时间完成任务;高性能核心也可能受到其他负载、频率或温度的影响。评估能耗时,需要考虑完成同一份工作的总能量,而不只是瞬时功率。

取消绑核后,调度器有更大的选择空间,但它能否做出符合你目标的选择,取决于硬件、内核支持和相关策略。不能假定内核一定了解应用的节能或延迟目标。

也可以把线程限制在某类核心组成的 CPU 集合中,允许它们在集合内迁移,再通过测量评估这种折中方案。

如何在程序中试验绑核?

下面的辅助函数适用于 Linux pthread 程序。工作线程启动后可以调用它,将当前线程绑定到其当前允许集合中的某个逻辑 CPU:

#define _GNU_SOURCE
#include <pthread.h>
#include <sched.h>
#include <errno.h>

int pin_current_thread(int cpu)
{
    cpu_set_t allowed;
    cpu_set_t target;

    if (cpu < 0 || cpu >= CPU_SETSIZE)
        return EINVAL;

    int rc = pthread_getaffinity_np(
        pthread_self(), sizeof(allowed), &allowed);
    if (rc != 0)
        return rc;

    if (!CPU_ISSET(cpu, &allowed))
        return EINVAL;

    CPU_ZERO(&target);
    CPU_SET(cpu, &target);

    return pthread_setaffinity_np(
        pthread_self(), sizeof(target), &target);
}

这里的 CPU 编号是逻辑 CPU 编号,不应直接假定可用编号是从 0 开始的连续范围。示例使用固定大小的 cpu_set_t;需要支持超大 CPU 集合时,应另行处理。

pthread_getaffinity_np() 和 pthread_setaffinity_np() 直接返回错误码,检查错误时应使用返回值。比较“不绑核”方案时,可以重新启动程序并跳过绑定调用,避免沿用上一轮已经收窄的 affinity。

怎样验证绑核是否值得?

用相同任务数据和相同工作线程数量,比较以下方案:

  1. 不额外设置 affinity。
  2. 每个工作线程绑定一个逻辑 CPU。
  3. 所有工作线程共享一个选定的 CPU 集合。

分别在系统较空闲和存在稳定后台负载时测量。主要观察完成固定工作量的总耗时、吞吐量和任务延迟;如果关心节能,再加入能耗测量。

可以用下面的命令辅助观察上下文切换和迁移次数,其中 ./program 代表你的程序:

perf stat -r 5 \
  -e task-clock,context-switches,cpu-migrations \
  -- ./program

perf 是否能使用这些事件,取决于系统支持和权限设置。迁移次数减少只能说明执行位置更固定,不能单独证明程序更快。

你的任务非常短,还应关注集中式工作池的取任务成本。锁竞争、共享计数器、频繁访问同一缓存行,以及每次只领取一个微小任务,都可能占据较大比例。可以测量批量领取任务是否有帮助;批量大小过大又可能影响负载均衡,需要结合任务时长判断。

如果不同 affinity 方案的耗时差异始终落在运行波动范围内,就没有足够证据说明绑核值得保留。保留实验开关、继续使用较简单的默认方案,也是一项合理的优化结论。

备注:内容仅供参考。