Linux 时钟内幕:vDSO、clocksource 与 6 种 CLOCK_* 的代价
很多人不知道:Linux 上 clock_gettime(CLOCK_MONOTONIC) 在现代硬件上的代价大约是 25 纳秒 —— 比一次普通 syscall 快 20 倍。你可以用 strace 验证一件反直觉的事:
$ strace -e clock_gettime ls
# 输出里看不到任何 clock_gettime 调用
ls 内部肯定调过 clock_gettime(看文件时间戳要用),但 strace 一条都没抓到 —— 因为它根本没走 syscall。这个魔法叫 vDSO(virtual Dynamic Shared Object)。
本文从 vDSO 讲起,把 Linux 时钟子系统的关键组件串起来:vDSO 工作原理、9 种 CLOCK_* 的实际代价、底层 clocksource 选择,所有 C 代码可直接编译运行,配 strace / bpftrace / /sys 验证。
环境假设:Linux 5.0+,x86_64,glibc 2.30+。
1. 为什么 syscall 这么贵¶
一次最简单的 syscall(getpid)在现代 Linux 上的代价:
// syscall_cost.c — 测一次 getpid syscall 的耗时
#define _GNU_SOURCE
#include <time.h>
#include <unistd.h>
#include <sys/syscall.h>
#include <stdio.h>
#include <stdint.h>
int main(void) {
struct timespec t0, t1;
const int iters = 5000000;
clock_gettime(CLOCK_MONOTONIC, &t0);
for (int i = 0; i < iters; i++) {
// 直接 syscall,绕过 glibc 的 PID cache
syscall(SYS_getpid);
}
clock_gettime(CLOCK_MONOTONIC, &t1);
int64_t ns = (t1.tv_sec - t0.tv_sec) * 1000000000LL
+ (t1.tv_nsec - t0.tv_nsec);
printf("getpid syscall: %lld ns/call\n", (long long)(ns / iters));
return 0;
}
编译运行:
$ gcc -O2 syscall_cost.c -o syscall_cost && ./syscall_cost
getpid syscall: 420 ns/call
420ns(KPTI 关掉后约 80ns)。代价来自:
- 用户态 ↔ 内核态切换:上下文保存 + 页表切换
- KPTI(Spectre/Meltdown 后的页表隔离)让这个翻 3-5 倍
- 内核处理 + 返回路径
每个 syscall 几百 ns。如果每条日志都 syscall 一次取时间,每秒能跑的日志量直接被限制在百万级。这对高频日志 / tracing / 性能 critical path 是性能上限。
clock_gettime 的解决方案:根本不要走 syscall。
2. vDSO:内核映射到用户态的”代码包”¶
vDSO = virtual Dynamic Shared Object。它是内核启动每个用户进程时,把一段内核维护的代码映射到用户空间一段虚拟地址,用户态可以直接 call,完全不走用户/内核态切换。
证据 1:ldd 显示它,但磁盘上找不到:
$ ldd /bin/ls
linux-vdso.so.1 (0x00007ffc12345000) # ← vDSO
libselinux.so.1 => /lib/x86_64-linux-gnu/libselinux.so.1
libc.so.6 => /lib/x86_64-linux-gnu/libc.so.6
...
$ find / -name 'linux-vdso.so*' 2>/dev/null
# (空 — 磁盘上根本不存在这个文件)
证据 2:每个进程的内存映射里都有一段 [vdso]:
$ cat /proc/self/maps | grep -E 'vdso|vsyscall'
7ffc12345000-7ffc12347000 r-xp 00000000 00:00 0 [vdso]
ffffffffff600000-ffffffffff601000 --xp 00000000 00:00 0 [vsyscall]
2 page (8KB):1 页代码 + 1 页数据。内核用 ELF auxv(辅助向量)告诉用户态它在哪:
// vdso_locate.c — 通过 AT_SYSINFO_EHDR 拿到 vDSO 起始地址
#define _GNU_SOURCE
#include <sys/auxv.h>
#include <stdio.h>
int main(void) {
unsigned long vdso_base = getauxval(AT_SYSINFO_EHDR);
printf("vDSO base: 0x%lx\n", vdso_base);
return 0;
}
$ gcc -O2 vdso_locate.c -o vdso_locate && ./vdso_locate
vDSO base: 0x7ffc12345000
glibc 启动时通过这个 base 解析 vDSO 里的 ELF,找到这几个符号的地址并缓存:
__vdso_clock_gettime__vdso_gettimeofday__vdso_time__vdso_getcpu__vdso_clock_getres
之后用户调 clock_gettime,glibc 直接跳转到 vDSO 那段代码,整个过程没有 syscall。
3. vDSO 怎么读出当前时间¶
vDSO 不是凭空算时间 —— 它需要”现在 TSC 是多少”和”上次 kernel update 时 TSC = X,wall time = Y”两个数据,内核把这些放在一个共享数据页 vdso_data,跟代码页相邻。
伪代码(实际是 arch/x86/entry/vdso/vclock_gettime.c):
// 概念伪代码 — 实际有 seqlock 保护
int64_t __vdso_clock_gettime(clockid_t id) {
int64_t result;
uint32_t seq;
do {
seq = read_seqcount_begin(&vdso_data->seq);
// 1. 从 vdso_data 取内核校准的基准
uint64_t last_tsc = vdso_data->cs.tsc_base;
uint64_t base_ns = vdso_data->cs.nsec_base;
uint32_t mult = vdso_data->cs.mult;
uint32_t shift = vdso_data->cs.shift;
// 2. 直接读 TSC (rdtsc 指令, ~10 cycles, 用户态可执行)
uint64_t now_tsc = __rdtsc();
// 3. 算出从 last update 到现在多少 ns
uint64_t delta_cycles = now_tsc - last_tsc;
uint64_t delta_ns = (delta_cycles * mult) >> shift;
result = base_ns + delta_ns;
} while (read_seqcount_retry(&vdso_data->seq, seq));
return result;
}
关键点:
- rdtsc 是用户态可直接执行的 CPU 指令(不需要内核)。invariant TSC 之后频率稳定,可以直接当时间用。
- mult / shift 是内核校准过的”TSC cycle → nanosecond”转换系数。
- seqlock 保证内核 update
vdso_data时用户态读到一致快照(读时不阻塞,失败重试)。 - 整个 hot path:1 次
rdtsc+ 1 次乘法 + 1 次移位 + 1 次加法 ≈ 25ns。
剩下一个问题:谁来 update vdso_data? 答:内核 timekeeping。每次 timer tick 或 NTP 校准时,内核会刷新 vdso_data.tsc_base / nsec_base。所以用户态读到的时间总是最新的(在 timer tick 频率内)。
4. benchmark:9 种 CLOCK_* 的实际代价¶
测一下哪些 clock 走 vDSO、各自代价多少:
// clock_bench.c — 测各种 CLOCK_* 的耗时
#define _GNU_SOURCE
#include <time.h>
#include <stdio.h>
#include <stdint.h>
typedef struct {
const char *name;
clockid_t id;
} clock_entry;
static int64_t bench(clockid_t id, int iters) {
struct timespec t0, t1, dummy;
clock_gettime(CLOCK_MONOTONIC, &t0);
for (int i = 0; i < iters; i++) {
clock_gettime(id, &dummy);
}
clock_gettime(CLOCK_MONOTONIC, &t1);
int64_t ns = (t1.tv_sec - t0.tv_sec) * 1000000000LL
+ (t1.tv_nsec - t0.tv_nsec);
return ns / iters;
}
int main(void) {
clock_entry clocks[] = {
{ "CLOCK_REALTIME", CLOCK_REALTIME },
{ "CLOCK_REALTIME_COARSE", CLOCK_REALTIME_COARSE },
{ "CLOCK_MONOTONIC", CLOCK_MONOTONIC },
{ "CLOCK_MONOTONIC_COARSE", CLOCK_MONOTONIC_COARSE },
{ "CLOCK_MONOTONIC_RAW", CLOCK_MONOTONIC_RAW },
{ "CLOCK_BOOTTIME", CLOCK_BOOTTIME },
{ "CLOCK_TAI", CLOCK_TAI },
{ "CLOCK_THREAD_CPUTIME_ID", CLOCK_THREAD_CPUTIME_ID },
{ "CLOCK_PROCESS_CPUTIME_ID", CLOCK_PROCESS_CPUTIME_ID },
};
const int iters = 5000000;
printf("Per-call cost over %d iterations:\n\n", iters);
printf(" %-30s %s\n", "clock", "avg ns/call");
printf(" %-30s %s\n", "------------------------------", "-----------");
for (size_t i = 0; i < sizeof(clocks) / sizeof(*clocks); i++) {
int64_t ns = bench(clocks[i].id, iters);
printf(" %-30s %5lld\n", clocks[i].name, (long long)ns);
}
return 0;
}
$ gcc -O2 clock_bench.c -o clock_bench && ./clock_bench
Per-call cost over 5000000 iterations:
clock avg ns/call
------------------------------ -----------
CLOCK_REALTIME 24
CLOCK_REALTIME_COARSE 4
CLOCK_MONOTONIC 25
CLOCK_MONOTONIC_COARSE 4
CLOCK_MONOTONIC_RAW 26
CLOCK_BOOTTIME 27
CLOCK_TAI 25
CLOCK_THREAD_CPUTIME_ID 450
CLOCK_PROCESS_CPUTIME_ID 480
数字是 Sandy Bridge 之后的现代 x86,你的机器可能在 ±50% 浮动。三档分明:
- 走 vDSO 的标准版(
REALTIME / MONOTONIC / MONOTONIC_RAW / BOOTTIME / TAI):~25ns _COARSE变体:~5ns- 不走 vDSO 的(
THREAD/PROCESS_CPUTIME_ID):400-500ns(典型 syscall 代价)
差异 100 倍。如果性能 critical path 用了 CLOCK_PROCESS_CPUTIME_ID,而实际能用 CLOCK_MONOTONIC,是巨大的浪费。
5. CLOCK_* 全集对照¶
| Clock | 含义 | vDSO? | 单调? | NTP 影响 | 典型耗时 |
|---|---|---|---|---|---|
REALTIME | wall clock,UTC | ✓ | ✗(NTP 跳) | ✓ | 25 ns |
REALTIME_COARSE | 同上,jiffies 精度 | ✓ | ✗ | ✓ | 5 ns |
MONOTONIC | 启动后单调,NTP 频率校准 | ✓ | ✓ | ✓(rate adj) | 25 ns |
MONOTONIC_COARSE | 同上,jiffies 精度 | ✓ | ✓ | ✓ | 5 ns |
MONOTONIC_RAW | 单调,纯硬件时间 | ✓ | ✓ | ✗ | 25 ns |
BOOTTIME | 含 suspend 时间的 monotonic | ✓ | ✓ | ✓ | 27 ns |
TAI | UTC + 累计 leap seconds | ✓(5.3+) | ✗ | ✓ | 25 ns |
THREAD_CPUTIME_ID | 当前线程消耗的 CPU 时间 | ✗ | ✓ | ✗ | 450 ns |
PROCESS_CPUTIME_ID | 当前进程消耗的 CPU 时间 | ✗ | ✓ | ✗ | 480 ns |
几个对工程决策有用的细节:
_COARSE 为什么这么快:它不读 TSC、不算 multiplier,只读 vdso_data->wall_time_coarse(内核在 timer tick 里写好的 jiffies 时间),直接返回。精度 ~1-4ms(取决于 CONFIG_HZ)。日志时间戳、5 秒级 timeout 这类场景用它就够,5ns 对 25ns,每秒能跑 2 亿次。
_RAW 跟 MONOTONIC 的真实差别:MONOTONIC 在 NTP 校准时会”调速度”(让 1 秒略快或略慢,平滑追上 UTC);MONOTONIC_RAW 永远按硬件 TSC 节奏走。测纯硬件 benchmark 用 RAW,普通耗时用 MONOTONIC。日常 99% 场景应该用 MONOTONIC,不要因为”RAW 听起来更底层”就用 RAW —— 这会让你的服务跟同机器其他进程慢慢失同步。
BOOTTIME 的场景:笔记本 / 手机 / VM suspend 期间,MONOTONIC 在 Linux 4.0+ 把 suspend 时间也累计了,所以 BOOTTIME 跟 MONOTONIC 在大多数现代系统上行为相同。差别仅在历史 kernel 或某些边界情况。
CPUTIME 系列:是”当前 thread/process 消耗了多少 CPU 时间”,不是 wall time。这个没法走 vDSO —— 需要 kernel 查 task 的 CPU 时间累计,必须 syscall。450-500ns 是它的固定底价。
6. clocksource:vDSO 背后的硬件¶
vDSO 用 rdtsc 读 TSC 当时间源,但 TSC 不是唯一硬件时钟。内核有一个 clocksource 抽象选择当前用哪个:
$ cat /sys/devices/system/clocksource/clocksource0/available_clocksource
tsc hpet acpi_pm
$ cat /sys/devices/system/clocksource/clocksource0/current_clocksource
tsc
几个候选(按现代硬件优先级):
- TSC(Time Stamp Counter):x86 CPU 内置 64-bit 周期计数器。invariant TSC 之后频率稳定,现代 x86 默认全选 TSC。读取一条
rdtsc指令,~10ns。 - HPET(High Precision Event Timer):主板上的独立硬件。需要 MMIO 读,~250-500ns(慢 30 倍)。
- ACPI PM Timer:更老的硬件,~600ns。
- kvm-clock:VM 里的 paravirt clock,host TSC + guest offset。
TSC 不稳定时(旧的多 socket 系统、某些 VM),内核自动 fallback。可以手动切换看效果:
# 需要 root
$ echo hpet | sudo tee /sys/devices/system/clocksource/clocksource0/current_clocksource
hpet
# 重新跑 benchmark
$ ./clock_bench
Per-call cost over 5000000 iterations:
clock avg ns/call
------------------------------ -----------
CLOCK_REALTIME 265 # ← 慢 10 倍
CLOCK_REALTIME_COARSE 5 # ← _COARSE 不变 (读 vDSO 缓存)
CLOCK_MONOTONIC 270
CLOCK_MONOTONIC_COARSE 4
CLOCK_MONOTONIC_RAW 268
...
# 恢复 TSC
$ echo tsc | sudo tee /sys/devices/system/clocksource/clocksource0/current_clocksource
_COARSE 不变,因为它只读 vDSO 缓存的 jiffies 时间,跟硬件 clocksource 无关。其他走 vDSO 路径但需要”现在的硬件时间”,HPET 的 MMIO 读取代价直接体现在结果上。
实战提示:在云上跑 benchmark 之前检查 current_clocksource。如果是 kvm-clock,数字会跟 bare metal TSC 略不同;如果是 acpi_pm 或 hpet,你的服务 clock_gettime 是 250ns 级,有性能优化空间(要么 host 配置好 TSC,要么应用层缓存时间)。
7. 用 bpftrace 验证 vDSO¶
strace 看不到 vDSO(因为不是 syscall),但 bpftrace 可以验证。
验证 A:vDSO 路径下,clock_gettime tracepoint 不会触发
# Terminal 1
$ sudo bpftrace -e 'tracepoint:syscalls:sys_enter_clock_gettime {
@[comm] = count();
}'
Attaching 1 probe...
# Terminal 2 — 跑 benchmark (5M × 9 种 clock = 45M 次调用)
$ ./clock_bench
# 回 Terminal 1, Ctrl-C
^C
@[chronyd]: 8
@[clock_bench]: 10000003 # ← 只有 CPUTIME 系列走 syscall
10M 不是 45M —— 9 种 clock 里只有 2 种(THREAD_CPUTIME_ID 和 PROCESS_CPUTIME_ID)走 syscall,5M × 2 = 10M,正好对得上。其余 35M 次调用走 vDSO,tracepoint 看不见。
验证 B:perf stat 数 syscall
$ perf stat -e syscalls:sys_enter_clock_gettime ./clock_bench
...
Performance counter stats for './clock_bench':
10,002,847 syscalls:sys_enter_clock_gettime
3.85 seconds time elapsed
跟 bpftrace 一致。
8. 实战:何时用哪个 clock¶
按场景决策:
| 场景 | 选哪个 | 理由 |
|---|---|---|
| 测函数耗时(ns 精度) | CLOCK_MONOTONIC | 25ns,单调,NTP 校准平滑不跳 |
| 日志时间戳 / 秒级 timeout | CLOCK_MONOTONIC_COARSE | 5ns,精度 1-4ms 够用 |
| 纯硬件 benchmark | CLOCK_MONOTONIC_RAW | 不被 NTP 调速,真实 TSC 节奏 |
| 笔记本 / 移动端含 suspend | CLOCK_BOOTTIME | 含 suspend 时间 |
| 显示给用户当前时刻 | CLOCK_REALTIME | UTC 时间 |
| 写入日志的秒级时间戳 | CLOCK_REALTIME_COARSE | 5ns,秒级精度够用 |
| 测 CPU 占用 / profile | CLOCK_PROCESS_CPUTIME_ID | 慢但提供独家数据 |
| 跨进程 / 跨机器 timing | NTP/PTP 同步的 wall + clock skew 算法 | monotonic 不能跨机器 |
反 anti-pattern:
- 测耗时用
clock()——clock()返回 CPU 时间,sleep 期间不增加。除非你真要测 CPU 时间,否则用MONOTONIC。 - 测耗时用
time(NULL)—— 秒级精度,几乎所有 benchmark 都用不了。 - 测耗时用
gettimeofday—— wall clock 类型,可能 NTP 跳。 - 需要 ns 精度但用了
_COARSE——_COARSE是 1-4ms 精度,如果代码段本身 < 1ms,可能连续读到同一个时间戳。 - 觉得
_RAW更”纯”就默认它 —— 你的服务会跟同机器其他进程时间慢慢漂移。 - 觉得 vDSO 是黑科技、不可靠 —— glibc 默认就在用 vDSO。
clock_gettime已经是你能拿到的最优 API。
收尾¶
vDSO 是 Linux 最实用的”用户态零成本加速”机制之一。掌握之后:
- 知道
clock_gettime(CLOCK_MONOTONIC)的 25ns 不是魔法,_COARSE的 5ns 也不是魔法,是 TSC + 共享内存 + seqlock。 - 写 benchmark / tracing / logging 时能挑对 clock,省 10~100 倍代价。
- 看到
strace输出”没有 clock_gettime”不会被吓到,知道真相是 vDSO。 - 看到
bpftrace报告里某些 clock 还在走 syscall,知道是不能走 vDSO 的 CPUTIME 系列,不是配置问题。
进一步阅读¶
man vdso(7)—— vDSO 官方文档- LWN: On vsyscalls and the vDSO —— 经典背景
- Brendan Gregg 《BPF Performance Tools》ch.3 / ch.4 / ch.8 时间相关 tracepoints
- Linux source:
kernel/time/vsyscall.c(vdso_data 维护)、arch/x86/entry/vdso/vclock_gettime.c(实际 vDSO 代码) - 自己跑:
cat /sys/devices/system/clocksource/clocksource0/{current,available}_clocksource、cat /proc/timer_list