[toc]

kernel/time/sched_clock.c 调度器时钟(Scheduler Clock) 高性能的调度与追踪时间戳

历史与背景

这项技术是为了解决什么特定问题而诞生的?

kernel/time/sched_clock.c 提供的核心功能 sched_clock() 是为了解决内核中一个对性能极其敏感的需求:以极低的开销,获取一个高分辨率、单调递增的时间戳

jiffies 虽然开销低,但其分辨率太差(毫秒级),完全无法满足现代调度器的需求。特别是对于CFS(完全公平调度器),它需要精确地知道一个任务在CPU上到底运行了多少纳秒,以便公平地更新其虚拟运行时间(vruntime)。如果时间测量不准,CFS的公平性就无从谈起。

此外,内核内部的其他高性能子系统也需要类似的时间戳:

  1. 性能追踪与分析:像ftraceperf这样的工具,在记录内核事件时,需要为每个事件打上一个极其精确的时间戳,以便分析微秒甚至纳秒级的性能瓶颈。
  2. 锁竞争分析:内核的锁调试器需要精确测量一个任务在等待一个自旋锁上花费了多长时间。

sched_clock() 就是为了满足这些场景而设计的。它被定位为一个“快速但不一定完美”的时钟,其首要设计目标是读取速度

它的发展经历了哪些重要的里程碑或版本迭代?

sched_clock() 的发展史,是一部内核与底层硬件时钟源(clocksources)不断“斗智斗勇”的历史,尤其是在x86平台上。

  • TSC的引入:在x86架构上,最快、分辨率最高的时钟源是CPU的时间戳计数器(Timestamp Counter, TSC)。早期的sched_clock()实现直接依赖于此。
  • “不稳定TSC”问题:这是一个巨大的挑战,也是sched_clock()复杂化的主要原因。在多核、多插槽(multi-socket)、支持变频(frequency scaling)和深度睡眠状态的现代CPU上,TSC会变得“不稳定”:
    • 不同CPU核心的TSC可能没有同步,导致一个进程在核心间迁移后,时间戳“回退”或“跳跃”。
    • CPU频率变化时,TSC的递增速率可能也会变化。
    • CPU进入深度睡眠状态后,TSC可能会停止递增。
  • 时钟稳定化与包装:为了解决这些问题,内核发展出了一套复杂的机制。sched_clock.c中的代码会与时钟源子系统(kernel/time/clocksource.c)协作,在系统启动时对硬件TSC进行检查。如果TSC是稳定且同步的(现代CPU大多如此),sched_clock()就会直接使用它。如果不是,内核会通过复杂的算法对其进行“包装”和“校准”,或者干脆放弃TSC,转而使用一个更慢但更稳定的硬件时钟源(如HPET)。
  • 作为通用接口sched_clock() 已经从一个调度器专用的内部函数,演变为一个内核通用的、代表“当前CPU上最快的单调时钟”的接口。

目前该技术的社区活跃度和主流应用情况如何?

sched_clock() 是内核时间子系统和调度器子系统的绝对核心

  • 主流应用:它被内核中所有对性能和延迟极其敏感的代码路径所使用:
    • CFS调度器:在每次上下文切换和时钟中断时,都会调用sched_clock()来精确更新进程的运行时间统计。
    • 内核追踪框架(ftrace, perf):所有追踪事件的时间戳都默认来自sched_clock()
    • 自旋锁和互斥锁的慢速路径:用于测量锁的争用时间。

核心原理与设计

它的核心工作原理是什么?

sched_clock() 本身是一个函数,而不是一个像jiffies那样的全局变量。它的核心原理是提供一个到系统底层最快、最稳定的硬件计数器的抽象访问接口

  1. 抽象层sched_clock() 是一个抽象函数。它内部会调用当前系统选定的、最合适的底层时钟源读取函数。
  2. 硬件时钟源
    • 在现代x86系统上,这通常是直接读取TSC寄存器(通过rdtsc汇编指令)。
    • 在ARM64系统上,这通常是读取通用的系统计数器(CNTVCT_EL0)。
    • 在其他架构上,则是该架构提供的最高效的计数器。
  3. 单调性与Per-CPU特性
    • sched_clock() 保证在其返回的值在同一个CPU上是单调递增的。内核通过软件逻辑确保即使底层硬件计数器发生回绕,sched_clock()的返回值也不会回退。
    • 一个至关重要的特性sched_clock() 不保证不同CPU之间是同步的。在同一物理时刻,CPU0和CPU1上调用sched_clock()返回的值可能会有微小的偏差。这是为了追求极致的读取速度而做出的权衡,因为它避免了昂贵的跨CPU同步操作。
  4. 纳秒单位:无论底层的硬件计数器是什么频率,sched_clock() 都会通过乘法和移位运算,将其转换为标准的纳秒单位返回。

它的主要优势体现在哪些方面?

  • 极低的读取开销:这是其最重要的优势。在理想情况下(如稳定的TSC),调用sched_clock()可能只涉及几条汇编指令,几乎和读取一个内存变量一样快。
  • 纳秒级高分辨率:提供了内核能从硬件获取的最高时间分辨率。
  • 单调性:保证了在单个CPU上的时间测量不会倒退,非常适合测量时间间隔。

它存在哪些已知的劣势、局限性或在特定场景下的不适用性?

  • 缺乏跨CPU同步保证:这是其最大的“劣势”或说“设计特点”。不能简单地用CPU1上的sched_clock()值减去CPU0上的值来精确测量跨CPU的事件延迟。
  • 非全局稳定:其起点(0点)是任意的,且在系统重启后会变化。它只适合测量时间差,不适合表示绝对时间点
  • 不是定时器:它是一个读取当前时间的工具,本身不提供任何在未来某个时间点触发事件的功能。

使用场景

在哪些具体的业务或技术场景下,它是首选解决方案?

sched_clock() 是内核内部在性能敏感路径上进行高精度时间间隔测量的首选方案。开发者通常不直接调用它,而是通过使用依赖于它的子系统来间接使用。

  • CFS更新vruntime:这是最核心的场景。当一个任务将要被换下CPU时,调度器会这样计算它本次的运行时间:delta_exec = sched_clock_cpu(cpu) - current->sched_info.last_arrival;
  • ftrace记录事件:当一个被追踪的内核函数被调用时,ftrace会立即调用sched_clock()来获取事件的起始时间戳。
  • 测量代码段执行时间:在内核中进行性能分析时,开发者会使用 start = sched_clock(); ... code ...; end = sched_clock(); duration = end - start; 来精确测量代码执行耗时。

是否有不推荐使用该技术的场景?为什么?

  • 需要跨CPU同步的场景:如果需要测量一个事件在CPU0上开始,在CPU1上结束的时间间隔,使用sched_clock()会引入误差。这种场景应该使用ktime_get(),它基于CLOCK_MONOTONIC,是全局同步的,但开销也更高。
  • 需要设置未来事件(定时器):应使用hrtimerstimer_list
  • 需要与用户空间或网络对端同步的绝对时间:应使用基于CLOCK_REALTIME的时间API。

对比分析

请将其 与 其他相似技术 进行详细对比。

特性 sched_clock() jiffies ktime (基于 CLOCK_MONOTONIC)
核心用途 极低开销高精度时间间隔测量 低分辨率的内核内部时间流逝和定时器基础 全局同步高精度时间戳和定时器基础
分辨率 (纳秒级)。 (毫秒级, 由HZ决定)。 (纳秒级)。
开销 极低 极低 (读取全局变量)。 中等 (比sched_clock高,需要处理时钟源回绕和同步)。
跨CPU同步性 不保证 保证 (因为是单一全局变量)。 保证 (核心设计目标之一)。
单调性 保证 (在单个CPU上)。 保证 (在回绕前)。 保证
适用场景 调度器、ftrace、内核性能微调。 低精度超时、传统内核定时器。 高精度定时器(hrtimers)、需要跨CPU同步的精确测量。

include/linux/sched_clock.h

clock_read_data

  1. sched_clock_register时,写入这块数据内容
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
/**
* struct clock_read_data - 从 sched_clock() 读取所需的数据
*
* @epoch_ns:上次更新时的 sched_clock() 值
* @epoch_cyc:上次更新的 clock cycle 值。
* @sched_clock_mask:非 64 位 clocks的 2 补码减法的 Bitmask。
* @read_sched_clock: Current clock source (或暂停时的 dummy source)。
* @mult:缩放数学转换的乘数。
* @shift:缩放数学转换的 Shift 值。
*
* 更新此结构时必须小心;它被一些非常热门的代码路径读取。它占用 <=40 字节,当与用于同步访问的 seqcount 结合使用时,可以轻松放入 64 字节的缓存行中。
*/
struct clock_read_data {
u64 epoch_ns;
u64 epoch_cyc;
u64 sched_clock_mask;
u64 (*read_sched_clock)(void);
u32 mult;
u32 shift;
};

local_clock

1
2
3
4
static __always_inline u64 local_clock(void)
{
return sched_clock();
}

kernel/time/sched_clock.c

clock_data

  1. sched_clock_register时,写入这块数据内容.默认的clock_data只有mult和read_sched_clock
1
2
3
4
5
static struct clock_data cd ____cacheline_aligned = {
.read_data[0] = { .mult = NSEC_PER_SEC / HZ,
.read_sched_clock = jiffy_sched_clock_read, },
.actual_read_sched_clock = jiffy_sched_clock_read,
};

jiffy_sched_clock_read

1
2
3
4
5
6
7
8
static u64 notrace jiffy_sched_clock_read(void)
{
/*
* 我们不需要在 32 位架构上使用 get_jiffies_64,因为我们在 BITS_PER_LONG 上注册
*/
return (u64)(jiffies - INITIAL_JIFFIES);
}

__sched_clock 返回时钟

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
static __always_inline unsigned long long __sched_clock(void)
{
struct clock_read_data *rd;
unsigned int seq;
u64 cyc, res;
//顺序锁获取时,被写入修改则重新获取
do {
seq = raw_read_seqcount_latch(&cd.seq); //顺序锁锁存当前值
/* (seq & 1) 的作用是根据序列计数器的最低位选择当前有效的缓冲区。
这种机制可以在写入数据时切换缓冲区,从而避免读写冲突。
由于cd.read_data[1]未定义无效 */
rd = cd.read_data + (seq & 1); //读数据
//read_sched_clock = jiffy_sched_clock_read()
cyc = (rd->read_sched_clock() //读取当前的调度时钟周期计数值
- rd->epoch_cyc) & //最后一次更新时的时钟周期值
rd->sched_clock_mask; //非 64bitclocks 的 2 补码减法的位掩码
//cyc_to_ns() = (cyc * mult) >> shift;
res = rd->epoch_ns + cyc_to_ns(cyc, rd->mult, rd->shift);
} while (raw_read_seqcount_latch_retry(&cd.seq, seq)); //顺序锁结束锁存状态

return res;
}

sched_clock 返回调度时间值

1
2
3
4
5
6
7
8
9
10
11
12
13
14
unsigned long long notrace sched_clock(void)
{
unsigned long long ns;
preempt_disable_notrace();
/*
* __sched_clock() 的所有内容都是seqcount_latch读者关键部分,但依赖于未插桩的原始帮助程序。
* 对于 KCSAN,将 __sched_clock() 中的所有访问标记为原子。
*/
kcsan_nestable_atomic_begin();
ns = __sched_clock();
kcsan_nestable_atomic_end();
preempt_enable_notrace();
return ns;
}

update_clock_read_data 更新读取时钟所需的数据

  • 它通过双缓冲机制(odd/even 数据副本)和序列计数器(sequence counter)来确保在多核或中断上下文中读取时钟数据的安全性和一致性。该机制特别适用于高并发环境.
  1. 双缓冲机制:
    • cd.read_data[0] 和 cd.read_data[1] 分别表示偶数副本和奇数副本。
    • 在更新过程中,读者会被引导到奇数副本(read_data[1]),而偶数副本(read_data[0])则被安全地更新。
    • 更新完成后,读者会被切换回偶数副本,奇数副本作为备用
  2. 序列计数器的使用
    • write_seqcount_latch_begin 和 write_seqcou`nt_latch_end 用于管理序列计数器,确保读者在更新过程中不会观察到不一致的数据。
    • write_seqcount_latch 用于切换读者的目标副本(从奇数副本切换回偶数副本)。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
/*
* 更新读取时钟所需的数据。
*
* sched_clock() 永远不会观察到不匹配的数据,即使从 NMI 调用也是如此。
* 我们通过维护数据的奇数/偶数副本并使用 sequence counter 将 sched_clock()
* 引导到一个或另一个来实现这一点。为了尽可能保留 sched_clock() 的数据缓存配置文件,
* 系统在更新完成时恢复为偶数副本;奇数副本*仅在*更新期间使用。
*/
static void update_clock_read_data(struct clock_read_data *rd)
{
/*将读者引导到奇数副本(read_data[1]) */
write_seqcount_latch_begin(&cd.seq);

/* 更新偶数副本(read_data[0]),确保其数据是最新的 */
cd.read_data[0] = *rd;

/* 将读者切换回偶数副本*/
write_seqcount_latch(&cd.seq);

/* 更新奇数副本(read_data[1]),使其与偶数副本保持一致*/
cd.read_data[1] = *rd;

/* 结束更新过程 */
write_seqcount_latch_end(&cd.seq);
}

sched_clock_register 调度时钟注册

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
void __init
sched_clock_register(u64 (*read)(void), int bits, unsigned long rate)
{
u64 res, wrap, new_mask, new_epoch, cyc, ns;
u32 new_mult, new_shift;
unsigned long r, flags;
char r_unit;
struct clock_read_data rd;
/* 如果当前时钟的频率(cd.rate)高于新时钟的频率(rate),直接返回,不进行注册。这是为了确保系统始终使用更高精度的时钟源 */
if (cd.rate > rate)
return;

/* 无法注册 sched_clock 和 Interrupts 开启 */
local_irq_save(flags);

/* 计算 mult/shift 以将计数器刻度转换为 ns。 */
clocks_calc_mult_shift(&new_mult, &new_shift, rate, NSEC_PER_SEC, 3600);

new_mask = CLOCKSOURCE_MASK(bits);
cd.rate = rate;

/* 计算时钟在溢出前可以运行的最大时间(以纳秒为单位) */
wrap = clocks_calc_max_nsecs(new_mult, new_shift, 0, new_mask, NULL);
/* 将溢出时间转换为内核时间格式(ktime_t)并存储在 cd.wrap_kt 中 */
cd.wrap_kt = ns_to_ktime(wrap);

rd = cd.read_data[0];

/* 更新时钟元数据*/
new_epoch = read(); /* 获取当前时钟的计数器值 */
cyc = cd.actual_read_sched_clock();
ns = rd.epoch_ns + cyc_to_ns((cyc - rd.epoch_cyc) & rd.sched_clock_mask, rd.mult, rd.shift);
cd.actual_read_sched_clock = read;

rd.read_sched_clock = read;
rd.sched_clock_mask = new_mask;
rd.mult = new_mult;
rd.shift = new_shift;
rd.epoch_cyc = new_epoch;
rd.epoch_ns = ns;
/* 更新时钟读取函数 */
update_clock_read_data(&rd);

/* 启动定时器 */
if (sched_clock_timer.function != NULL) {
/* update timeout for clock wrap */
hrtimer_start(&sched_clock_timer, cd.wrap_kt,
HRTIMER_MODE_REL_HARD);
}

r = rate;
if (r >= 4000000) {
r = DIV_ROUND_CLOSEST(r, 1000000);
r_unit = 'M';
} else if (r >= 4000) {
r = DIV_ROUND_CLOSEST(r, 1000);
r_unit = 'k';
} else {
r_unit = ' ';
}

/* 计算此计数器的 ns 分辨率 */
res = cyc_to_ns(1ULL, new_mult, new_shift);

pr_info("sched_clock: %u bits at %lu%cHz, resolution %lluns, wraps every %lluns\n",
bits, r, r_unit, res, wrap);

/* 如果时钟频率足够高(≥ 1 MHz),启用中断时间统计功能 */
if (irqtime > 0 || (irqtime == -1 && rate >= 1000000))
enable_sched_clock_irqtime();

local_irq_restore(flags);

pr_debug("Registered %pS as sched_clock source\n", read);
}

update_sched_clock 原子更新 sched_clock() epoch。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
/*
* 原子更新 sched_clock() epoch。
*/
static void update_sched_clock(void)
{
u64 cyc;
u64 ns;
struct clock_read_data rd;

rd = cd.read_data[0];

cyc = cd.actual_read_sched_clock();
ns = rd.epoch_ns + cyc_to_ns((cyc - rd.epoch_cyc) & rd.sched_clock_mask, rd.mult, rd.shift);

rd.epoch_ns = ns;
rd.epoch_cyc = cyc;

update_clock_read_data(&rd);
}

generic_sched_clock_init

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
void __init generic_sched_clock_init(void)
{
/*
* 如果此时未提供 sched_clock() 函数,请将其设为最后一个函数。
*/
if (cd.actual_read_sched_clock == jiffy_sched_clock_read)
sched_clock_register(jiffy_sched_clock_read, BITS_PER_LONG, HZ);

update_sched_clock();

/*
* 启动计时器以保持 sched_clock() 正确更新并设置初始纪元。
*/
hrtimer_setup(&sched_clock_timer, sched_clock_poll, CLOCK_MONOTONIC, HRTIMER_MODE_REL_HARD);
hrtimer_start(&sched_clock_timer, cd.wrap_kt, HRTIMER_MODE_REL_HARD);
}

Scheduler Clock Suspend/Resume Handling (sched_clock_suspend, sched_clock_resume)

本代码片段实现了 Linux 内核中 sched_clock(调度器时钟)在系统核心挂起(suspend)和恢复(resume)期间的关键处理逻辑。其核心功能是通过 syscore_ops 框架,在系统进入睡眠前“冻结”sched_clock 的值,使其在整个挂起过程中返回一个固定不变的时间戳;在系统恢复后,再重新读取硬件时钟,并“解冻”sched_clock,使其恢复正常计时。这确保了内核调度器及其他依赖 sched_clock 的子系统在电源状态转换期间看到的时间是一致且单调的。

实现原理分析

此机制的核心是一种基于函数指针替换的动态行为改变技术,用于在不修改 sched_clock() hot-path 代码的情况下,改变其在特定状态下的行为。

  1. Syscore Ops 注册: sched_clock_syscore_init 通过 register_syscore_opssched_clock_ops 注册到内核的系统核心操作链表中。syscore_ops 是一种特殊的电源管理回调,它在设备驱动挂起之前(early suspend)和设备驱动恢复之后(late resume)执行,用于处理最核心的、与具体设备无关的系统状态。

  2. 时间冻结 (sched_clock_suspend):

    • update_sched_clock(): 首先调用此函数,将当前最新的硬件时钟值同步到 sched_clock 的内部状态变量(如 epoch_cyc)中。这捕获了系统挂起前的最后一个精确时间点。
    • hrtimer_cancel(&sched_clock_timer): 取消用于周期性更新 sched_clock(例如处理硬件计数器回绕)的高精度定时器。因为系统即将睡眠,这个定时器不再需要。
    • rd->read_sched_clock = suspended_sched_clock_read;: 这是最关键的一步。它将 sched_clock 内部实际用于读取时间的函数指针,从正常的硬件读取函数(cd.actual_read_sched_clock)切换到了一个特殊的 suspended_sched_clock_read 函数。
  3. 返回冻结值 (suspended_sched_clock_read): 这个被换上的函数行为极其简单:它不访问任何硬件,而是直接返回在 sched_clock_suspend 中捕获并保存的 epoch_cyc 值。因此,在系统挂起的整个过程中,任何对 sched_clock() 的调用都将返回同一个、被“冻结”的时间戳。

  4. 时间解冻 (sched_clock_resume):

    • rd->epoch_cyc = cd.actual_read_sched_clock();: 恢复后的第一件事就是立即读取底层的硬件时钟,并用这个全新的值更新 epoch_cyc,为 sched_clock 建立一个新的时间基点。
    • hrtimer_start(...): 重新启动之前被取消的周期性更新定时器。
    • rd->read_sched_clock = cd.actual_read_sched_clock;: 将函数指针切换回正常的硬件读取函数,sched_clock 从此恢复动态计时。

代码分析

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
// suspended_sched_clock_read: 在时钟挂起期间使用的时钟读取函数。
// 该函数被 notrace 标记,以防止被追踪工具(如 ftrace)插桩。
/*
* 该函数的作用是让 sched_clock() 看起来像是时钟在其最后一次更新时就停止了计数。
*
* 它必须仅从 sched_clock() 内部的临界区被调用。它依赖于该临界区末尾的
* read_seqcount_retry() 来确保观察到的是 'epoch_cyc' 的正确副本。
*/
static u64 notrace suspended_sched_clock_read(void)
{
// 读取 seqlock 锁的当前序列号。
unsigned int seq = read_seqcount_latch(&cd.seq);

// 根据序列号的奇偶性,从双缓冲的 read_data 数组中选择一个副本,
// 并返回其中存储的、在挂起前捕获的周期计数值(epoch_cyc)。
return cd.read_data[seq & 1].epoch_cyc;
}

// sched_clock_suspend: sched_clock 的系统核心挂起回调函数。
int sched_clock_suspend(void)
{
// 获取指向主时钟读取数据区的指针。
struct clock_read_data *rd = &cd.read_data[0];

// 更新一次 sched_clock,以捕获进入挂起前的最后一个精确时间。
update_sched_clock();
// 取消用于周期性更新 sched_clock 的高精度定时器。
hrtimer_cancel(&sched_clock_timer);
// 将实际的时钟读取函数指针替换为“返回冻结值”的函数。
rd->read_sched_clock = suspended_sched_clock_read;

return 0;
}

// sched_clock_resume: sched_clock 的系统核心恢复回调函数。
void sched_clock_resume(void)
{
// 获取指向主时钟读取数据区的指针。
struct clock_read_data *rd = &cd.read_data[0];

// 立即调用实际的硬件读取函数,用恢复后的第一个时钟值更新时间基点。
rd->epoch_cyc = cd.actual_read_sched_clock();
// 重新启动周期性更新 sched_clock 的高精度定时器。
hrtimer_start(&sched_clock_timer, cd.wrap_kt, HRTIMER_MODE_REL_HARD);
// 将时钟读取函数指针恢复为正常的硬件读取函数。
rd->read_sched_clock = cd.actual_read_sched_clock;
}

// 定义一个 syscore_ops 结构体,包含挂起和恢复的回调函数指针。
static struct syscore_ops sched_clock_ops = {
.suspend = sched_clock_suspend,
.resume = sched_clock_resume,
};

// sched_clock_syscore_init: sched_clock 系统核心操作的初始化函数。
static int __init sched_clock_syscore_init(void)
{
// 向内核注册上述的 syscore_ops 结构体。
register_syscore_ops(&sched_clock_ops);

return 0;
}
// 将 sched_clock_syscore_init 注册为一个设备初始化调用,在内核启动的相应阶段执行。
device_initcall(sched_clock_syscore_init);