2、内核抢占模型:Linux内核的三种抢占模型与抢占点分布

在Linux内核中,抢占模型决定了内核态代码在何种时机可以被更高优先级的任务打断。抢占点的密度直接影响系统的调度延迟和吞吐量。本章深入剖析三种内核抢占模型的设计哲学、抢占点分布规律及其对实时性的影响。

2.1 抢占模型概述

Linux内核自2.6内核引入可配置的抢占模型,通过内核编译选项CONFIG_PREEMPT_*控制。三种模型在内核态抢占时机上存在本质差异:

模型 内核配置选项 抢占点密度 调度延迟典型范围 适用场景
PREEMPT_NONE CONFIG_PREEMPT_NONE 极低(仅返回用户态) 10ms ~ 100ms+ 服务器、批处理、吞吐优先
PREEMPT_VOLUNTARY CONFIG_PREEMPT_VOLUNTARY 中(显式抢占点) 1ms ~ 10ms 桌面、通用负载
PREEMPT CONFIG_PREEMPT 高(几乎任意内核位置) 100μs ~ 1ms 嵌入式、软实时、低延迟
关键认知:抢占模型不改变内核逻辑的正确性,只改变调度延迟的上界。PREEMPT_NONE下,一个在内核中长时间运行的系统调用可能阻塞所有其他用户态进程长达数十毫秒。

2.2 三种抢占模型的抢占点分布

2.2.1 PREEMPT_NONE:无抢占内核

这是传统的Linux内核模型,也是默认的服务器配置。内核代码在执行期间完全不可被抢占,只有当内核即将返回用户态时(ret_to_user路径),才会检查TIF_NEED_RESCHED标志并执行调度。

  • 抢占点位置:仅在系统调用/中断返回用户态时。
  • 临界区长度:无上限,一个系统调用可以独占CPU直到完成。
  • 调度延迟:取决于最长内核路径的执行时间。
  • 优势:零抢占开销,吞吐量最高。
// PREEMPT_NONE 下的调度检查点(arch/x86/entry/entry_64.S)
ret_to_user:
    // 检查 TIF_NEED_RESCHED
    testl   $_TIF_NEED_RESCHED, %edi
    jnz     ret_to_user_work
    // 返回用户空间
    iretq

2.2.2 PREEMPT_VOLUNTARY:自愿抢占内核

在PREEMPT_NONE基础上,内核开发者手动在可能长时间运行的内核路径中插入显式的cond_resched()调用。这些位置称为自愿抢占点。

  • 抢占点位置:循环迭代、内存分配、文件系统操作等耗时路径。
  • 临界区长度:受限于相邻两个cond_resched()之间的代码长度。
  • 调度延迟:通常降低一个数量级,但仍不可预测。
  • 优势:对吞吐影响小,延迟改善明显。
// 内核中典型的 cond_resched() 插入点
// mm/page_alloc.c: 在大量页面分配循环中
for (i = 0; i < nr_pages; i++) {
    page = alloc_pages(gfp_mask, order);
    if (!page)
        goto failed;
    // 每分配一定数量页面后检查是否需要调度
    if (!(i & 0x3ff))
        cond_resched();
}

2.2.3 PREEMPT:完全抢占内核

这是最激进的抢占模型。内核在几乎任意位置都可以被抢占,只要当前不在持有spinlock等自旋锁的临界区内。内核通过在每个指令边界检查preempt_count实现。

  • 抢占点位置:每个指令边界(通过preempt_enable()/preempt_disable()控制)。
  • 临界区长度:仅限自旋锁保护的极短代码段。
  • 调度延迟:可预测,通常在微秒级。
  • 优势:最低的调度延迟,适合实时应用。
// PREEMPT 模型下的抢占检查宏
// include/linux/preempt.h
#define preempt_enable() \
do { \
    barrier(); \
    if (unlikely(preempt_count_dec_and_test())) \
        __preempt_schedule(); \
} while (0)

// __preempt_schedule() 会调用 schedule() 进行进程切换

2.3 抢占点分布对比SVG图

下图直观展示了三种模型下抢占点在内核执行路径上的分布密度差异:

三种抢占模型的抢占点分布对比 抢占点 自旋锁临界区(不可抢占) cond_resched() 自愿抢占点 PREEMPT_NONE 整个内核路径为不可抢占临界区 返回用户态 PREEMPT_VOLUNTARY cond_resched() 将内核路径分割为多个短临界区 PREEMPT 仅自旋锁临界区内不可抢占,其余位置均可被高优先级任务打断 抢占点密度与调度延迟的关系 • PREEMPT_NONE:抢占点仅1个(返回用户态),最长临界区 = 整个内核路径 • PREEMPT_VOLUNTARY:抢占点约5~20个(cond_resched),最长临界区 = 相邻自愿抢占点间距 • PREEMPT:抢占点数百~数千个(preempt_enable),最长临界区 = 自旋锁持有时间(通常<10μs) 调度延迟上界 ≈ max(临界区长度) + 调度器切换开销 因此,PREEMPT模型将调度延迟从毫秒级降低到微秒级,代价是每次preempt_enable()的原子操作开销。

2.4 抢占点的内核实现机制

2.4.1 preempt_count 计数器

完全抢占内核通过preempt_count(每个CPU的thread_info字段)控制抢占使能状态:

preempt_count 位域 作用 抢占是否允许
0(PREEMPT_ACTIVE) 标记当前正在执行抢占调度 不允许(防止递归)
1~7(PREEMPT_BITS) 显式抢占禁用计数 count > 0 时不允许
8~15(SOFTIRQ_BITS) 软中断上下文计数 不允许
16~23(HARDIRQ_BITS) 硬中断上下文计数 不允许
24~31(NMI_BITS) NMI上下文计数 不允许
// preempt_count 的典型操作路径
// 1. 进入自旋锁临界区
void __lockfunc _raw_spin_lock(raw_spinlock_t *lock)
{
    preempt_disable();  // preempt_count += 1
    // ... 实际锁操作 ...
}

// 2. 离开自旋锁临界区
void __lockfunc _raw_spin_unlock(raw_spinlock_t *lock)
{
    // ... 解锁操作 ...
    preempt_enable();   // preempt_count -= 1,若变为0则检查调度
}

2.4.2 抢占点的触发路径

当preempt_count降为0且TIF_NEED_RESCHED被设置时,内核通过以下路径执行抢占:

  1. 中断返回路径:中断处理完成后,检查preempt_count和TIF_NEED_RESCHED。
  2. preempt_enable():显式启用抢占时触发。
  3. cond_resched():自愿抢占点,仅在PREEMPT_VOLUNTARY中有效。
  4. spin_unlock():释放自旋锁后,若唤醒高优先级任务则触发。
ftrace 观测点:使用 trace_preempt_off 和 trace_preempt_on 事件可以精确追踪抢占禁用/使能事件,定位过长的不可抢占区间。

2.5 抢占模型的选择策略

实际部署中,抢占模型的选择需要权衡吞吐量与调度延迟:

  • 云服务器/数据中心:推荐PREEMPT_NONE,追求最大吞吐,调度延迟由应用层保证。
  • 桌面/交互式系统:推荐PREEMPT_VOLUNTARY,平衡响应速度与整体性能。
  • 音频/视频/工业控制:推荐PREEMPT,需要微秒级调度延迟保证。
  • 硬实时系统:需配合PREEMPT_RT补丁(在PREEMPT基础上进一步降低自旋锁临界区)。
性能陷阱:在PREEMPT模型下,频繁的preempt_enable()/preempt_disable()操作会增加原子指令开销(约10~20ns/次)。对于短临界区密集的驱动代码,吞吐量可能下降5%~15%。使用ftrace的preemptirqsoff跟踪器可以量化这部分开销。

2.6 本章小结

内核抢占模型通过控制抢占点的密度来调节调度延迟的上界。PREEMPT_NONE将抢占点压缩到极致(仅返回用户态),PREEMPT通过preempt_count机制在几乎每个指令边界插入抢占检查,PREEMPT_VOLUNTARY则处于两者之间。理解抢占点的分布规律,是后续使用ftrace定位调度延迟根源的基础——我们将在第3章中,通过ftrace的preemptoff跟踪器实际测量这些抢占点之间的延迟。