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 影响典型耗时
REALTIMEwall 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 时间的 monotonic27 ns
TAIUTC + 累计 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 亿次

_RAWMONOTONIC 的真实差别MONOTONIC 在 NTP 校准时会”调速度”(让 1 秒略快或略慢,平滑追上 UTC);MONOTONIC_RAW 永远按硬件 TSC 节奏走。测纯硬件 benchmark 用 RAW,普通耗时用 MONOTONIC。日常 99% 场景应该用 MONOTONIC,不要因为”RAW 听起来更底层”就用 RAW —— 这会让你的服务跟同机器其他进程慢慢失同步。

BOOTTIME 的场景:笔记本 / 手机 / VM suspend 期间,MONOTONIC 在 Linux 4.0+ 把 suspend 时间也累计了,所以 BOOTTIMEMONOTONIC 在大多数现代系统上行为相同。差别仅在历史 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_pmhpet,你的服务 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_IDPROCESS_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_MONOTONIC25ns,单调,NTP 校准平滑不跳
日志时间戳 / 秒级 timeoutCLOCK_MONOTONIC_COARSE5ns,精度 1-4ms 够用
纯硬件 benchmarkCLOCK_MONOTONIC_RAW不被 NTP 调速,真实 TSC 节奏
笔记本 / 移动端含 suspendCLOCK_BOOTTIME含 suspend 时间
显示给用户当前时刻CLOCK_REALTIMEUTC 时间
写入日志的秒级时间戳CLOCK_REALTIME_COARSE5ns,秒级精度够用
测 CPU 占用 / profileCLOCK_PROCESS_CPUTIME_ID慢但提供独家数据
跨进程 / 跨机器 timingNTP/PTP 同步的 wall + clock skew 算法monotonic 不能跨机器

反 anti-pattern

  1. 测耗时用 clock() —— clock() 返回 CPU 时间,sleep 期间不增加。除非你真要测 CPU 时间,否则用 MONOTONIC
  2. 测耗时用 time(NULL) —— 秒级精度,几乎所有 benchmark 都用不了。
  3. 测耗时用 gettimeofday —— wall clock 类型,可能 NTP 跳。
  4. 需要 ns 精度但用了 _COARSE —— _COARSE 是 1-4ms 精度,如果代码段本身 < 1ms,可能连续读到同一个时间戳。
  5. 觉得 _RAW 更”纯”就默认它 —— 你的服务会跟同机器其他进程时间慢慢漂移。
  6. 觉得 vDSO 是黑科技、不可靠 —— glibc 默认就在用 vDSO。clock_gettime 已经是你能拿到的最优 API。

收尾

vDSO 是 Linux 最实用的”用户态零成本加速”机制之一。掌握之后:

  1. 知道 clock_gettime(CLOCK_MONOTONIC) 的 25ns 不是魔法,_COARSE 的 5ns 也不是魔法,是 TSC + 共享内存 + seqlock。
  2. 写 benchmark / tracing / logging 时能挑对 clock,省 10~100 倍代价。
  3. 看到 strace 输出”没有 clock_gettime”不会被吓到,知道真相是 vDSO。
  4. 看到 bpftrace 报告里某些 clock 还在走 syscall,知道是不能走 vDSO 的 CPUTIME 系列,不是配置问题。

进一步阅读

这篇怎么样?