﻿# 线程亲和性（CPU Affinity）—— 把线程钉在指定 CPU 上：原理、实现与操作

> 默认情况下，**线程跑在哪个 CPU 核由内核调度器决定**，而且会随时被迁移到别的核。大多数程序不用管这个。但在追求**低延迟、稳定尾延迟、高缓存命中**的场景（交易系统、DPDK、数据库、实时任务），你会想**把线程"钉"在固定的核上**——这就是**线程亲和性（CPU affinity / pinning）**。


> 本篇从原理到操作逐层展开：**调度器为什么会频繁迁移线程**（sched_domain 层级、负载均衡触发条件）、**亲和性在内核中如何实现**（task_struct 存储、cgroup cpuset 交互、调度器强制检查点）、**怎么绑**（taskset / sched_setaffinity / pthread_setaffinity_np 三层工具）、**绑核 vs 绑 NUMA 节点的区别**、**系统级核隔离的组合配方**、**以及绑错了反而更慢的坑**。它和 [scheduling.md](/concepts/process/scheduling.md)（调度器内部如何选人）、[../numa/numa.md](/concepts/numa/numa.md)（NUMA 远程访问代价）、[../cache/cache-organization.md](/concepts/cache/cache-organization.md)（缓存私有性）、[../cache/tlb.md](/concepts/cache/tlb.md)（TLB 冷了也要重新填）、[../cache/mesi.md](/concepts/cache/mesi.md)（多核缓存一致性/伪共享）都相关。

---

## 一、调度器为什么会让线程迁移——负载均衡的本质

要理解"为什么要绑核"，首先要理解"调度器为什么要把线程搬来搬去"。

### 1.1 CFS/EEVDF 的设计目标与迁移的必然性

Linux 内核的完全公平调度器（CFS；v6.6+ 为 EEVDF）有两个核心目标：

1. **公平性**：每个可运行线程都应该获得"应有"的 CPU 时间（按 nice 权重分配）。如果一个线程长时间得不到运行，调度器必须主动把它放到能跑的核上。
2. **CPU 利用率**：所有核应该尽量忙，不能出现"A 核有多个线程在排队等 CPU、B 核却空闲"的情况。

这两个目标天然会导致线程迁移——**当一个核忙而另一个核闲时，调度器会把线程从忙碌核搬到空闲核**。这称为**负载均衡（load balancing）**。

对吞吐型任务，迁移让所有核都充分利用——总体吞吐更高。但对延迟敏感任务，迁移的代价是**被迁线程的所有缓存状态全部作废**——这是"公平性/利用率"和"缓存局部性"之间的根本矛盾，调度器默认偏向前者。

### 1.2 sched_domain 层级——调度域决定了迁移的"代价认知"

调度器不是在所有核之间做无差别负载均衡。它按照 CPU 硬件拓扑构建层级化的**调度域（sched_domain）**，每一级域对应一种共享资源：

```plantuml
@startuml
skinparam rectangleBorderThickness 1
skinparam backgroundColor transparent
rectangle "<b>NUMA 域</b>\n<i>跨 socket 均衡，代价最大</i>" #E8EAF6 {
  rectangle "<b>DIE — socket 0</b>\n<i>共享 L3 cache</i>" #C8E6C9 {
    rectangle "<b>MC 域</b>\n<i>物理核间，L1/L2 冷</i>" #FFF9C4 {
      rectangle "SMT 核0\n<i>共享 L1/L2</i>" #FFF
      rectangle "SMT 核1\n<i>共享 L1/L2</i>" #FFF
    }
    rectangle "<b>MC 域</b>\n<i>物理核间，L1/L2 冷</i>" #FFF9C4 {
      rectangle "SMT 核2\n<i>共享 L1/L2</i>" #FFF
      rectangle "SMT 核3\n<i>共享 L1/L2</i>" #FFF
    }
  }
  rectangle "<b>DIE — socket 1</b>\n<i>共享 L3 cache</i>" #C8E6C9 {
    rectangle "<b>MC 域</b>\n<i>物理核间，L1/L2 冷</i>" #FFF9C4 {
      rectangle "SMT 核4\n<i>共享 L1/L2</i>" #FFF
      rectangle "SMT 核5\n<i>共享 L1/L2</i>" #FFF
    }
    rectangle "<b>MC 域</b>\n<i>物理核间，L1/L2 冷</i>" #FFF9C4 {
      rectangle "SMT 核6\n<i>共享 L1/L2</i>" #FFF
      rectangle "SMT 核7\n<i>共享 L1/L2</i>" #FFF
    }
  }
}
note bottom
  <size:11><b>均衡方向：</b>SMT → MC → DIE → NUMA（自底向上）
  <b>核心原则：</b>便宜的迁移优先做，贵的迁移尽量少做</size>
end note
@enduml
```

| 调度域层级 | 标志位示例 | 共享资源 | 迁移代价 | 均衡策略 |
|-----------|-----------|---------|---------|---------|
| SMT（超线程） | `SD_SHARE_CPUCAPACITY` | 同一物理核的 L1/L2、执行单元 | **最低**——缓存全共享 | 优先在此域内均衡 |
| MC（多核） | `SD_SHARE_PKG_RESOURCES` | L3 cache（同一 socket） | **中等**——L1/L2 冷，L3 热 | 仅当 SMT 域已均衡后才上升 |
| DIE（die 内） | — | 可能同一 socket（取决于拓扑） | **较大**——L3 也可能冷 | MC 均衡后再上升 |
| NUMA（跨 socket） | `SD_NUMA` | 无共享缓存 | **最大**——远程内存访问 | 频率最低，只在严重不均衡时触发 |

> 调度器的均衡策略是**"自底向上"**的：先在最细粒度域（SMT）内均衡 → 均衡不了才上升一级 → 直到 NUMA 域。这保证了"便宜的迁移优先做，贵的迁移尽量少做"——但"便宜"是相对于整个系统的吞吐而言的，对单个线程来说每次迁移都意味着缓存全冷。

### 1.3 负载均衡何时触发——三种触发时机

| 触发时机 | 函数入口 | 场景 | 行为 |
|---------|---------|------|------|
| **周期性均衡** | `rebalance_domains()` | 每个 CPU 的调度 tick（每 1~10ms，取决于 `CONFIG_HZ`） | 更新各 CPU 负载值，比较相邻 CPU 是否不均衡。如果一段时间窗口内持续不平衡，触发迁移 |
| **新空闲均衡** | `newidle_balance()` | 某 CPU 进入空闲（当前无可运行任务），即将 idle | 从最繁忙的 CPU 拉取任务来跑——"别的核忙不过来，我来帮忙"。最激进的均衡，可能跨多级域 |
| **唤醒均衡** | `select_task_rq_fair()` | 线程被唤醒时（数据到达、锁释放、定时器到期） | `select_idle_sibling()` 在唤醒者的 sched_domain 内找 idle CPU。优先同 SMT 兄弟 → 同 LLC（L3）域 → 最终才选当前 CPU |

**为什么在这种设计下迁移仍然高频发生**——考虑一个典型场景：

```bash
场景：16 核服务器上跑 12 个计算密集型线程
1. 线程 A 在核 0 上跑了约 4ms
2. 调度 tick 到期 → 核 0 的内核定时器中断 → A 被抢占，入就绪队列
3. 核 0 接着跑内核代码（中断处理）+ 另一个线程
4. A 在就绪队列等了几百微秒后再次被唤醒
5. 此时核 0 还在忙 → select_idle_sibling() 在同一个 MC 域内找到核 4 是 idle
6. A 被放到核 4 运行 —— 一次迁移！
7. A 在核 0 养了 4ms 的 L1/L2 数据全部作废
关键矛盾：调度器做决策时认为"让 A 立即在核 4 上跑" > "让 A 在核 0 排队等"。
调度器优先保证"即时响应"，而不是"缓存局部性"——因为调度器不知道 A 的缓存有多热。
```

每次单个决策都合理，但累积起来，线程可能在数秒内在不同物理核间跳跃数次——造成可观测的尾延迟抖动。

### 1.4 迁移的性能代价——量化分析

线程被迁到另一个核时的真实性能损耗：

```plantuml
@startuml
skinparam shadowing false
skinparam sequence {
  ParticipantBackgroundColor #E3F2FD
  ParticipantBorderColor     #1976D2
  LifeLineBorderColor        #90A4AE
}
participant "线程 T" as T
participant "核 0\n(L1/L2 已养热)" as C0
participant "核 3\n(L1/L2 是冷的)" as C3
T -> C0 : 在核0 跑了一阵,\n工作集都在核0 的 L1/L2
note over C0 : 缓存命中率高 → 快
C0 -> C3 : 调度器决定迁移(负载均衡/核0 被抢占)
T -> C3 : 现在在核3 跑
note over C3 #FFCDD2 : L1/L2 全是冷的\n→ 一堆 cache miss 重新养热\n→ 一次延迟尖刺
@enduml
```

| 资源 | 正常命中延迟（近似） | 迁移后首次访问 | 代价倍数 |
|------|-------------------|-------------|---------|
| L1 data cache | ~4-5 cycles | → L3（~40 cycles）或 → 内存（~100ns ≈ 300+ cycles） | 10x~50x |
| L2 cache | ~12-14 cycles | → L3 或内存 | 3x~30x |
| L1 instruction cache | ~4-5 cycles | → 下一级缓存或内存 | ~10x |
| L1 d-TLB | ~1 cycle | → page walk（5-20+ cycles） | 5x~20x |
| 分支预测器状态 | ~0 cycles | 状态全部丢失 → 间接跳转预测率暴跌 | 每次错误 ~15-20 cycles |
| **跨 NUMA 迁移** | — | 远节点内存延迟 ~1.5-2x，**且不会随运行恢复** | **持续性退化** |

**累计效应**：一个线程迁移到新核后，**前 1~10ms 的执行效率可能下降 30%~70%**（取决于工作集大小和对缓存的敏感度），直到关键数据被重新加载到本地 L1/L2。这在延迟分布上表现为 **p99/p999 出现明显尖刺**——"平均延迟没问题，但 tail latency 炸了"。

> **亲和性的核心价值**：把线程钉在固定核上，**保住它的 L1/L2 缓存热度、TLB 条目、分支预测器状态**，锁死 NUMA 本地内存访问，消除迁移带来的延迟不确定性和尖刺。代价是**放弃调度器的自动负载均衡**——这是"低延迟可控性"换"高吞吐灵活性"的权衡。理解调度器的设计动机，才能理解为什么这种"手动违反调度器意愿"的行为在特定场景中是正确且必要的。

---

## 二、亲和性的内核实现——比"位图"更深的机制

### 2.1 表象：一个位图掩码

每个线程有一个**CPU 亲和性掩码（affinity mask）**——一个 `cpumask_t` 类型的位图，标记它**被允许**在哪些 CPU 上运行。调度器在任何放置决策中，只会在掩码内的核中选择。

- 默认掩码 = **所有 online CPU**（线程哪都能去）。
- 绑到单核 = 掩码只留一个 bit（线程只能在那个核跑）。
- 绑到一组核 = 掩码留若干 bit（线程可以在这几个核间被调度器自由放置，但绝不超出这个集合）。

**CPU 编号**使用逻辑 CPU 号（即 `/proc/cpuinfo` 的 `processor` 字段）。**注意超线程**：一个物理核的两个超线程是两个逻辑 CPU（例如核 0 = CPU 0 和 CPU 8），绑核前须确认物理核↔逻辑 CPU 的映射关系（`lscpu -e` 输出 `CORE` 列）。

### 2.2 内核中的存储——两级掩码结构

在 `task_struct` 中，亲和性由**两个字段共同表达**：

```c
// include/linux/sched.h（简化）
struct task_struct {
    ...
    cpumask_t               cpus_mask;    // 用户态 set 的"预期"掩码
    const struct cpumask    *cpus_ptr;    // 调度器实际参考的"有效"掩码
    ...
};
```

- **`cpus_mask`**：用户通过 `sched_setaffinity()` / `pthread_setaffinity_np()` 设置的"理想"掩码。存储在线程的 task_struct 中，不因外部环境变动。
- **`cpus_ptr`**：调度器在**每个决策点**实际参考的掩码指针。它**通常**指向 `cpus_mask`，但在某些场景会指向不同的掩码：
  - **cgroup cpuset** 施加了额外限制 → `cpus_ptr` 指向 cpuset 控制器算出的有效掩码（`effective_cpus`）。
  - CPU 热插拔导致某 CPU offline → 调度器把该 CPU 从 `cpus_ptr` 中移除（但 `cpus_mask` 保留原值，等 CPU 重新 online 后可恢复）。
  - 极少见的 `__migrate_task()` 过程中的临时修改。

**两级设计的意义**——与 cgroup cpuset 的交互：

```bash
例：用户把线程绑到 CPU 0-3，同时该线程所在的 cgroup cpuset 只分配了 CPU 0-1
  逻辑 CPU 编号:     0    1    2    3    4    5    6    7
  cpus_mask          1    1    1    1    0    0    0    0    ← 用户设的"我想要 0-3"
  cgroup cpuset      1    1    0    0    0    0    0    0    ← cgroup 限制"只能用 0-1"
  ───────────────────────────────────────────────
  cpus_ptr (有效)     1    1    0    0    0    0    0    0    ← 取交集：实际只能在 0、1
  结果：
  - 调度器看到 cpus_ptr → 线程被限制在 0、1
  - 用户态读取 cpus_mask → 仍然是 0-3（用户"意图"未丢失）
  - 如果 cgroup cpuset 以后放开限制，cpus_ptr 会回到 cpus_mask 的完整范围（0-3）
```

> 关键认知：**`cpus_ptr` 是硬约束，不是优化建议**。它不是"调度器优先考虑这些核"，而是"调度器不能把这些核之外的核作为目标"，违反该约束会导致调度器拒绝操作或在迁移路径上触发 BUG。

### 2.3 调度器如何在各决策路径上强制亲和性

| 调度路径 | 检查点（函数/宏） | 行为 |
|---------|-----------------|------|
| **负载均衡—推任务** | `can_migrate_task()` | `cpumask_test_cpu(target_cpu, p->cpus_ptr)` → 目标 CPU 不在允许集合内则拒绝迁移 |
| **负载均衡—拉任务** | `detach_tasks()` 内的 `can_migrate_task()` | 同上，从最繁忙 CPU 的队列中拉取任务前遍历确认 |
| **唤醒放置** | `select_task_rq_fair()` → 候选 CPU 列表 | 所有候选必须在 `p->cpus_ptr` 中；若集合为空或全忙，退回到 `select_fallback_rq()` |
| **fork 放置** | `wake_up_new_task()` → `select_task_rq_fair()` | 新创建的子线程在父线程的 `cpus_ptr` 集合内找 idle CPU |
| **修改亲和性** | `set_cpus_allowed_ptr()` | 如果新掩码排除了线程当前所在 CPU → 立即触发迁移，调用 `push_task_to_cpu()` 把线程移到掩码内的某个 CPU |
| **CPU hot-unplug** | `sched_cpu_deactivate()` | 如果某 CPU 要下线而某线程的 `cpus_ptr` 只剩下这个 CPU → 强制把线程踢到 cpus_ptr 内的其他 CPU / fallback |

> **关键认知**：绑到 CPU 2-5 意味着线程可以在 [2,3,4,5] 这四个核上被调度器自由放置——只是不会跑到 2-5 之外的核。**亲和性本身不阻止集合内的迁移**。如果想做到"线程只在一组核内跑，且调度器不在这组核内做负载均衡移动"，需要配合系统级隔离（见 §五）。

### 2.4 亲和性 vs CPU 隔离——两个容易混淆的层次

| | 亲和性（CPU affinity） | CPU 隔离（isolation） |
|---|---|---|
| **控制什么** | 线程**能**用哪些核 | 谁**不能**用这些核 |
| **作用范围** | 单线程（`task_struct` 粒度） | 系统级（`isolcpus=` 启动参数）或 cgroup 级（cpuset） |
| **对系统中其他任务的影响** | 零——别人照样能用这些核 | 把普通任务/中断从这些核上赶走 |
| **生效方式** | 调度器在放置决策时跳过不允许的核 | 被隔离的核不参与默认负载均衡，调度 tick 可关闭 |
| **典型命令** | `taskset -c` / `sched_setaffinity` | `isolcpus=2-5`（启动参数）+ `nohz_full=2-5` |

> 亲和性是**拉的方向**（让我的线程只能用这些核），隔离是**推的方向**（让其他东西别用这些核）。极致低延迟场景需要两个方向同时发力：用隔离把核清空，再用亲和性把关键线程独占放进去。

---

## 三、怎么绑：三个层次的工具

### 3.1 taskset —— 命令行，不改代码（最快上手）

```bash
# 启动时就绑：把新进程绑到 CPU 2（-c 指定十进制 CPU 号列表，比位掩码参数直观）
taskset -c 2 ./myapp
# 绑到一组核 CPU 2,3,4,5
taskset -c 2-5 ./myapp
# 改已运行进程的亲和性（-p 指定 PID）
taskset -c 2 -p <PID>
# 查看某进程当前亲和性
taskset -c -p <PID>
```

- 优点：零代码改动、对整个进程（及其所有线程）生效。
- 局限：**粒度是进程**——绑的是"这个进程的所有线程都只能在这些核跑"，无法给不同线程绑不同核（要那样得在进程内部用 §3.3 的 API）。

### 3.2 sched_setaffinity(2) —— 进程/线程级系统调用

`taskset` 底层调的就是它。C 代码中直接调用，能精确到**单个线程**（传 TID）：

```c
#define _GNU_SOURCE
#include <sched.h>
cpu_set_t set;
CPU_ZERO(&set);
CPU_SET(2, &set);          // 只允许 CPU 2
CPU_SET(3, &set);          // 也允许 CPU 3
// pid=0 表示当前线程；传具体 TID (gettid()) 可绑别的线程
sched_setaffinity(0, sizeof(set), &set);
// 查询当前亲和性
cpu_set_t got;
sched_getaffinity(0, sizeof(got), &got);
```

### 3.3 pthread_setaffinity_np —— 给具体线程绑（多线程程序标配）

多线程程序里**给每个工作线程绑不同的核**，用 pthread 接口最自然（`np` = non-portable，Linux 特有）：

```c
#define _GNU_SOURCE
#include <pthread.h>
cpu_set_t set;
CPU_ZERO(&set);
CPU_SET(cpu_id, &set);
// 绑当前线程：pthread_self()；也可以传已创建的线程句柄
pthread_setaffinity_np(pthread_self(), sizeof(set), &set);
```

典型用法——**线程池里第 i 个工作线程独占第 i 个核**：

```c
void* worker(void* arg) {
    int cpu = (int)(intptr_t)arg;
    cpu_set_t set; CPU_ZERO(&set); CPU_SET(cpu, &set);
    pthread_setaffinity_np(pthread_self(), sizeof(set), &set);
    /* ... 这个线程从此只在 cpu 上跑 ... */
    return NULL;
}
// 创建时：pthread_create(&t[i], NULL, worker, (void*)(intptr_t)i);
```

---

## 四、绑核 vs 绑 NUMA 节点：别混

这是最容易搞混的一组概念——**亲和性（绑 CPU）和 NUMA 绑定（绑内存）是两个正交的维度**：

| | CPU 亲和性（本篇） | NUMA 绑定（[numactl](/tools/numa/numactl.md)） |
|---|---|---|
| **绑的是** | 线程能在哪些 **CPU 核**上跑 | 内存从哪个 **NUMA 节点**分配 |
| **工具** | `taskset` / `sched_setaffinity` / `pthread_setaffinity_np` | `numactl --cpunodebind` / `--membind` / `set_mempolicy` |
| **解决问题** | 缓存热度、避免迁移抖动、消除 tail latency | 避免跨节点**远程内存访问** |
| **粒度** | 精确到单个逻辑 CPU | 以 NUMA 节点为单位（一个节点含多个核） |

**两者常配合使用**：低延迟服务的标准配方是**既绑核又绑内存到同一个 NUMA 节点**——`numactl --cpunodebind=0 --membind=0` 一次搞定"线程只在节点 0 的核上跑 + 内存只从节点 0 分配"，彻底本地化、零远程访问（详见 [../numa/numa.md](/concepts/numa/numa.md)）。如果只绑核不绑内存，线程钉住了但内存可能在远节点——NUMA 远程访问带来的 50%~100% 额外延迟会吃掉所有绑核收益；如果只绑内存不绑核，线程可能被调度器迁到别节点的核，又变回远程访问。

> 一句话：**绑核管"线程在哪算"、绑 NUMA 管"数据在哪存"，低延迟要两个一起绑到同一节点。** `numactl --cpunodebind` 本质上是设了"绑定某个 NUMA 节点对应的那组 CPU 核"（节点级亲和性），更细的"钉到某一个具体核"才用 taskset / pthread 接口。

---

## 五、系统级隔离：把核"让"给关键线程

绑核只是让你的线程**能**用某个核，但**别人也能用**——内核的其他任务、中断处理、甚至其他用户的进程还是会跑上来抢占。要真正独占，得从系统层把核**隔离**出来：

| 手段 | 作用 |
|------|------|
| **`isolcpus=2-5`**（内核启动参数） | 把 CPU 2-5 从调度器的默认负载均衡里**摘出去**——普通任务不会被自动放上去，只有显式通过亲和性绑上来的线程才跑在这些核上。给关键线程留干净的核 |
| **`nohz_full=2-5`** | 让这些核在只有一个可运行任务时**关掉调度时钟中断**（tickless），减少周期性中断对关键线程的打扰——极致低延迟场景的关键配置 |
| **中断亲和性** `/proc/irq/<n>/smp_affinity` | 把网卡、磁盘等设备中断**从关键核挪走**，别让 IRQ handler 和 softirq 抢占关键线程的 CPU 时间（呼应 [../../tools/cpu/mpstat.md](/tools/cpu/mpstat.md) 中观测到的 `%irq` / `%soft`） |
| **cgroup cpuset** | 容器/cgroup 级别把一组核划给某组进程独占（Kubernetes CPU Manager `static` 策略的底层实现就是这个） |

> 完整的低延迟配方：`isolcpus` 隔出干净核 → `nohz_full` 关掉调度 tick → 中断亲和性把 IRQ 挪走 → 应用用 pthread 亲和性把关键线程钉上去。**层层把核清空，再把线程独占放进去。**

---

## 六、常见坑：绑错了反而更慢

亲和性是一把双刃剑，帮倒忙比不绑更糟糕：

- **绑到超线程的同一物理核**：CPU 0 和 CPU 8 若是同一物理核的两个超线程，把两个繁忙线程分别绑到它们 = 两个线程抢同一套 L1/L2 和执行单元，**性能可能不如让它们跑在两个不同物理核上**。绑之前用 `lscpu -e` 或 `cat /proc/cpuinfo` 确认 `core id` 和逻辑 CPU 的映射关系。
- **绑核数 < 线程数**：把 8 个繁忙线程绑到 4 个核，它们挤在一起互相抢占、频繁上下文切换，比不绑还慢。
- **绑了核却没绑 NUMA 内存**：线程钉在节点 1 的核，内存却在节点 0——每次访存都远程，越绑越慢（见 §四）。
- **和调度器对着干**：绑死后放弃了负载均衡——如果被绑的核上还残留重任务（没做 `isolcpus` 隔离），你的线程反而饿死在那几个核上。
- **伪共享没解决**：绑核让线程稳定在各自核上，但如果它们高频修改的变量落在**同一条 cache line**，绑核反而让跨核的 MESI 协议 ping-pong 更稳定地持续发生——伪共享要靠 cache line 对齐/填充来解决，不是绑核（见 [../cache/mesi.md](/concepts/cache/mesi.md)、[../cache/memory-alignment.md](/concepts/cache/memory-alignment.md)）。
- **多线程进程只绑了主线程**：用 `taskset ./myapp` 只绑了初始线程，如果进程后续 `pthread_create` 出子线程——子线程**继承**父线程的亲和性掩码，所以多线程情况下它们是同命运的。但如果期望的是"主线程绑核 0、工作线程绑核 1-N"，必须在每个线程内分别调用 `pthread_setaffinity_np`，`taskset` 做不到这一点。

> **准则**：亲和性是**低延迟/稳定性**的优化，不是**吞吐**的银弹。绑之前先确认：物理核映射对不对、核够不够线程用、NUMA 内存有没有跟着绑、系统级隔离做没做。**测量驱动**——绑完对比 `perf stat -e migrations,cache-misses` 和 p99 延迟，别凭感觉。

---

## 七、怎么观测

```bash
lscpu -e                          # 逻辑 CPU ↔ 物理核 ↔ NUMA 节点的映射（绑核前必看）
taskset -c -p <PID>               # 查进程当前亲和性
cat /proc/<PID>/status | grep Cpus_allowed_list   # 同上，看允许的 CPU 列表
# 看线程实际在哪个核跑（PSR 列 = 当前 CPU）
ps -o pid,tid,psr,comm -T -p <PID>
top -H                            # -H 展开线程，f 键加 P（Last used cpu）列看线程在哪个核
# 迁移次数 & 亲和性效果：perf 看上下文切换和 cache-miss
perf stat -e cs,migrations,cache-misses ./app
# 更详细的——看具体从哪迁移到哪
perf sched record ./app && perf sched latency
```

**判据**：`migrations`（CPU 迁移次数）高 + 尾延迟抖动 → 亲和性可能有帮助；绑完再测一次，看 `migrations` 是否归零、p99 是否变稳、`cache-misses` 是否下降。

---

## 八、和本仓库其他文档的关系

| 相关主题 | 文档 | 关系 |
|---------|------|------|
| **调度器内部算法** | [scheduling.md](/concepts/process/scheduling.md) | 调度器"怎么选人"（CFS/EEVDF、运行队列、抢占）；本文讲"怎么限制选择范围" |
| **线程从哪来** | [thread-creation.md](/concepts/process/thread-creation.md) | 每线程独立调度实体，亲和性就是给这个实体设 CPU 约束 |
| **NUMA 为何重要** | [../numa/numa.md](/concepts/numa/numa.md) | NUMA 远程访问代价量化；绑核必须和绑内存配合的理论依据 |
| **NUMA 节点绑定操作** | [../../tools/numa/numactl.md](/tools/numa/numactl.md) | `--cpunodebind` / `--membind` 的具体用法 |
| **缓存私有性** | [../cache/cache-organization.md](/concepts/cache/cache-organization.md) | L1/L2 为什么是 per-core 私有的——绑核保住它们的理论依据 |
| **TLB 也怕冷** | [../cache/tlb.md](/concepts/cache/tlb.md) | 迁移后 TLB flush / page walk 的代价——绑核的第二个动机 |
| **别用绑核治伪共享** | [../cache/mesi.md](/concepts/cache/mesi.md)、[../cache/memory-alignment.md](/concepts/cache/memory-alignment.md) | MESI 缓存一致性协议、伪共享的诊断和修复 |
| **中断抢核** | [../../tools/cpu/mpstat.md](/tools/cpu/mpstat.md) | `%irq` / `%soft` 指标——系统级隔离的要排除的东西 |
| **观测工具** | [../../tools/cpu/top.md](/tools/cpu/top.md)（`top -H`）、[../../tools/code/perf.md](/tools/code/perf.md)（migrations/cache-misses） | 亲和性效果的观测手段 |
| **系统调用接口** | [../syscall/syscall.md](/concepts/process/syscall.md) | `sched_setaffinity` 是一个系统调用 |

---

## 九、一句话总结

> **线程亲和性 = 在 task_struct 中设一个 `cpus_mask` 位图，通过 `cpus_ptr` 强制性约束调度器只能在指定 CPU 核集合内放置该线程。内核 CFS/EEVDF 调度器为公平性和 CPU 利用率会在 sched_domain 层级间做自底向上的负载均衡，导致线程在核间频繁迁移——每次迁移丢掉养热的 L1/L2 缓存、TLB 条目和分支预测器状态，表现在延迟分布上就是 p99/p999 尖刺。绑核（taskset / sched_setaffinity / pthread_setaffinity_np）配合 NUMA 内存绑定（numactl --membind）锁死数据本地性，再用 isolcpus / nohz_full / 中断亲和性层层隔离把核清空，是低延迟系统消除尾部抖动的标准配方。但绑核是低延迟优化而非吞吐银弹——绑错超线程、核不够、忘绑 NUMA、拿它治伪共享都会适得其反，必须测量驱动。**

