背景

nvcsw(自愿上下文切换的次数Number of Voluntary Context Switches)和 nivcsw(非自愿上下文切换的次数Number of InVoluntary Context Switches)是用于判断 CPU-bound 的一种性能指标,可以在 proc 文件系统中查看统计信息:/proc/[pid]/sched

在早期的文章 Linux 内核的 CFS 任务调度 中也提到,nvcsw 和 nivcsw 的计数是在 __schedule() 流程中完成的。本文继续跟踪 __schedule() 的调用路径,简单探讨一下这两个计数器的统计方式。

__schedule()

下面是 Linux v6.4.8 版本的 __schedule() 实现,省略了无关细节:

static void __sched notrace __schedule(unsigned int sched_mode)
{
        struct task_struct *prev, *next;
        unsigned long *switch_count;
        unsigned long prev_state;

        // ...

        prev = rq->curr;

        // ...

        switch_count = &prev->nivcsw;

        prev_state = READ_ONCE(prev->__state);
        // 非抢占标记且进程状态不是 TASK_RUNNING(只有这个状态的数值为 0)
        if (!(sched_mode & SM_MASK_PREEMPT) && prev_state) {
                // ...

                deactivate_task(rq, prev, DEQUEUE_SLEEP | DEQUEUE_NOCLOCK);

                // ...

                switch_count = &prev->nvcsw;
        }

        next = pick_next_task(rq, prev, &rf);
        clear_tsk_need_resched(prev);
        clear_preempt_need_resched();

        // ...

        if (likely(prev != next)) {
                rq->nr_switches++;

                // ...

                ++*switch_count;

                // ...

                rq = context_switch(rq, prev, next, &rf);
        } else {
                // ...
        }
}

从代码实现可以看出,只要函数传参不包含 SM_MASK_PREEMPT 标记,并且提前设置了合适的进程状态如 TASK_UNINTERRUPTIBLE,本次调度将会视为自愿的上下文切换,否则就是非自愿的上下文切换。

NOTES:

  • 反过来说,调度前仍处于 TASK_RUNNING 状态的进程肯定是非自愿的上下文切换。
  • 不管自愿与否,调度的具体操作都一样(pick next task + deactivate task)。

schedule()

自愿上下文切换的主要接口就是 schedule(),即主动调度:

asmlinkage __visible void __sched schedule(void)
{
        struct task_struct *tsk = current;

        sched_submit_work(tsk);
        // 第一次调度不检查 TIF_NEED_RESCHED 标记
        do {
                preempt_disable();
                // 肯定是传递 SM_NONE 标记
                __schedule(SM_NONE);
                sched_preempt_enable_no_resched();
        } while (need_resched());
        sched_update_worker(tsk);
}

所以下面这些(依赖于 schedule() 函数的)做法都是自愿行为:

  • 主动调度:set_current_state + schedule + __set_current_state
  • 等待队列:prepare_to_wait + schedule + finish_wait
  • 等待完成:wait_for_completion
  • 定时任务:上面的 schedule 再换成 schedule_timeout
  • 统计封装:上面的 schedule 再换成 io_schedule 或者 blk_io_schedule 等等。

NOTES:

  • 内核态使用的 yield() 函数是非自愿切换,因为强制设置当前进程为 TASK_RUNNING 状态。
  • 用户态使用的 sched_yield() 系统调用是非自愿切换,因为进程肯定是 TASK_RUNNING 状态。
  • 用户态使用的阻塞网络 IO (例:recvmsg())以及睡眠等待(例:nanosleep())是自愿切换。

抢占模型

下面是 kernel/sched/core.c 的一些注释,直接说明了 Linux 三种抢占模型的区别:

/*
 * SC:cond_resched
 * SC:might_resched
 * SC:preempt_schedule
 * SC:preempt_schedule_notrace
 * SC:irqentry_exit_cond_resched
 *
 *
 * NONE:
 *   cond_resched               <- __cond_resched
 *   might_resched              <- RET0
 *   preempt_schedule           <- NOP
 *   preempt_schedule_notrace   <- NOP
 *   irqentry_exit_cond_resched <- NOP
 *
 * VOLUNTARY:
 *   cond_resched               <- __cond_resched
 *   might_resched              <- __cond_resched
 *   preempt_schedule           <- NOP
 *   preempt_schedule_notrace   <- NOP
 *   irqentry_exit_cond_resched <- NOP
 *
 * FULL:
 *   cond_resched               <- RET0
 *   might_resched              <- RET0
 *   preempt_schedule           <- preempt_schedule
 *   preempt_schedule_notrace   <- preempt_schedule_notrace
 *   irqentry_exit_cond_resched <- irqentry_exit_cond_resched
 */

(有必要吐槽一下,这么好的总结居然放在源码文件里,还藏在第 8000+ 行?)

内核态抢占

在完全抢占(CONFIG_PREEMPT)的情况下,根据上面注释展开的调用栈:

preempt_schedule
    preempt_schedule_common
        __schedule(SM_PREEMPT)

preempt_schedule_notrace
    __schedule(SM_PREEMPT)

irqentry_exit_cond_resched
    raw_irqentry_exit_cond_resched
        preempt_schedule_irq
            __schedule(SM_PREEMPT)

调度传递了 SM_PREEMPT 标记,也就是说内核态抢占的部分会被标记为非自愿上下文切换。

用户态抢占

在禁止内核抢占(CONFIG_PREEMPT_NONE)的情况下,系统调用或中断执行退出到用户态前:

exit_to_user_mode_prepare
    exit_to_user_mode_loop
        schedule()

由于要退回到用户态需要有前提保证状态为 TASK_RUNNING,因此仍然标记为非自愿上下文切换。

cond_resched()

cond_resched() 语义比较特殊(conditional reschedule),可以认为是有 TIF_NEED_RESCHED 标记的情况下「提前主动抢占」,算是一种降低延迟的优化手段。所以这种既主动又抢占的方式到底算是自愿还是非自愿?看流程:

cond_resched
    __cond_resched
        if should_resched:
            preempt_schedule_common()

结合上面内核态抢占分析过的流程得知,这种方式调度标记为非自愿上下文切换。

NOTES:

  • 完全抢占模型的前提下,该函数是空实现,因为内核态基本上随时都可以抢占了。
  • 自愿抢占模型的前提下,might_resched() 就是 cond_resched(),注意这是非自愿切换。
  • 上面也有提到 IRQ(中断)专用的 cond_resched 版本。
  • 块设备 IO 经常使用这个函数,可能是在追求「high speed, low drag」。

总结

只要是抢占,都会标记为非自愿上下文切换。但是如果是在统计非人为因素这段代码不是我写的造成的调度,至少还需要在 nivcsw 的基础上进一步过滤掉自己使用的 cond_resched()(还有 yield(),但是没有使用它的理由)等容易望文生义的函数调用统计;即使是只考虑用户态进程,也至少要排除掉 sched_yield()

最后说句不好听的,我个人觉得这种性能指标其实也没啥参考价值,它能体现调度的基本情况(比如每个进程或者线程的抢占调度的次数和占比,自己做时间窗口还能统计出长度),但也就仅此而已了。