时钟源:硬件到底怎么「数时间」
CPU 之外没有钟,内核的时间来自时钟源(clocksource)——一个以已知频率走动的计数器。x86-64 的主角是 TSC(time stamp counter):每个 CPU 核一个 64 位计数器,每个时钟周期加一,rdtsc 指令一条读取,纳秒级开销。vDSO(第 15 篇)能让 clock_gettime 不陷入内核,靠的就是直接读 TSC 加换算。
TSC 的历史麻烦是跨核不一致与变频漂移:老 CPU 的 TSC 随频率变化(CPU 降频时「时间」变慢)、多核间不同步。现代 CPU 提供不变 TSC(invariant TSC,/sys/devices/system/clocksource/clocksource0/current_clocksource 显示 tsc 即可用),内核启动时校准各核偏差。TSC 不可信时内核退回 HPET/ACPI PM timer(读起来贵十倍,vDSO 快路径失效)——这是「某些老机器上时钟调用突然变贵」的根源。
cat /sys/devices/system/clocksource/clocksource0/available_clocksource
# tsc hpet acpi_pm —— 当前用的与可选的
dmesg | grep -i tsc # 内核启动时对 TSC 的评估结论
两套时间:wall time 与 monotonic,API 选择即正确性
内核维护两个语义不同的时间,混淆它们是分布式系统的经典 bug:
- CLOCK_REALTIME(墙上时间):对齐真实世界,会被 NTP 与管理员向前或向后调整。用途:日志时间戳、对外展示。绝不能用于测量耗时或定时——NTP 跳变会让「睡 60 秒」变成睡 30 秒或 90 秒。
- CLOCK_MONOTONIC(单调时间):只前进不后退,不受跳变影响(起点任意)。测量耗时、实现超时的唯一正确选择。
- CLOCK_MONOTONIC_RAW:连 NTP 的渐进 slew 调速都不参与,纯硬件计数——需要绝对不受干扰的测量时用。
- CLOCK_BOOTTIME:monotonic + 睡眠时间——统计「进程活了多久」用。
Go 的 time.Now() 返回墙钟而 time.Since() 内部走 monotonic(Go 1.9 起 Now 携带单调读数);Java 的 System.currentTimeMillis() 是墙钟、System.nanoTime() 是单调;C 的 clock_gettime(CLOCK_MONOTONIC, ...)。「用错时钟导致 NTP 校时后服务超时计算全乱」是真实存在的生产事故类别——2012 年的 leap second(闰秒)曾让大量用墙钟做超时的服务集体故障,此后 monotonic 的纪律才被写入各语言运行时。
定时器的内核实现:时间轮与红黑树的分工
「N 毫秒后叫醒我」的内核实现有两个世代,分工延续至今:
传统低精度定时器(timer wheel):围绕 jiffies(全局节拍,HZ 通常 100–1000)的多级时间轮——把未来时刻按「近期精细、远期粗略」分桶,到期逐级下移。O(1) 添加,但精度只有 1/HZ 秒(1–10ms),且支持「超时后检查」的宽松语义。内核的绝大多数超时(TCP 重传、连接老化)用它——网络协议栈的超时本来就是「大约这么多时间」语义。
高精度定时器 hrtimer:基于 TSC 换算的纳秒级到期时间,按到期时刻组织成红黑树,到期精确触发。clock_gettime/nanosleep/epoll 的精确超时、进程调度器的运行时片,全走 hrtimer。成本比时间轮高(树操作 + 可能的中断重编程),但语义是「到点即醒」。
两者的分工哲学值得记住:精度是花钱买的——select/poll 的超时精度受限于 HZ(老内核),epoll_wait 的 timeout 走 hrtimer 纳秒级但每次修改都要重编程时钟中断。应用侧「疯狂调整超时导致 hrtimer 风暴」(每秒百万次 setTimer)是真实病理,/proc/timer_stats(老内核)或 perf stat -e hrtimer_* 可观测。
应用侧的定时姿势:从 sleep 到 timerfd
把内核设施映射到应用接口,四种常用姿势各有位置:
sleep/usleep/nanosleep:挂起整线程。简单但占线程(第 31 篇的调度成本),高并发里逐请求 sleep 是反模式。- 事件循环超时:
epoll_wait(epfd, events, n, timeout_ms)的第四参数——把「等事件」与「等定时」合并成一次调用,是单线程万级连接的标准做法(Redis/Nginx 的事件循环都这样跑定时任务)。 timerfd_create:把定时器封装成 fd,超时变成「fd 可读」事件——定时器从此融入 epoll 统一事件模型,多组定时器不再需要独立线程,配合上节的时间轮思想可实现纯用户态的分层定时(Go 的 runtime timer、Netty 的 HashedWheelTimer 都是同一思路的用户态实现)。- POSIX 定时器(timer_create):信号通知机制,与事件模型不亲和,现代服务端少用。
// timerfd:定时器成为 epoll 家族的一员
int tfd = timerfd_create(CLOCK_MONOTONIC, 0);
struct itimerspec spec = {
.it_value = {1, 0}, // 1 秒后首次到期
.it_interval = {1, 0}, // 之后每秒一次
};
timerfd_settime(tfd, 0, &spec, NULL);
// epoll_wait 收到 tfd 可读 → 处理定时任务 → read(tfd) 清除到期计数
NTP 与时间同步的工程边界
时间同步层(chrony/ntpd)用两种手段对齐:slew(微调频率,渐进收敛,不跳变)与 step(直接跳变,偏差过大时用)。工程纪律由此而来:服务端基础设施允许 slew,尽量禁止 step——一次 step 就是一次「时间倒流」,monotonic 之外的时钟全部不可信。虚拟机的问题更突出:宿主迁移、CPU 暂停会让 guest 时钟漂移秒级,chrony 的 makestep 策略与 virtio 时钟源(kvm-clock)配置是虚拟化环境的必修课。Kubernetes 集群的通行做法是每节点跑 chrony 容器、makestep 限制在启动阶段,运行期只 slew。
监控时间的健康度本身也是运维项:chronyc tracking 看 offset(与上游的偏差,毫秒级正常)、clocksource 是否为 tsc、跨机器日志时间戳的偏差(日志排序依赖它)。分布式系统的一致性协议(Raft 选超时、租约)对时钟偏差敏感——第 31 篇之后如果做分布式内核话题,这条会反复回来。
中间件的时间轮:Redis、Kafka、Netty 的定时器实现
时间轮思想在应用层中间件里被反复重新发明,三个样本各有取舍,值得对照着记。Netty 的 HashedWheelTimer 是教科书实现:单线程驱动、固定精度 tick(默认 100ms)、多层轮询,O(1) 添加与删除——海量连接的空闲超时管理(每连接一个定时器,百万级并发下红黑树的 O(log n) 反而是负担)是它的主场;精度粗是特性不是缺陷,超时管理不需要纳秒。Kafka 的层级时间轮(TimingWheel)更进一步:秒、分、时多层轮子按需升降级,请求「延迟 30 分钟处理」这类长时间定时也不需要海量桶——延迟队列(Purgatory)支撑着 acks=all 的副本等待与事务超时。Redis 的实现反其道而行:没有时间轮,用「每个 database 一个到期字典加惰性删除加定时抽样」——每次事件循环抽一批带 TTL 的键检查过期,配合访问时惰性判定。精度无所谓(过期键晚点删不损语义)、内存成本优先(不为定时器建结构)的取舍,跟它的内存数据库定位一致。三个样本的选型逻辑可以归纳成一句话:定时器数量决定数据结构——百万级定时器选时间轮(O(1) 添加),万级以内红黑树或堆完全够用(精度还高),而「能在访问时惰性处理」的干脆不建定时器。应用工程师的迁移价值在于:你的连接管理、缓存 TTL、任务调度,选型时先数一数定时器的量级,再决定抄谁的作业。
用户态的高精度延时:busy-wait、rdtsc 与它的边界
内核定时器精度到微秒后,最后一公里在用户态:nanosleep 的实际唤醒延迟受调度影响(醒来后还要等调度器安排 CPU),实时性要求极高的路径(高频交易、DPDK 轮询循环、工业控制)需要更硬的手段。第一档:busy-wait——纯空转比较时钟,延迟零但烧满一个核,多核机器上「一核等一核」的专用核模式(把等待核与工作核用 cpuset 隔离,第 31 篇的隔离纪律)是可接受的工程折中。第二档:rdtsc 自旋加 usleep 混合——远期用 nanosleep(省电),临近截止切 rdtsc 自旋(精度),兼顾两者;TSC 的核间一致性问题(第 30 篇)要求等待与截止用同一核读数或用 invariant TSC。第三档:调整调度属性配合——SCHED_FIFO 加 mlockall 加内存预触碰,把「睡醒后的第一毫秒」里所有不可控因素(调度延迟、缺页)提前消灭,这是量化交易系统「唤醒抖动小于微秒」的完整配方。边界同样要划清:99.9% 的业务应该用 nanosleep/epoll 超时(毫秒级精度足够,busy-wait 是浪费);busy-wait 的合法域是「等待成本低于其占核成本」的极端场景——决策前先算一笔账:等待期间这个核能做的其他工作值多少钱。时间精度的层级(墙钟、单调钟、TSC、自旋)本质是「确定性与资源占用」的连续谱,沿着谱选最左够用的那一档,就是时间工程学的全部。
时间轮在监控里的应用:海量超时的心跳压缩
时间轮思想在可观测性领域有一个反直觉的应用:用「缺失」代替「上报」来压缩心跳流量。场景:万台节点的存活监控,传统做法是每台每 10 秒上报心跳(千 QPS 常驻流量),服务端为每台维护「最后心跳时间」并扫描超时——上报是流量的主体,超时检测是常态负担。时间轮式的反转设计:服务端维护一个时间轮,节点注册时按预期心跳周期放入对应槽位;只有超时事件才产生流量(节点该报没报,服务端主动探测或告警),正常节点静默。流量从「每节点常驻」变成「仅异常节点」,万节点集群的监控心跳从千 QPS 降到接近零——Consul 的 anti-entropy 与某些服务网格的心跳压缩用的就是这套思路。它的交换条件是「静默等于健康」的假设必须成立(网络分区会把静默误判为死亡,需要旁路验证),以及节点侧要有「恢复后补注册」的协议。这个应用的推广价值超出监控本身:任何「海量实体的周期性状态刷新」都可以问一句——能不能让健康状态零流量,只让异常付费;租约续期、配置中心的长轮询(第 17 篇配置中心的长轮询本质也是「无变化零流量」)、连接保活,都是同一设计题的不同卷面。
小结
时间子系统的四层结构:TSC 硬件计数 → 内核双时间框架(wall/monotonic 语义分离)→ 双定时器机制(时间轮管粗粒度协议超时、hrtimer 管纳秒级精确到期)→ 应用接口(epoll 超时与 timerfd 把定时融入事件循环)。工程纪律三条:测耗时用 monotonic(闰秒事故的教训)、允许 NTP slew 禁止 step、警惕 hrtimer 风暴。「时间从哪来、准不准、谁在调它」是分布式一致性与延迟测量共同的隐变量。下一篇回到 CPU 视角,计算这个系列反复引用的那笔账:上下文切换到底贵在哪。
读者留言
COMMENTS 暂无还没有留言,来说第一句?