2.11 定时器与休眠
为什么 sleep(1) 可能睡 1.3 秒、也可能被信号提前叫醒?alarm、setitimer、POSIX 定时器、timerfd 四代接口各自解决什么问题?系统时间被 NTP 拨快一小时,哪些定时器会疯掉?
用单调时钟写出不漂移的周期任务;给阻塞 read 加上可靠超时;在 epoll 事件循环里用 timerfd 管理成千上万个截止时间;解释 CLOCK_REALTIME 与 CLOCK_MONOTONIC 的每一条差异。
2.10 把信号讲成了「异步事件」,但信号最常见的来源之一恰恰是时间:alarm() 到期发 SIGALRM,setitimer() 周期发 SIGPROF,POSIX 定时器可以把到期通知投递成信号或线程。定时器是信号机制的第一个大规模用户,也是它的第一个陷阱来源——信号会打断系统调用,会合并,会在错误的时刻到达。
这一章把「时间」拆成两个独立问题:读时间(clock_gettime,选哪条时间线)与等时间(sleep/nanosleep/timerfd,怎么等到某个时刻)。两者混用是绝大多数超时 bug 的根源:用会跳变的墙上时钟计算间隔,用相对休眠做周期任务,用信号做高频定时。
学完这一章,你应该能回答一个工程问题:一个需要同时管理 10 万个连接超时的服务器,为什么不应该开 10 万个 alarm,也不应该开 10 万个线程,而应该用「时间轮 + 一个 timerfd」或者「最小堆 + epoll_wait 超时参数」。答案在 2.11.7。
2.11.1 三条时间线:墙上时间、单调时间与开机时间
计算机里没有一条「绝对正确」的时间线。内核同时维护好几条,各自回答不同的问题:
- 现在几点(可以往回跳)——
CLOCK_REALTIME; - 过了多久(绝不回跳,但 suspend 时停表)——
CLOCK_MONOTONIC; - 开机以来多久(含 suspend)——
CLOCK_BOOTTIME; - 这段代码用了多少 CPU——
CLOCK_PROCESS_CPUTIME_ID/CLOCK_THREAD_CPUTIME_ID。
事件 A 事件 B
│ │
────●───────────────────────────────●──────────▶ 真实时间
│◀────── CLOCK_MONOTONIC ──────▶│ 只增不减;suspend 期间停表
│ │
────●───────────┬───────────────────●──────────▶ CLOCK_BOOTTIME(含 suspend)
│
settimeofday(+1h) / NTP step
▼
────●───────────────────────────────●──────────▶ CLOCK_REALTIME(可向前跳,也可向后跳)
▲
└── 同一段代码,在跳变前后测出的间隔可能变成负数
读图:三条线在没有时间调整、没有 suspend 时读数完全一致,所以本地测试很难暴露问题。差异只在两个时刻出现:系统时钟被设置(settimeofday、NTP step、容器热迁移),以及机器挂起再恢复。服务端代码必须在设计时就选定一条线,而不是等线上出事再改。
clock_gettime(clockid_t clk, struct timespec *tp) 从指定时钟读数。POSIX 定义的时钟 ID 与 Linux 扩展的语义:
| 时钟 ID | 起点 | 会跳变吗 | 计 suspend 吗 | 典型用途 |
|---|---|---|---|---|
CLOCK_REALTIME |
Unix epoch(1970-01-01 UTC) | 会(settimeofday/NTP step) | 计 | 日志时间戳、证书、stat 时间 |
CLOCK_MONOTONIC |
开机(不暴露绝对值) | 不会 | 不计 | 超时、间隔、周期任务 |
CLOCK_BOOTTIME |
开机 | 不会 | 计 | 需要跨 suspend 的超时 |
CLOCK_MONOTONIC_RAW |
开机 | 不会 | 不计 | 不受 NTP 频率调整影响的测量 |
CLOCK_TAI |
TAI 纪元 | 不会(无闰秒) | 计 | 需要连续秒数的科学计量 |
CLOCK_PROCESS_CPUTIME_ID |
进程创建 | 不会 | 不适用 | CPU 配额、性能剖析 |
CLOCK_THREAD_CPUTIME_ID |
线程创建 | 不会 | 不适用 | 单线程热点定位 |
CLOCK_MONOTONIC 保证单调,但不保证准确:NTP 会以 skew(缓慢调整频率)的方式让它走快或走慢一点,所以它可能比物理时间略有偏差;CLOCK_MONOTONIC_RAW 才是未调整的硬件计数。对超时而言,「单调」比「准确」重要得多。
clock_gettime 的返回值只有成功 0 与失败 -1。合法时钟 ID 在 Linux 上几乎不会失败;失败通常意味着 clk 是无效值(EINVAL)或 tp 不可写(EFAULT)。struct timespec 的 tv_nsec 必须在 [0, 999999999],内核返回时保证已归一化;你自己做加法时必须手动进位。
/* clocks.c — 打印各时钟读数与分辨率 */
#define _GNU_SOURCE
#include <errno.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <time.h>
static const struct {
clockid_t id;
const char *name;
} clocks[] = {
{ CLOCK_REALTIME, "CLOCK_REALTIME" },
{ CLOCK_MONOTONIC, "CLOCK_MONOTONIC" },
{ CLOCK_BOOTTIME, "CLOCK_BOOTTIME" },
{ CLOCK_MONOTONIC_RAW, "CLOCK_MONOTONIC_RAW" },
{ CLOCK_PROCESS_CPUTIME_ID, "CLOCK_PROCESS_CPUTIME_ID" },
};
int main(void)
{
for (size_t i = 0; i < sizeof clocks / sizeof clocks[0]; i++) {
struct timespec ts, res;
if (clock_gettime(clocks[i].id, &ts) == -1) {
fprintf(stderr, "clock_gettime(%s): %s\n",
clocks[i].name, strerror(errno));
continue;
}
if (clock_getres(clocks[i].id, &res) == -1) {
fprintf(stderr, "clock_getres(%s): %s\n",
clocks[i].name, strerror(errno));
continue;
}
printf("%-24s %10ld.%09ld s res=%ld ns\n",
clocks[i].name, (long)ts.tv_sec, ts.tv_nsec,
res.tv_sec * 1000000000L + res.tv_nsec);
}
return EXIT_SUCCESS;
}
编译并运行:
cc -std=c17 -D_GNU_SOURCE -Wall -Wextra -Werror -O2 -g clocks.c -o clocks
./clocks
strace -e trace=clock_gettime ./clocks 2>&1 | head
第一行输出里,CLOCK_REALTIME 与 CLOCK_MONOTONIC 的数值相差极大(前者约 17 亿秒,后者是开机秒数),这正常。CLOCK_PROCESS_CPUTIME_ID 只有几毫秒,因为它只算 CPU 时间。
strace 那一行是本节最值得记住的观察:在 x86-64 上,clock_gettime 通常没有系统调用。glibc 把它实现成 vDSO(virtual dynamic shared object)里的用户态函数,直接读内核映射出来的共享页。strace -e trace=clock_gettime 看不到调用,不代表没执行;用 strace -c 统计系统调用开销时,也不能把 clock_gettime 算进去。这解释了为什么「每次循环都读一次单调时钟」在现代 Linux 上几乎免费。
- 「
time(NULL)够用了」——它只有秒级分辨率,且读的是CLOCK_REALTIME。测量间隔、做超时都不该用它。 - 「
gettimeofday是标准做法」——POSIX.1-2008 已把它标为 obsolescent,推荐clock_gettime。gettimeofday返回struct timeval,分辨率受tv_usec限制,且没有单调版本。新代码不应再用它取当前时间(历史接口兼容除外)。 - 「
CLOCK_MONOTONIC包含休眠时间」——不包含。笔记本合盖 8 小时后,CLOCK_MONOTONIC只前进了几秒。需要跨 suspend 的看门狗用CLOCK_BOOTTIME。 - 「时间戳用 32 位
time_t也行」——32 位有符号time_t在 2038-01-19 溢出。现代 64 位 Linux 的time_t是 64 位,但 32 位用户态、旧文件系统、旧协议字段仍可能截断。
2.11.2 clock_gettime 与时间算术
struct timespec 只有两个字段,但自己写加法时有两类错误:忘记归一化,以及用有符号数直接相减导致借位错误。把这两件事封装成函数,后面所有章节都复用。
/* timespec_util.h — 单调时钟的加法、比较与毫秒差(节选) */
static void ts_add_ms(struct timespec *ts, long ms)
{
ts->tv_sec += ms / 1000;
ts->tv_nsec += (ms % 1000) * 1000000L;
if (ts->tv_nsec >= 1000000000L) { /* 只有正数加法,一次进位足够 */
ts->tv_sec += 1;
ts->tv_nsec -= 1000000000L;
}
}
static long ts_diff_ms(const struct timespec *a, const struct timespec *b)
{
long long ns = (long long)(a->tv_sec - b->tv_sec) * 1000000000LL
+ (a->tv_nsec - b->tv_nsec);
return (long)(ns / 1000000); /* 截断到毫秒,调用者决定取整方向 */
}
两个细节:
ms % 1000在ms为负时是负数。上面的函数假设ms >= 0;要支持负偏移,先把tv_nsec调整到非负,再处理借位。- 比较两个
timespec不要写a->tv_nsec > b->tv_nsec,必须先比tv_sec。写一个ts_cmp()比在每个调用点手写更安全。
绝对时间与相对时间
所有等待接口都有两种形式:
- 相对:从「现在」起等 N 毫秒。实现简单,但 N 次等待会累积误差,且「现在」在每次循环里都被重新取一次。
- 绝对:等到某个时间点。周期任务用它才不会漂移;超时用它才能在「处理耗时不定」时给出统一截止时间。
/* 绝对截止时间:从当前单调时钟起算 ms 毫秒 */
static void deadline_after(struct timespec *dl, long ms)
{
if (clock_gettime(CLOCK_MONOTONIC, dl) == -1) {
perror("clock_gettime");
exit(EXIT_FAILURE);
}
ts_add_ms(dl, ms);
}
/* 距离截止时间还有多少毫秒;已过期返回 0。向上取整,避免 0 被当成「无限等」 */
static int deadline_left_ms(const struct timespec *dl)
{
struct timespec now;
long long ns;
if (clock_gettime(CLOCK_MONOTONIC, &now) == -1) {
perror("clock_gettime");
exit(EXIT_FAILURE);
}
ns = (long long)(dl->tv_sec - now.tv_sec) * 1000000000LL
+ (dl->tv_nsec - now.tv_nsec);
if (ns <= 0) return 0;
return (int)((ns + 999999) / 1000000);
}
向上取整不是美学问题。poll() 的超时参数单位是毫秒,0 表示「立刻返回」,-1 表示「无限等」。如果剩余 0.4 ms 时你截断成 0,poll 会变成忙轮询;四舍五入成 0 同样。向上取整保证「至少等到截止时间」,代价是最多多等 1 ms。
时区:TZ、tzset 与 localtime 的选择
time_t 是 UTC 秒数;把它变成「北京时间 2026-10-18 10:00」需要时区数据库。glibc 的规则:
- 启动/首次调用
localtime/strftime时,读环境变量TZ; TZ未设置 → 用/etc/localtime(通常是/usr/share/zoneinfo/Asia/Shanghai的符号链接);TZ已设置但为空,或值无法按任何格式解释 → UTC(不是系统时区);TZ=Asia/Shanghai→ 读/usr/share/zoneinfo/Asia/Shanghai;文件缺失/无法解释 → 静默回退 UTC;TZ=UTC0/TZ=:UTC→ 固定偏移;TZ=EST5EDT→ POSIX 规则;- 改了
TZ之后必须调用tzset()才生效;tzname[0/1]、timezone、daylight是tzset的全局输出。
#include <stdio.h>
#include <stdlib.h>
#include <time.h>
int main(void)
{
time_t t = time(NULL);
struct tm tm;
setenv("TZ", "Asia/Shanghai", 1);
tzset(); /* 必须:让新 TZ 生效 */
localtime_r(&t, &tm);
char buf[64];
strftime(buf, sizeof buf, "%Y-%m-%d %H:%M:%S %Z", &tm);
printf("%s (tzname=%s, offset=%ld)\n", buf, tzname[0], timezone);
return 0;
}
localtime()返回静态struct tm,多线程用localtime_r;gmtime同理。tm_isdst=-1让mktime自己判断夏令时;填 0/1 可能差一小时。TZ是进程全局:多线程程序不要一边改TZ一边格式化;在启动时固定。- 容器里可能没有
/usr/share/zoneinfo:TZ=Asia/Shanghai不会报错,而是静默回退 UTC;tzset()没有错误返回,必须在启动时校验tm_gmtoff/tzname,并在镜像里装tzdata。
2.11.3 sleep、nanosleep 与 clock_nanosleep
三代休眠接口的差别,一张表能说清:
| 接口 | 分辨率 | 被信号打断 | 剩余时间 | 备注 |
|---|---|---|---|---|
sleep(unsigned int) | 秒 | 返回未睡完的秒数 | 通过返回值 | 实现可能基于 nanosleep |
usleep(useconds_t) | 微秒 | 返回 -1/EINTR | 拿不到 | POSIX.1-2008 已废弃 |
nanosleep(req, rem) | 纳秒(受内核限制) | 返回 -1/EINTR | rem 给出剩余 | 相对时间 |
clock_nanosleep(clk, flags, req, rem) | 纳秒 | 返回错误码 | 绝对模式下无意义 | 可选时钟与 TIMER_ABSTIME |
nanosleep 的返回值有一个反直觉的地方:被信号打断时它返回 -1 并设置 errno = EINTR,同时把剩余时间写进 rem。正确处理不是简单重试整个 req,而是从 rem 继续:
/* 睡满 ms 毫秒;被信号打断时用 rem 续睡,不重新计时 */
static int sleep_ms(long ms)
{
struct timespec req = { .tv_sec = ms / 1000,
.tv_nsec = (ms % 1000) * 1000000L };
while (nanosleep(&req, &req) == -1) {
if (errno != EINTR) return -1;
/* req 已被内核改写成剩余时间,直接继续 */
}
return 0;
}
注意 nanosleep(&req, &req) 把同一个对象同时作为入参和剩余时间出参。POSIX 允许 rem 与 req 指向同一对象,glibc 的实现也支持;但为了可读性,很多代码库仍用两个变量再赋值。真正不能做的是「EINTR 后重新 nanosleep(&req_original)」——那会把已经睡过的时间再睡一遍。
clock_nanosleep 的错误返回约定与所有 clock_* 函数一致:成功返回 0,失败返回错误码本身,不设置 errno。所以不能写 if (clock_nanosleep(...) == -1) perror(...),那永远不会命中。
/* 绝对休眠:等到 dl;已经过期则立即返回 0 */
int rc = clock_nanosleep(CLOCK_MONOTONIC, TIMER_ABSTIME, &dl, NULL);
if (rc != 0) {
errno = rc;
perror("clock_nanosleep");
}
TIMER_ABSTIME 下有两个关键语义:
rem参数被忽略。绝对时间没有「剩余」的概念——醒来后重新读时钟,与dl比较即可。EINTR不再是错误。被信号打断后,内核不会返回EINTR让你续睡;它会在信号处理器返回后重新检查绝对时间,若还没到就继续睡。也就是说,绝对休眠天然抗信号打断,不需要循环。这一点与相对休眠相反,是周期任务应该用绝对模式的另一个理由。
clock_nanosleep 还支持 CLOCK_REALTIME。用实时时钟做绝对休眠意味着:如果系统时间在睡眠期间被调后,你可能多睡;被调前,你可能立刻醒来。除非你在实现「闹钟」(必须在墙上时间 7:00 响),否则一律用 CLOCK_MONOTONIC。
- 「
sleep(1)精确睡 1 秒」——它保证至少 1 秒,可能更久(调度延迟、timer slack、系统负载)。高精度场景要接受「不早于」而不是「等于」。 - 「
sleep不会被信号打断」——会被打断,且返回剩余秒数。很多代码忽略返回值,醒来后以为时间到了。 - 「
usleep(1000000)安全」——某些实现要求参数小于 1000000,等于 1 秒会EINVAL。用nanosleep。 - 「
nanosleep被打断后重睡整个时长」——多睡一倍。必须用rem续睡。 - 「
sleep(0)什么也不做」——它至少让出一次 CPU(sched_yield语义),在自旋重试里可能把 CPU 占满。
2.11.4 alarm 与 setitimer:信号驱动的定时器
alarm() 是最古老的定时器:每个进程一个,到期发 SIGALRM,一次性。
unsigned int left = alarm(2); /* 2 秒后 SIGALRM;返回旧定时器剩余秒数 */
alarm(0); /* 取消 */
它只支持整秒,且「每个进程只有一个」——两个库函数都想用 alarm 时会互相覆盖。setitimer() 稍微好一点:每个进程每种类型一个,支持微秒,且可以周期触发。
int setitimer(int which, const struct itimerval *new, struct itimerval *old):
which |
计时对象 | 到期信号 | 备注 |
|---|---|---|---|
ITIMER_REAL |
墙上时间 | SIGALRM |
最常用;会受系统时间调整影响 |
ITIMER_VIRTUAL |
进程用户态 CPU 时间 | SIGVTALRM |
只在进程运行时递减 |
ITIMER_PROF |
用户态 + 内核态 CPU 时间 | SIGPROF |
剖析器常用 |
struct itimerval 有 it_value(距下次到期)与 it_interval(之后每次的间隔)。it_value 置 0 表示解除;it_interval 置 0 表示一次性。old 非空时返回之前的设置。
setitimer 的周期不是精确周期:到期后内核把 it_value 重置为 it_interval,而从「到期」到「进程真正处理信号」之间的延迟不会补偿。高负载下,100 ms 的 ITIMER_REAL 可能实际每 105 ms 触发一次;如果处理函数耗时超过间隔,信号会合并(标准信号不排队),你只会看到一次。
用 alarm 给阻塞 read 加超时的经典写法:
/* read_timeout.c — 用 SIGALRM 打断阻塞 read(节选) */
static volatile sig_atomic_t timed_out;
static void on_alarm(int sig)
{
(void)sig;
timed_out = 1; /* 处理器里只做 async-signal-safe 的事 */
}
static ssize_t read_with_timeout(int fd, void *buf, size_t n, unsigned int sec)
{
struct sigaction sa = {0};
sa.sa_handler = on_alarm;
sigemptyset(&sa.sa_mask);
sa.sa_flags = 0; /* 关键:不设 SA_RESTART,否则 read 会自动重启 */
if (sigaction(SIGALRM, &sa, NULL) == -1) return -1;
timed_out = 0;
alarm(sec); /* 返回值是旧定时器的剩余秒数,这里不关心 */
ssize_t r = read(fd, buf, n);
int saved = errno; /* 先保存,后面的 alarm(0) 可能改 errno */
alarm(0); /* 取消尚未触发的定时器 */
errno = saved;
if (r == -1 && errno == EINTR && timed_out) {
errno = ETIMEDOUT;
return -1;
}
return r;
}
alarm() 的返回值是「上一个定时器还剩几秒」;它本身不返回错误(参数是 unsigned int,没有非法值)。alarm(0) 取消当前定时器并返回剩余秒数。
这段代码有四个必须理解的点:
sa_flags = 0。若设了SA_RESTART,read会在信号处理器返回后自动重启,超时永远不生效。这是SA_RESTART与超时逻辑的正面冲突。timed_out必须是volatile sig_atomic_t。处理器与主流程共享它,普通int可能被编译器缓存到寄存器。- 存在竞态窗口:
read刚返回、alarm(0)还没执行时,SIGALRM可能到达,把timed_out置 1。此时数据已经读到,函数仍返回r,不会误判为超时;但处理器会多执行一次,这无害。真正危险的是反过来——先检查timed_out再读,那是另一类竞态。 alarm与sleep共享SIGALRM。在用了alarm的程序里调用sleep,两者会互相打断。同理,任何第三方库调alarm都会破坏你的超时。
setitimer 能提供毫秒级超时,但同样有「信号打断系统调用」的副作用,而且仍然每进程每类型只有一个。要在同一个进程里同时管理多个超时,信号方案就撑不住了——这正是 POSIX 定时器与 timerfd 出现的原因。
2.11.5 POSIX 定时器:timer_create 与 sigevent
POSIX 定时器把「定时器」变成内核对象,用 timer_t 标识。一个进程可以创建任意多个,每个可以绑定不同的时钟、不同的通知方式。
timer_create(clockid, sigevent *sev, timer_t *timerid):创建定时器,绑定时钟与通知方式。timer_settime(timerid, flags, const struct itimerspec *new, struct itimerspec *old):设置首次到期与周期;it_value为 0 表示解除。timer_gettime(timerid, struct itimerspec *cur):查询剩余时间与周期。timer_getoverrun(timerid):返回上次到期被合并的次数。timer_delete(timerid):删除。
sigevent 的 sigev_notify 决定通知方式:SIGEV_SIGNAL(发信号)、SIGEV_THREAD(每次到期创建一个线程执行函数)、SIGEV_NONE(不通知,只能轮询)、Linux 扩展的 SIGEV_THREAD_ID(发给指定线程)。
/* posix_timer.c — 100ms 周期定时器,用 sigwaitinfo 同步收取 */
#define _GNU_SOURCE
#include <errno.h>
#include <signal.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <time.h>
#include <unistd.h>
static void die(const char *msg)
{
perror(msg);
exit(EXIT_FAILURE);
}
int main(void)
{
sigset_t mask;
sigemptyset(&mask);
sigaddset(&mask, SIGRTMIN);
/* 先阻塞,信号才会排队等 sigwaitinfo,而不是打断主流程 */
if (sigprocmask(SIG_BLOCK, &mask, NULL) == -1)
die("sigprocmask");
struct sigevent sev = {0};
sev.sigev_notify = SIGEV_SIGNAL;
sev.sigev_signo = SIGRTMIN;
sev.sigev_value.sival_int = 42; /* 随信号带给接收者 */
timer_t tid;
if (timer_create(CLOCK_MONOTONIC, &sev, &tid) == -1)
die("timer_create");
struct itimerspec its = {0};
its.it_value.tv_nsec = 100 * 1000000L; /* 首次 100ms */
its.it_interval.tv_nsec = 100 * 1000000L; /* 之后每 100ms */
if (timer_settime(tid, 0, &its, NULL) == -1)
die("timer_settime");
for (int i = 0; i < 5; i++) {
siginfo_t si;
int sig = sigwaitinfo(&mask, &si);
if (sig == -1) {
if (errno == EINTR) { i--; continue; }
die("sigwaitinfo");
}
int overrun = timer_getoverrun(tid);
if (overrun == -1) die("timer_getoverrun");
printf("signal=%d value=%d overrun=%d\n",
sig, si.si_value.sival_int, overrun);
}
if (timer_delete(tid) == -1)
die("timer_delete");
return EXIT_SUCCESS;
}
关键行:
- 创建前先
sigprocmask(SIG_BLOCK)。如果不阻塞,SIGRTMIN会走默认动作(终止进程),sigwaitinfo永远等不到。 sigev_value.sival_int让接收者区分「是哪个定时器」——一个进程里可以有多个定时器共用一个信号,靠这个字段区分。sigwaitinfo是同步等待,不涉及异步信号处理器,因此可以在等待点安全地调用任意函数。这比在处理器里做业务安全得多。
SIGEV_THREAD 看起来最方便:到期自动起一个线程跑回调。但它有两个代价:每次到期都创建/销毁线程(高频定时器会把调度器打爆),以及回调线程与主线程的同步要你自己做。它适合「低频、回调里可以阻塞」的场景,例如每天清理一次缓存。高频场景应该用 SIGEV_SIGNAL + 专用等待线程,或者 timerfd。
timer_getoverrun 是 POSIX 定时器比 setitimer 强的地方:如果进程在处理上一次到期时定时器又到期了,标准信号会合并,但内核会计数。返回值告诉你「漏了几次」。对账、限流、监控都靠它判断是否过载。
内核不能在定时器中断上下文里调用用户态函数:地址空间、栈、锁状态都不对。所以所有定时器机制都只能「通知」——发信号、唤醒线程、把 fd 变可读。真正的回调必须由用户态的事件循环或线程调用。SIGEV_THREAD 只是 glibc 帮你起了一个线程,并没有绕开这个限制。理解这一点,就不会问「能不能让内核在 3 秒后直接调用 on_timeout()」。
2.11.6 timerfd:把定时器变成文件描述符
timerfd 是 Linux 2.6.22 引入的接口,思路上把「定时器到期」映射成「fd 可读」。它是事件循环里管理超时的标准工具:
timerfd_settime(1s, 周期) epoll_wait()
│ │
▼ ▼
┌─────────────┐ 到期,count++ ┌──────────────┐
│ hrtimer │──────────────────▶│ timerfd │──▶ EPOLLIN
└─────────────┘ │ (8 字节计数) │
└──────────────┘
│
read(fd, &exp, 8)
│
返回累计到期次数(可能大于 1)
读图:定时器到期不产生信号,只把 fd 的可读状态置位。事件循环用 epoll_wait 一次等所有 fd,超时和网络 I/O 走同一条路径,不需要 self-pipe,也不需要异步信号处理器。读出的 8 字节是 uint64_t,表示「自上次读取以来到期了几次」;如果事件循环被阻塞了很久,这个数可能大于 1,你可以在一次 read 里补上所有错过的 tick。
/* timerfd_tick.c — 100ms 周期,poll + read 收取 10 次 */
#define _GNU_SOURCE
#include <errno.h>
#include <poll.h>
#include <stdint.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/timerfd.h>
#include <time.h>
#include <unistd.h>
static void die(const char *msg)
{
perror(msg);
exit(EXIT_FAILURE);
}
int main(void)
{
int tfd = timerfd_create(CLOCK_MONOTONIC, TFD_CLOEXEC | TFD_NONBLOCK);
if (tfd == -1)
die("timerfd_create");
struct itimerspec its = {0};
its.it_value.tv_nsec = 100 * 1000000L;
its.it_interval.tv_nsec = 100 * 1000000L;
if (timerfd_settime(tfd, 0, &its, NULL) == -1)
die("timerfd_settime");
struct pollfd pfd = { .fd = tfd, .events = POLLIN };
for (int i = 0; i < 10; i++) {
int n = poll(&pfd, 1, 1000);
if (n == -1) {
if (errno == EINTR) { i--; continue; }
die("poll");
}
if (n == 0) {
fprintf(stderr, "poll timeout\n");
break;
}
uint64_t exp;
ssize_t r = read(tfd, &exp, sizeof exp);
if (r == -1) {
if (errno == EAGAIN || errno == EINTR) { i--; continue; }
die("read timerfd");
}
if (r != (ssize_t)sizeof exp) {
fprintf(stderr, "short read on timerfd\n");
break;
}
printf("tick %2d: expirations=%llu\n",
i, (unsigned long long)exp);
}
if (close(tfd) == -1)
die("close");
return EXIT_SUCCESS;
}
timerfd 的语义细节:
| 操作 | 语义与返回值 | 常见错误 |
|---|---|---|
timerfd_create(clk, flags) | 返回 fd;flags 可含 TFD_CLOEXEC、TFD_NONBLOCK | 用 CLOCK_REALTIME 做超时 |
timerfd_settime(fd, flags, new, old) | flags 含 TFD_TIMER_ABSTIME;it_value=0 解除 | 把相对时间当绝对时间传 |
read(fd, &u64, 8) | 返回 8;未到期时阻塞(非阻塞则 EAGAIN) | 缓冲区小于 8 → EINVAL |
timerfd_gettime(fd, cur) | 返回剩余时间与周期 | 忘记它返回「距下次到期」而非绝对截止 |
TFD_TIMER_CANCEL_ON_SET 是一个容易被忽略的 flag:与 CLOCK_REALTIME 配合时,如果系统时钟被 settimeofday 之类非连续地修改,等待中的 read 会返回 ECANCELED。这让「基于墙上时间的闹钟」可以感知时间跳变并重新计算。用单调时钟时这个 flag 没有意义。
- 「
read一次读 1 字节也行」——不行。read的count必须是 8,否则EINVAL。这是内核强制的对齐要求,不是建议。 - 「读出的值总是 1」——不一定。事件循环被阻塞、进程被调度走、周期很短时,累计值可能大于 1。
- 「
timerfd会自己重复触发,不用管」——它会按it_interval重复,但你不读,计数就一直累加,且 fd 一直可读。漏读不会丢事件,但会丢「次数以外的时间信息」。 - 「
timerfd比nanosleep精确」——两者的底层都是 hrtimer,精度相同。timerfd的优势是可被多路复用,不是更准。
2.11.7 超时、漂移与选型
周期任务为什么不能「干活 + 相对休眠」
假设每轮工作耗时 W,相对休眠 S,则实际周期是 W + S + ε。误差每轮累积,10 秒后可能少跑好几轮。用绝对截止时间,则周期恒为 S(只要 W < S),工作耗时被自动吸收:
/* 每 period_ms 执行一次;工作耗时不超过周期时,长期不漂移 */
struct timespec next;
deadline_after(&next, period_ms);
for (;;) {
do_work();
int rc;
while ((rc = clock_nanosleep(CLOCK_MONOTONIC, TIMER_ABSTIME,
&next, NULL)) == EINTR)
; /* 绝对休眠:EINTR 后重试即可 */
if (rc != 0) {
errno = rc;
perror("clock_nanosleep");
break;
}
ts_add_ms(&next, period_ms); /* 下一轮的绝对截止 */
}
如果 do_work() 偶尔超过一个周期,next 已经过期,clock_nanosleep 立即返回 0,循环会连续补跑,直到追上。这是「固定速率」语义;若你想「固定延迟」(跳过错过的轮次),在循环开头判断 deadline_left_ms(&next) == 0 并重新对齐即可。两种语义都合法,关键是想清楚要哪一种。
设 S = 100 ms,do_work() 平均 2 ms,调度与唤醒开销 0.3 ms。相对休眠的实际周期是 102.3 ms,10 秒内跑 10000 / 102.3 ≈ 97.75 轮,比预期少 2 轮;一小时少约 800 轮。若用绝对截止,周期恒为 100 ms(只要每轮工作在 100 ms 内完成),一小时仍是 36000 轮。
给阻塞操作加超时的三种做法
| 场景 | 做法 | 代价 |
|---|---|---|
| 单个 socket 读写 | SO_RCVTIMEO/SO_SNDTIMEO | 只能设相对超时;返回值 EAGAIN 与「非阻塞」混淆 |
| 等一组 fd | poll/epoll_wait 的超时参数 | 毫秒精度;每次等待都要重算剩余 |
| 大量独立截止时间 | 最小堆/时间轮 + 一个 timerfd | 实现复杂,但 O(log n) 或 O(1) |
SO_RCVTIMEO 的实现细节值得记一笔:它用 struct timeval,在 32 位用户态下可能因 time_t 宽度产生截断;Linux 5.1 起提供 SO_RCVTIMEO_NEW/SO_SNDTIMEO_NEW,用 struct __kernel_sock_timeval(64 位秒 + 微秒)。写跨 32/64 位代码时,优先用新常量。
epoll_wait 的超时参数是 int 毫秒,-1 表示无限。它不保证精确:内核会把超时向上取整到调度粒度,实际可能多等几毫秒。要精确到毫秒以内,用 timerfd + epoll_wait(-1),让定时器自己变成事件。
定时器精度与 timer slack
Linux 默认给每个进程 50 µs 的 timer slack:允许内核把到期时间推迟最多 50 µs,以合并唤醒、省电。对大多数业务无所谓;对高频交易、实时采样,50 µs 可能太大。可以按进程调整:
#include <sys/prctl.h>
/* 收紧到 1ns:唤醒更准,但更费电、更容易被调度延迟影响 */
if (prctl(PR_SET_TIMERSLACK, 1UL, 0, 0, 0) == -1)
perror("prctl(PR_SET_TIMERSLACK)");
也可以写 /proc/self/timerslack_ns(需要 CAP_SYS_NICE 调整其他进程)。注意:把 slack 调到 0 并不会让定时器「精确」,只是去掉内核主动合并的延迟;最终精度仍受调度器、CPU 频率、中断延迟影响。
选型判据
| 需求 | 首选 | 不要用 |
|---|---|---|
| 睡一会儿,不关心信号 | clock_nanosleep(CLOCK_MONOTONIC, TIMER_ABSTIME) | sleep/usleep |
| 给一次阻塞调用加超时 | poll 超时或 SO_RCVTIMEO | alarm(全局、不可嵌套) |
| 事件循环里的周期任务 | timerfd + epoll | SIGEV_THREAD(每次建线程) |
| 大量连接的超时管理 | 最小堆 + 单个 timerfd | 每连接一个 alarm/线程 |
| 墙上时间的闹钟(7:00 响) | timerfd(CLOCK_REALTIME) + TFD_TIMER_CANCEL_ON_SET | 单调时钟(不认挂钟) |
| 测量代码耗时 | CLOCK_MONOTONIC 或 CLOCK_THREAD_CPUTIME_ID | gettimeofday |
- 确认用的是哪条时钟:
grep -n "CLOCK_" 源码。超时逻辑里出现CLOCK_REALTIME就是可疑点。 - 确认是相对还是绝对:周期任务用相对休眠,先怀疑漂移;单次超时用绝对时间,先怀疑「截止时间已过」。
- 看信号是否打断:
strace -e trace=nanosleep,clock_nanosleep,poll,epoll_wait,注意EINTR与重启。 - 看实际唤醒间隔:
perf sched、bpftrace的 timer 探针,或简单地在回调里打印CLOCK_MONOTONIC差值。 - 看 timer slack:
cat /proc/$PID/timerslack_ns;高频定时器是否被合并。 - 看是否被 suspend:
CLOCK_MONOTONIC停表,CLOCK_BOOTTIME不停。
2.11.8 时间接口全表:签名、结构体与 errno
这一节回答一个查手册时最费时间的问题:同一个「失败」,到底是返回 -1 并把错误码放进 errno,还是直接把错误码当返回值?哪些 errno 是真会出现的,哪些只是文档里的泛泛之谈?
先记住三条互不相同的约定,它们决定了错误处理代码的形状:
| 约定 | 代表接口 | 正确写法 | 典型错误写法 |
|---|---|---|---|
返回 -1,设置 errno | clock_gettime、timer_create、timerfd_settime、read | if (f(...) == -1) { int e = errno; ... } | 把返回值当错误码 |
返回错误码本身,不设置 errno | clock_nanosleep | int rc = clock_nanosleep(...); if (rc != 0) { errno = rc; ... } | if (rc == -1),永远不成立 |
返回错误码,且不碰 errno | pthread_* 系列 | int rc = pthread_create(...); if (rc != 0) ... | perror() 打印出无关的旧 errno |
clock_nanosleep 是本章最容易写错的一个:它是 POSIX 实时扩展里少数「返回错误号」的接口,glibc 不会把它翻译成 -1/errno。同理,timer_* 系列虽然返回 -1,但内部系统调用返回的是负错误码,由 glibc 转成 -1 并设置 errno——你自己用 syscall(SYS_timer_create, ...) 时看到的是负值,不要混用两套约定。
读时钟:五个接口的分工
| API | 头文件与签名 | 成功 | 失败 errno | 说明 |
|---|---|---|---|---|
clock_gettime | <time.h> int clock_gettime(clockid_t clk, struct timespec *tp) | 0,写 *tp | EFAULT、EINVAL、ENODEV、ENOTSUP、EPERM | ENODEV/ENOTSUP 出现在动态 POSIX 时钟(PTP)上;普通时钟只有前两个 |
clock_getres | int clock_getres(clockid_t clk, struct timespec *res) | 0,写分辨率 | EFAULT、EINVAL | res 可为 NULL,此时只校验 clk |
clock_settime | int clock_settime(clockid_t clk, const struct timespec *tp) | 0 | EFAULT、EINVAL、EPERM | 只有 CLOCK_REALTIME 可设;需要 CAP_SYS_TIME |
time | time_t time(time_t *tloc) | 秒数,(time_t)-1 表示错 | EFAULT(仅当 tloc 非空且不可写) | 秒级;time_t 宽度是 ABI 问题,见下文 |
gettimeofday | int gettimeofday(struct timeval *tv, void *tz) | 0 | EFAULT | 已弃用;tz 必须为 NULL,非空行为未定义 |
clock_gettime 的 EINVAL 有两个来源:clk 不是本机支持的时钟 ID,或者(对动态时钟)句柄已经失效。普通业务代码几乎只会遇到前者——通常是把 CLOCK_TAI 之类常量用在了不支持的内核上。
clock_settime 的 EINVAL 还包括「tv_nsec 不在 [0, 999999999]」。内核对设置时间做向下取整到时钟分辨率,所以即使传入的值比分辨率细,也不会报错,只会被截断。需要 CAP_SYS_TIME;容器里默认没有,date -s 失败通常就是这里。
time() 返回 (time_t)-1 既可能是错误,也可能真的是 1969-12-31 23:59:59(只在极端时钟下)。要区分必须先把 errno 清零,再检查返回值。多数程序不检查,因为 time() 在正常系统上不会失败;但库代码应该检查。
时间转换:time_t ↔ struct tm ↔ 字符串
| API | 签名 | 作用 | 关键点 |
|---|---|---|---|
gmtime_r | struct tm *gmtime_r(const time_t *t, struct tm *out) | UTC 分解 | 可重入;tm_isdst=0 |
localtime_r | struct tm *localtime_r(const time_t *t, struct tm *out) | 本地时区分解 | 可重入;依赖 TZ/tzset |
mktime | time_t mktime(struct tm *tm) | 本地 struct tm → time_t | tm_isdst=-1 让系统判断;会规范化越界字段 |
timegm | time_t timegm(struct tm *tm) | UTC struct tm → time_t | GNU/BSD 扩展;不依赖时区 |
strftime | size_t strftime(char *buf, size_t n, const char *fmt, const struct tm *tm) | tm → 字符串 | 返回 0 表示「结果为空」或「缓冲区放不下」;POSIX 未规定 strftime 设置 errno,不要用 errno 判断 |
strptime | char *strptime(const char *s, const char *fmt, struct tm *tm) | 字符串 → tm | 不设置 tm_isdst;解析完要 mktime |
tzset | void tzset(void) | 重新读 TZ | 改 TZ 后必须调用 |
time_t t = time(NULL);
struct tm tm;
localtime_r(&t, &tm);
char buf[64];
if (strftime(buf, sizeof buf, "%Y-%m-%d %H:%M:%S %z", &tm) == 0) {
/* 缓冲区不够或格式为空 */
}
tm_isdst=1 表示「这是夏令时」,0 表示「不是」,-1 表示「让 mktime 判断」。手动填 0/1 在时区切换日可能差一小时;解析用户输入时先置 -1,再让 mktime 规范化。
休眠:四个接口的边界
| API | 签名 | 成功 | 失败 errno | 关键语义 |
|---|---|---|---|---|
sleep | unsigned int sleep(unsigned int seconds) | 返回未睡完的秒数(被信号打断)或 0 | 不设置 | 秒级;与 SIGALRM 共享,可能被其他库的 alarm 打断 |
usleep | int usleep(useconds_t usec) | 0 | EINTR、EINVAL | usec >= 1000000 在部分实现返回 EINVAL;POSIX.1-2008 已废弃 |
nanosleep | int nanosleep(const struct timespec *req, struct timespec *rem) | 0 | EFAULT、EINVAL、EINTR | EINTR 时 rem 写剩余;rem 可为 NULL |
clock_nanosleep | int clock_nanosleep(clockid_t clk, int flags, const struct timespec *req, struct timespec *rem) | 0 | EFAULT、EINVAL、EINTR、ENOTSUP | flags 只能是 0 或 TIMER_ABSTIME;EINTR 只在相对模式出现 |
nanosleep 的 EINVAL 条件是「tv_sec 为负」或「tv_nsec 不在 [0, 999999999]」。注意 tv_sec == 0 && tv_nsec == 0 是合法的:它不睡,但仍然会经过一次调度点检查。
clock_nanosleep 的 ENOTSUP 是给「时钟存在但不支持休眠」的动态时钟准备的;普通 CLOCK_MONOTONIC 不会返回它。绝对模式下 rem 被忽略,可以传 NULL;相对模式下 rem 的行为与 nanosleep 相同。
间隔定时器与 POSIX 定时器
| API | 签名 | 成功 | 失败 errno | 说明 |
|---|---|---|---|---|
alarm | unsigned int alarm(unsigned int seconds) | 返回旧定时器剩余秒数 | 不会失败 | 每进程一个;alarm(0) 取消 |
setitimer | int setitimer(int which, const struct itimerval *new, struct itimerval *old) | 0 | EFAULT、EINVAL | which 非法或 tv_usec 越界 → EINVAL |
getitimer | int getitimer(int which, struct itimerval *cur) | 0 | EFAULT、EINVAL | 返回剩余时间与周期 |
timer_create | int timer_create(clockid_t clk, struct sigevent *sev, timer_t *tid) | 0,写 *tid | EAGAIN、EINVAL、ENOMEM、EPERM | EAGAIN:超出配额或内核内存紧张;EPERM:对非本进程 CPU 时钟建定时器 |
timer_settime | int timer_settime(timer_t tid, int flags, const struct itimerspec *new, struct itimerspec *old) | 0 | EFAULT、EINVAL | flags 只能是 0/TIMER_ABSTIME;it_value 为 0 表示解除 |
timer_gettime | int timer_gettime(timer_t tid, struct itimerspec *cur) | 0 | EFAULT、EINVAL | 返回的是「距下次到期」,不是绝对截止 |
timer_getoverrun | int timer_getoverrun(timer_t tid) | 溢出次数 >= 0 | EINVAL | 只在信号通知模式下有意义 |
timer_delete | int timer_delete(timer_t tid) | 0 | EINVAL | 删除已解除的定时器仍成功;tid 无效才 EINVAL |
timer_create 的 EPERM 值得单独记:用 clock_getcpuclockid() 取得别的进程的 CPU 时钟 ID 后,能否为它创建定时器取决于权限与内核配置;跨进程 CPU 时钟通常不可用。EAGAIN 则常被误认为「内存不足」,实际更常见的是 RLIMIT_SIGPENDING 或内核的 POSIX 定时器上限——容器里跑大量定时器时要看 cgroup 与 ulimit -i。
timer_delete 对「已经到期并自动解除」的定时器仍然成功,因为定时器对象还在,只是 it_value 为 0。这与「tid 已被删除」不同:删除后再用同一个 tid 是未定义行为,glibc 可能复用它给新定时器。
timerfd:返回值与 8 字节协议
| API | 签名 | 成功 | 失败 errno | 说明 |
|---|---|---|---|---|
timerfd_create | int timerfd_create(clockid_t clk, int flags) | 返回 fd | EINVAL、EMFILE、ENFILE、ENOMEM | flags 只认 TFD_CLOEXEC、TFD_NONBLOCK;clk 只支持 REALTIME/MONOTONIC/BOOTTIME |
timerfd_settime | int timerfd_settime(int fd, int flags, const struct itimerspec *new, struct itimerspec *old) | 0 | EFAULT、EINVAL、EBADF | flags 含 TFD_TIMER_ABSTIME、TFD_TIMER_CANCEL_ON_SET |
timerfd_gettime | int timerfd_gettime(int fd, struct itimerspec *cur) | 0 | EFAULT、EINVAL、EBADF | 绝对定时器返回的 it_value 仍是「距下次到期」 |
read | ssize_t read(int fd, void *buf, size_t count) | 返回 8 | EAGAIN、EINVAL、EINTR、EBADF、EFAULT | count < 8 → EINVAL;非阻塞且未到期 → EAGAIN |
close | int close(int fd) | 0 | EINTR、EBADF | 关闭即删除定时器;fork 后父子共享同一对象 |
timerfd_settime 的 EINVAL 还包括「TFD_TIMER_CANCEL_ON_SET 与 CLOCK_MONOTONIC 一起用」:这个 flag 只对可被设置的实时时钟有意义,内核对单调时钟会直接拒绝。反过来,TFD_TIMER_ABSTIME 对三条时钟都合法,但绝对时间的零点各不相同。
结构体:每个字段谁维护
struct timespec 只有两个字段,但「谁负责归一化」是 bug 的高发区:
| 字段 | 类型 | 合法范围 | 谁维护 |
|---|---|---|---|
tv_sec | time_t(64 位 Linux 上 long) | 可正可负;time_t 溢出是 ABI 问题 | 调用者 |
tv_nsec | long | [0, 999999999] | 调用者;内核返回值已归一化 |
struct itimerspec 用于 POSIX 定时器与 timerfd:
| 字段 | 含义 | 0 的语义 | 被谁改写 |
|---|---|---|---|
it_interval | 周期 | 一次性定时器 | timer_gettime/timerfd_gettime 回填 |
it_value | 首次到期(相对或绝对,由 flags 决定) | 解除定时器 | timer_gettime 回填为「距下次到期」 |
struct itimerval 是 setitimer 的旧结构,字段相同但用 struct timeval(微秒):
| 字段 | 类型 | 单位 | 与 itimerspec 的差异 |
|---|---|---|---|
it_interval | struct timeval | 微秒 | 分辨率低 1000 倍;tv_usec 必须 < 1000000 |
it_value | struct timeval | 微秒 | 同样是「距下次到期」 |
struct sigevent 决定 POSIX 定时器如何通知。glibc 把最后几个字段藏在宏后面,直接访问 sigev_notify_function 是标准用法:
| 字段/宏 | 类型 | 何时使用 | 注意 |
|---|---|---|---|
sigev_value | union sigval(int 或 void *) | 所有通知方式 | 随信号/线程参数带回;用来区分多个定时器 |
sigev_signo | int | SIGEV_SIGNAL、SIGEV_THREAD_ID | 实时信号可排队;标准信号会合并 |
sigev_notify | int | 必填 | SIGEV_NONE/SIGNAL/THREAD/THREAD_ID |
sigev_notify_function | void (*)(union sigval) | SIGEV_THREAD | 每次到期由 glibc 新建线程调用 |
sigev_notify_attributes | pthread_attr_t * | SIGEV_THREAD | NULL 表示默认属性;可设分离状态与栈大小 |
sigev_notify_thread_id | pid_t | SIGEV_THREAD_ID | Linux 扩展;必须是内核线程 ID(gettid()),不是 pthread_t |
SIGEV_THREAD_ID 的坑在于类型:pthread_self() 返回的是 glibc 的 pthread_t(通常是指针),而 sigev_notify_thread_id 要的是 gettid() 的整数。两者在 NPTL 下数值不同,混用会让信号投递到错误的线程,甚至返回 EINVAL。
flags 位表
| flag | 适用接口 | 语义 | 常见误用 |
|---|---|---|---|
TIMER_ABSTIME | clock_nanosleep、timer_settime | it_value/req 是绝对时间点 | 把「相对 1 秒」当绝对时间,结果立即返回 |
TFD_TIMER_ABSTIME | timerfd_settime | 同上,但 flag 名不同 | 与 TIMER_ABSTIME 混写,编译能过但语义错 |
TFD_TIMER_CANCEL_ON_SET | timerfd_settime | 实时时钟被非连续修改时,read 返回 ECANCELED | 用在 CLOCK_MONOTONIC 上 → EINVAL |
TFD_CLOEXEC | timerfd_create | 设置 FD_CLOEXEC | 以为 exec 后定时器自动销毁(其实会继续走) |
TFD_NONBLOCK | timerfd_create | 设置 O_NONBLOCK | 忘了 EAGAIN 是正常状态而非错误 |
PR_SET_TIMERSLACK | prctl | 调整 timer slack(纳秒) | 以为调到 0 就能「精确唤醒」 |
errno 速查:按错误码反查
errno | 可能来自 | 现场含义 | 处理 |
|---|---|---|---|
EINVAL | 几乎所有接口 | 时钟 ID、flag、tv_nsec、timer_t、read 长度任一非法 | 检查常量拼写与结构体归一化;不要重试 |
EFAULT | 所有带指针的接口 | 用户缓冲区不可写/不可读 | 编程错误;修代码 |
EINTR | nanosleep、read、close | 被信号处理器打断 | 相对休眠用 rem 续睡;read 判断是否业务信号 |
EAGAIN | timerfd 非阻塞 read、timer_create | 前者是「还没到期」;后者是资源配额 | 前者继续等;后者查 RLIMIT_SIGPENDING 与定时器数量 |
EPERM | clock_settime、timer_create | 缺 CAP_SYS_TIME,或跨进程 CPU 时钟 | 降级为不可设置;不要反复重试 |
ENODEV/ENOTSUP | clock_gettime、clock_nanosleep | 动态时钟设备不存在/不支持该操作 | 回退到 CLOCK_MONOTONIC |
EMFILE/ENFILE | timerfd_create | 进程/系统 fd 耗尽 | 查 ulimit -n 与泄漏;不要用「重试」掩盖 |
ECANCELED | timerfd read | 实时时钟被 step,且设了 TFD_TIMER_CANCEL_ON_SET | 重新读时钟、重算绝对截止、重设定时器 |
EOVERFLOW | clock_gettime(32 位) | time_t 装不下秒数 | 用 64 位 time_t 或 _TIME_BITS=64 |
- 「
clock_nanosleep失败返回-1」——它返回错误码本身。写if (clock_nanosleep(...) == -1)会漏掉所有错误,代码看起来「从不失败」。 - 「
timer_gettime返回绝对截止时间」——返回的是「距下次到期」的相对值,即使定时器是用TIMER_ABSTIME设置的。想记录绝对截止,自己在设置时保存。 - 「
timerfd的read可以读 4 字节」——内核要求count >= 8,否则EINVAL。读出的uint64_t是累计到期次数,不是时间戳。 - 「
time_t在所有平台都是 64 位」——32 位用户态默认仍是 32 位time_t,2038 年溢出;glibc 2.34 起可用-D_TIME_BITS=64编译,但内核与文件系统也要支持。
2.11.9 内核路径:vDSO、hrtimer 与 timerfd
这一节回答:一次 clock_gettime 到底进不进内核?nanosleep 睡在哪里?timerfd 到期后是谁把 fd 变可读的?
先把结论画成一条链:
用户态 内核态
────────────────────────────────────────────────────────────────
clock_gettime ──vDSO──▶ 读 vdso_data + clocksource(不陷内核)
│ (慢路径)
└──syscall──▶ ksys_clock_gettime ──▶ clock->get_timespec()
│
timekeeper(seqlock 保护)
nanosleep ──syscall──▶ hrtimer_nanosleep ──▶ do_nanosleep
│
hrtimer(per-CPU 红黑树)
│
schedule()
timerfd_settime ──▶ do_timerfd_settime ──▶ hrtimer_start
│
timerfd 到期 ◀── timerfd_tmrproc ◀────────────┘
│
├─ ctx->ticks++(累计)
└─ wake_up_poll(&ctx->wqh, EPOLLIN)
│
read(timerfd) ──▶ timerfd_read ──▶ 取走 ticks 并清零
读图:三条路径的共同点是「内核只负责把状态改掉并唤醒」,不执行用户回调。区别在于唤醒的对象:nanosleep 唤醒的是睡眠的 task,timerfd 唤醒的是等待队列上的 epoll/poll。
clock_gettime 的 vDSO 快速路径
在 x86-64 上,clock_gettime(CLOCK_MONOTONIC) 绝大多数时候不执行 syscall 指令。内核在进程启动时映射一页只读的 vvar 页(struct vdso_data),里面有:
- 当前的
sec/nsec基准与mult/shift换算系数; - clocksource 的周期计数(通过
rdtsc或cntvct_el0读取); - 一个 seqlock 版本号,用来检测「读的过程中内核更新了数据」。
glibc 的 __vdso_clock_gettime 做的是:读版本号 → 读基准与硬件计数 → 用 mult/shift 换算成纳秒 → 再读版本号确认没变。整个过程是纯用户态,成本约 20–30 ns,比系统调用快一个数量级。这也是为什么「每次循环读一次时钟」在现代 Linux 上可以接受。
vDSO 只覆盖少数时钟:CLOCK_REALTIME、CLOCK_MONOTONIC、CLOCK_REALTIME_COARSE、CLOCK_MONOTONIC_COARSE、CLOCK_BOOTTIME(较新内核)。CLOCK_THREAD_CPUTIME_ID、CLOCK_TAI 以及动态时钟必须走系统调用。判断方法是 strace -e trace=clock_gettime:看得到的是慢路径,看不到不代表没执行。
慢路径进入 ksys_clock_gettime(),按 clockid 找到 struct k_clock,调用它的 clock_get_timespec 回调。标准时钟的回调最终读 timekeeper(kernel/time/timekeeping.c),它用 seqlock 保护 xtime/wall_to_monotonic/offsets 等字段;CLOCK_MONOTONIC 不直接存一个绝对值,而是由「基准 + 经过的 cycles」现算,所以不会因为 NTP 调整而跳变。
CLOCK_MONOTONIC_COARSE 与 CLOCK_REALTIME_COARSE 只在 tick 更新时刷新,读的是 tk->xtime_sec 等缓存值,分辨率是一个 jiffy(通常 1–10 ms),但完全不访问硬件计数器。高频打日志、采样时用它可以把时间开销压到接近零;代价是同一 jiffy 内的多次读数完全相同。
nanosleep 与 hrtimer
nanosleep 的系统调用入口是 hrtimer_nanosleep(),核心循环在 do_nanosleep():
/* 按 Linux 6.8 kernel/time/hrtimer.c 的结构简化,省略 freezer 与锁 */
static int __sched do_nanosleep(struct hrtimer_sleeper *t, enum hrtimer_mode mode)
{
for (;;) {
set_current_state(TASK_INTERRUPTIBLE);
hrtimer_sleeper_start_expires(t, mode); /* 挂到 per-CPU 红黑树 */
if (likely(t->task))
freezable_schedule(); /* 让出 CPU */
hrtimer_cancel(&t->timer);
mode = HRTIMER_MODE_ABS; /* 被唤醒后按绝对时间重算 */
if (!t->task)
return 0;
if (signal_pending(current))
return -ERESTART_RESTARTBLOCK; /* 相对模式 → EINTR */
}
}
三个细节解释了前面的用户态语义:
mode在第一次唤醒后被改成HRTIMER_MODE_ABS。所以即使相对休眠被信号打断,内核也会用「原截止时间」重算剩余,而不是重新睡一整段。这正是rem能给出正确剩余时间的原因。signal_pending()检查发生在hrtimer_cancel()之后。定时器到期与信号到达同时发生时,内核先取消定时器,再决定返回 0 还是EINTR;不会出现「信号丢了、定时器也没了」的窗口。- 绝对模式不返回
-ERESTART_RESTARTBLOCK,而是让schedule()在信号处理后继续睡到绝对截止。这就是clock_nanosleep(TIMER_ABSTIME)不需要用户态循环的底层原因。
hrtimer 的组织是 per-CPU 的红黑树(hrtimer_bases),按到期时间排序;每个 CPU 还有一个高精度时钟事件设备(clock_event_device)负责在最近一个到期点触发中断。没有高精度事件设备时,内核退化为 tick 驱动的 timer wheel(kernel/time/timer.c 的 timer_list),精度受 HZ 限制。timerfd、POSIX 定时器、nanosleep 都走 hrtimer,所以它们的精度上限相同——差别只在通知方式。
POSIX 定时器:k_itimer 与信号队列
timer_create 不创建线程,也不创建 fd,而是创建一个 struct k_itimer,挂进 posix_timers_idr(IDR),返回一个 timer_t 句柄。关键字段:
| 字段 | 作用 |
|---|---|
it_clock | 绑定的 clockid |
it.timer | 内嵌的 hrtimer(信号通知时) |
it_overrun / it_overrun_last | 溢出计数;timer_getoverrun 读的就是它 |
it_sigq | 待投递的 sigqueue(实时信号排队) |
it_pid / it_process | 目标进程/线程 |
timer_settime 最终调用 hrtimer_start_expires() 启动内嵌 hrtimer。到期时 posix_timer_fn() 被调用,它做两件事:把 it_overrun 加一(如果上一次还没投递),以及通过 posix_timer_queue_signal() 把信号排进目标进程的 sigqueue。信号投递不是中断上下文里直接执行你的处理器,而是排队,等进程返回用户态时再走正常的信号递送路径。这就解释了为什么:
- 标准信号(
SIGALRM)会合并,实时信号(SIGRTMIN)会排队; timer_getoverrun()统计的是「排队期间又到期了几次」,与信号是否合并无关;SIGEV_THREAD是 glibc 在用户态收信号后再pthread_create,不是内核线程。
timerfd:一个 anon inode 加一个等待队列
timerfd_create 通过 anon_inode_getfile() 创建一个匿名 inode 文件,私有数据是 struct timerfd_ctx:
| 字段 | 作用 |
|---|---|
t.tmr | 内嵌 hrtimer |
ticks | 自上次 read 以来的到期次数(u64) |
wqh | 等待队列;poll/epoll 在这里等 |
clockid | 绑定的时钟 |
moffs | 绝对定时器用到的「单调偏移」 |
settime_flags | 记录 TFD_TIMER_ABSTIME/CANCEL_ON_SET |
到期回调 timerfd_tmrproc() 的语义可以概括为:
/* 按 Linux 6.8 fs/timerfd.c 的结构简化 */
static enum hrtimer_restart timerfd_tmrproc(struct hrtimer *htmr)
{
struct timerfd_ctx *ctx = container_of(htmr, struct timerfd_ctx, t.tmr);
ctx->ticks++; /* 累计,不丢事件 */
wake_up_poll(&ctx->wqh, EPOLLIN); /* 唤醒 epoll/poll */
return HRTIMER_NORESTART; /* 周期由 timerfd_setup 重挂 */
}
read() 走 timerfd_read():如果 ticks == 0 且阻塞,就 wait_event_interruptible_locked() 睡在 wqh 上;醒来后把 ticks 取走并清零。因此:
- 漏读不会丢事件,只会把多次到期合并成一个大于 1 的计数;
epoll的 LT 模式会在ticks归零前反复报告可读,ET 模式只报一次,必须读到EAGAIN或计数归零;fork后父子共享同一个ctx,谁先读谁拿走计数,另一个只能等下一次到期。
strace -c -e trace=clock_gettime,clock_nanosleep,timerfd_settime,read看有没有慢路径系统调用。cat /proc/$PID/timerslack_ns看 slack 是否被调大。cat /proc/timer_list(需 root)看到期队列里都有谁。perf stat -e cycles,instructions对比 vDSO 与 syscall 版本的耗时。- 用
bpftrace挂hrtimer_start/hrtimer_expire_entry,看实际到期与预期差。
2.11.10 程序一:绝对截止的周期采样器
这个程序验证三件事:绝对截止能否把周期钉在 50 ms;调度延迟如何被测量;SIGINT 打断绝对休眠后为什么不丢轮次。
/* period_jitter.c — 绝对截止的周期任务:测迟到与丢轮 */
#define _GNU_SOURCE
#include <errno.h>
#include <signal.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <time.h>
#include <unistd.h>
#define PERIOD_MS 50
#define TICKS 20
static volatile sig_atomic_t stop;
static void on_sigint(int sig)
{
(void)sig;
stop = 1;
}
static void ts_add_ms(struct timespec *ts, long ms)
{
ts->tv_sec += ms / 1000;
ts->tv_nsec += (ms % 1000) * 1000000L;
if (ts->tv_nsec >= 1000000000L) {
ts->tv_sec += 1;
ts->tv_nsec -= 1000000000L;
}
}
static long long ts_diff_us(const struct timespec *a, const struct timespec *b)
{
return (long long)(a->tv_sec - b->tv_sec) * 1000000LL
+ (a->tv_nsec - b->tv_nsec) / 1000;
}
int main(void)
{
struct sigaction sa = {0};
sa.sa_handler = on_sigint;
sigemptyset(&sa.sa_mask);
sa.sa_flags = 0; /* 不自动重启,让 EINTR 可见 */
if (sigaction(SIGINT, &sa, NULL) == -1) {
perror("sigaction");
return EXIT_FAILURE;
}
struct timespec next;
if (clock_gettime(CLOCK_MONOTONIC, &next) == -1) {
perror("clock_gettime");
return EXIT_FAILURE;
}
ts_add_ms(&next, PERIOD_MS);
long long min = 0, max = 0, sum = 0;
int n = 0, missed = 0;
for (int i = 0; i < TICKS && !stop; i++) {
int rc;
do {
rc = clock_nanosleep(CLOCK_MONOTONIC, TIMER_ABSTIME, &next, NULL);
} while (rc == EINTR);
if (rc != 0) {
errno = rc;
perror("clock_nanosleep");
break;
}
struct timespec now;
if (clock_gettime(CLOCK_MONOTONIC, &now) == -1) {
perror("clock_gettime");
break;
}
long long late = ts_diff_us(&now, &next);
if (n == 0 || late < min) min = late;
if (n == 0 || late > max) max = late;
sum += late;
n++;
ts_add_ms(&next, PERIOD_MS);
/* 如果本轮已经超过下一个截止,跳过错过的轮次并计数 */
if (clock_gettime(CLOCK_MONOTONIC, &now) == -1) break;
while (ts_diff_us(&now, &next) > 0) {
ts_add_ms(&next, PERIOD_MS);
missed++;
}
printf("tick %2d late=%+lld us\n", i, late);
}
printf("ticks=%d late min=%lld avg=%lld max=%lld us missed=%d\n",
n, n ? min : 0, n ? sum / n : 0, max, missed);
return EXIT_SUCCESS;
}
编译与运行:
cc -std=c17 -D_GNU_SOURCE -Wall -Wextra -Werror -O2 -g \
period_jitter.c -o period_jitter
./period_jitter
预期输出(节选):
tick 0 late=+38 us
tick 1 late=+52 us
...
tick 19 late=+41 us
ticks=20 late min=21 avg=63 max=310 us missed=0
关键行:
late是「醒来时刻 − 截止时刻」,正常应在几十微秒;偶尔几百微秒是调度与 timer slack。missed只在do_work()超过一个周期时才增长。本程序没有工作负载,所以应为 0。- 按
Ctrl-C后,信号处理器置stop,但绝对休眠不会被EINTR提前打断;程序会在下一个 tick 退出。这恰好证明「绝对休眠抗信号」不是传说。
把 clock_nanosleep 换成 nanosleep(&period, &period) 再跑一次,avg 会随轮次线性增长——这就是相对休眠的漂移,2.11.7 的公式在真实机器上的复现。
2.11.11 程序二:一个 timerfd 管理多个截止时间
这个程序验证:多个不同周期的任务如何共享一个 timerfd;最小堆如何决定下一次唤醒;read 返回的累计计数为什么不能假设为 1。
/* deadline_heap.c — 一个 timerfd + 最小堆管理 5 个周期截止 */
#define _GNU_SOURCE
#include <errno.h>
#include <stdint.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/epoll.h>
#include <sys/timerfd.h>
#include <time.h>
#include <unistd.h>
#define MAX_DEADLINES 32
struct deadline {
struct timespec when;
int id;
long period_ms;
};
static struct deadline heap[MAX_DEADLINES];
static int heap_len;
static long long ts_diff_ns(const struct timespec *a, const struct timespec *b)
{
return (long long)(a->tv_sec - b->tv_sec) * 1000000000LL
+ (a->tv_nsec - b->tv_nsec);
}
static void ts_add_ms(struct timespec *ts, long ms)
{
ts->tv_sec += ms / 1000;
ts->tv_nsec += (ms % 1000) * 1000000L;
if (ts->tv_nsec >= 1000000000L) {
ts->tv_sec += 1;
ts->tv_nsec -= 1000000000L;
}
}
static void heap_swap(int i, int j)
{
struct deadline tmp = heap[i];
heap[i] = heap[j];
heap[j] = tmp;
}
static void heap_up(int i)
{
while (i > 0) {
int parent = (i - 1) / 2;
if (ts_diff_ns(&heap[parent].when, &heap[i].when) <= 0)
break;
heap_swap(parent, i);
i = parent;
}
}
static void heap_down(int i)
{
for (;;) {
int left = 2 * i + 1, right = left + 1, best = i;
if (left < heap_len &&
ts_diff_ns(&heap[left].when, &heap[best].when) < 0)
best = left;
if (right < heap_len &&
ts_diff_ns(&heap[right].when, &heap[best].when) < 0)
best = right;
if (best == i)
break;
heap_swap(i, best);
i = best;
}
}
static void heap_push(struct deadline d)
{
if (heap_len >= MAX_DEADLINES) {
fprintf(stderr, "heap full\n");
exit(EXIT_FAILURE);
}
heap[heap_len] = d;
heap_up(heap_len++);
}
static struct deadline heap_pop(void)
{
struct deadline top = heap[0];
heap[0] = heap[--heap_len];
heap_down(0);
return top;
}
/* 把 timerfd 重挂到堆顶截止时间;堆空则解除定时器 */
static int rearm(int tfd)
{
struct itimerspec its = {0};
if (heap_len == 0)
return timerfd_settime(tfd, 0, &its, NULL);
struct timespec now;
if (clock_gettime(CLOCK_MONOTONIC, &now) == -1)
return -1;
long long ns = ts_diff_ns(&heap[0].when, &now);
if (ns <= 0) {
its.it_value.tv_nsec = 1; /* 已过期:尽快触发一次 */
} else {
its.it_value.tv_sec = ns / 1000000000LL;
its.it_value.tv_nsec = ns % 1000000000LL;
}
return timerfd_settime(tfd, 0, &its, NULL);
}
int main(void)
{
int tfd = timerfd_create(CLOCK_MONOTONIC, TFD_CLOEXEC | TFD_NONBLOCK);
if (tfd == -1) {
perror("timerfd_create");
return EXIT_FAILURE;
}
struct timespec base;
if (clock_gettime(CLOCK_MONOTONIC, &base) == -1) {
perror("clock_gettime");
return EXIT_FAILURE;
}
const long periods[] = { 100, 150, 200, 300, 500 };
for (int i = 0; i < 5; i++) {
struct deadline d = { .id = i, .period_ms = periods[i] };
d.when = base;
ts_add_ms(&d.when, d.period_ms);
heap_push(d);
}
int ep = epoll_create1(EPOLL_CLOEXEC);
if (ep == -1) {
perror("epoll_create1");
return EXIT_FAILURE;
}
struct epoll_event ev = { .events = EPOLLIN, .data.fd = tfd };
if (epoll_ctl(ep, EPOLL_CTL_ADD, tfd, &ev) == -1) {
perror("epoll_ctl");
return EXIT_FAILURE;
}
if (rearm(tfd) == -1) {
perror("timerfd_settime");
return EXIT_FAILURE;
}
int fired = 0;
while (fired < 12) {
struct epoll_event out;
int n = epoll_wait(ep, &out, 1, 2000);
if (n == -1) {
if (errno == EINTR) continue;
perror("epoll_wait");
break;
}
if (n == 0) {
fprintf(stderr, "epoll timeout\n");
break;
}
uint64_t exp;
ssize_t r = read(tfd, &exp, sizeof exp);
if (r == -1) {
if (errno == EAGAIN || errno == EINTR) continue;
perror("read timerfd");
break;
}
if (r != (ssize_t)sizeof exp) {
fprintf(stderr, "short read: %zd\n", r);
break;
}
struct timespec now;
if (clock_gettime(CLOCK_MONOTONIC, &now) == -1) break;
while (heap_len > 0 &&
ts_diff_ns(&heap[0].when, &now) <= 0) {
struct deadline d = heap_pop();
printf("id=%d period=%ldms exp=%llu\n",
d.id, d.period_ms, (unsigned long long)exp);
ts_add_ms(&d.when, d.period_ms);
heap_push(d);
if (++fired >= 12) break;
}
if (rearm(tfd) == -1) {
perror("timerfd_settime");
break;
}
}
close(ep);
close(tfd);
return EXIT_SUCCESS;
}
预期输出:id=0 每 100 ms 出现一次,id=1 每 150 ms,依此类推;exp 在正常情况下是 1,机器卡顿时可能大于 1。若把 epoll_wait 换成 sleep(1) 再读,exp 会累积成 5–10,while 循环会一次性补跑所有过期任务——这是「漏读不丢事件」的直接证据。
三个边界:
- 堆顶已过期:
rearm把it_value设为 1 ns,保证立刻触发,而不是传 0(传 0 是解除定时器)。 epoll_wait被信号打断:EINTR后continue,不重新计算堆——timerfd的状态还在。read得到EAGAIN:说明 ET/LT 竞态下事件已被别的路径消费,继续等即可,不要当成错误退出。
2.11.12 程序三:POSIX 定时器的溢出计数
这个程序故意在 120 ms 内不处理 10 ms 周期的定时器信号,验证 timer_getoverrun() 能把「合并了多少次」量化出来。
/* posix_overrun.c — 阻塞信号,观察 overrun */
#define _GNU_SOURCE
#include <errno.h>
#include <signal.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <time.h>
#include <unistd.h>
int main(void)
{
sigset_t mask;
sigemptyset(&mask);
sigaddset(&mask, SIGRTMIN);
if (sigprocmask(SIG_BLOCK, &mask, NULL) == -1) {
perror("sigprocmask");
return EXIT_FAILURE;
}
struct sigevent sev = {0};
sev.sigev_notify = SIGEV_SIGNAL;
sev.sigev_signo = SIGRTMIN;
sev.sigev_value.sival_int = 7;
timer_t tid;
if (timer_create(CLOCK_MONOTONIC, &sev, &tid) == -1) {
perror("timer_create");
return EXIT_FAILURE;
}
struct itimerspec its = {0};
its.it_value.tv_nsec = 10 * 1000000L;
its.it_interval.tv_nsec = 10 * 1000000L;
if (timer_settime(tid, 0, &its, NULL) == -1) {
perror("timer_settime");
return EXIT_FAILURE;
}
/* 故意不处理信号:让定时器在 120ms 内反复到期 */
struct timespec req = { .tv_sec = 0, .tv_nsec = 120 * 1000000L };
while (nanosleep(&req, &req) == -1) {
if (errno != EINTR) {
perror("nanosleep");
return EXIT_FAILURE;
}
}
siginfo_t si;
memset(&si, 0, sizeof si);
int sig = sigwaitinfo(&mask, &si);
if (sig == -1) {
perror("sigwaitinfo");
return EXIT_FAILURE;
}
int overrun = timer_getoverrun(tid);
if (overrun == -1) {
perror("timer_getoverrun");
return EXIT_FAILURE;
}
printf("sig=%d value=%d overrun=%d\n",
sig, si.si_value.sival_int, overrun);
/* 清空可能排队的实时信号,避免进程退出前还有未处理信号 */
sigset_t pending;
while (sigpending(&pending) == 0 &&
sigismember(&pending, SIGRTMIN) == 1) {
siginfo_t tmp;
if (sigwaitinfo(&mask, &tmp) == -1)
break;
}
if (timer_delete(tid) == -1) {
perror("timer_delete");
return EXIT_FAILURE;
}
return EXIT_SUCCESS;
}
预期输出:overrun 在 8–11 之间(120 ms / 10 ms ≈ 12 次到期,第一次作为待处理信号,其余计入 overrun)。具体值取决于调度,不应断言等于某个常数。
关键点:
overrun是「本次投递的信号对应多少次额外到期」,不是「总共到期几次」。总次数 ≈overrun + 1。- 如果换成
SIGALRM(标准信号),sigwaitinfo只会看到一次,overrun仍然有效——溢出计数独立于信号是否排队。 - 如果把
SIGRTMIN换成SIGEV_THREAD,timer_getoverrun依然可用,但回调线程的并发会让「上一次是否处理完」变得难以定义;高频场景不要用SIGEV_THREAD。
2.11.13 实验:用 strace、/proc 与 bpftrace 看定时器
实验一:系统调用、vDSO 与 timer slack
# 1. 观察 clock_gettime 是否走系统调用
strace -f -e trace=clock_gettime ./period_jitter 2>&1 | head -5
# 预期:没有输出(x86-64 上走 vDSO)
# 2. 观察 clock_nanosleep 的实际睡眠时长(-T 显示系统调用耗时)
strace -T -e trace=clock_nanosleep ./period_jitter 2>&1 | head -8
# 预期:每行 <...clock_nanosleep resumed> <0.0501xx>
# 3. 看 timer slack
./period_jitter & pid=$!
cat /proc/$pid/timerslack_ns # 默认 50000(ns)
cat /proc/$pid/status | grep -i timer
wait $pid
# 4. 看到期队列(需要 root 或 CAP_SYS_ADMIN)
sudo cat /proc/timer_list | grep -A3 "hrtimer" | head -20
怎么读:
strace里没有clock_gettime,不代表程序没读时间;它走的是 vDSO。把同一个程序用setarch -R关闭随机化再跑,结论相同。clock_nanosleep的<0.0501xx>是系统调用停留时间,约等于睡眠时长,不是调度延迟。要看延迟,比较程序打印的late。/proc/$pid/timerslack_ns默认 50000;用prctl(PR_SET_TIMERSLACK, 1)后应变成 1。注意写该文件需要CAP_SYS_NICE(改自己可以)。/proc/timer_list在新内核可能因CONFIG_TIMER_LIST未开而不存在;没有它就用bpftrace。
在负载下复现抖动:
# 终端 A:制造 CPU 压力
stress-ng --cpu 4 --timeout 20s
# 终端 B:绑到一个核,观察 late 的 max
taskset -c 0 ./period_jitter
预期:max 从几十微秒涨到几毫秒;missed 仍可能是 0(因为 50 ms 周期足够长)。把周期改成 1 ms,missed 就会开始增长,late 分布也会出现双峰——一峰是正常唤醒,一峰是被调度器推迟后的补跑。
实验二:overrun、实时优先级与时钟跳变
# 1. 基线:故意延迟处理,观察 overrun
./posix_overrun
# 预期:overrun≈9(120ms / 10ms - 1)
# 2. 用实时优先级减少调度延迟
sudo chrt -f 50 ./posix_overrun
# 预期:overrun 略降,但不会变成 0——因为程序自己睡了 120ms
# 3. 看 hrtimer 的启动与到期(需要 root)
sudo bpftrace -e '
tracepoint:timer:hrtimer_start {
@start[comm] = count();
}
tracepoint:timer:hrtimer_expire_entry {
@expire[comm] = count();
}
interval:s:5 { exit(); }'
# 预期:period_jitter/posix_overrun 的 start 与 expire 计数接近
# 4. 时钟跳变:让 timerfd 感知 settimeofday
# 先运行一个 CLOCK_REALTIME + TFD_TIMER_CANCEL_ON_SET 的 timerfd 程序,
# 再在另一个终端执行(需要 CAP_SYS_TIME):
sudo date -s "$(date -d '+1 hour' '+%Y-%m-%d %H:%M:%S')"
# 预期:该程序的 read 返回 -1/ECANCELED,而不是一直睡到旧截止时间
第 4 步的判定逻辑值得写进代码注释:
/* 只对 CLOCK_REALTIME 有意义;read 返回 ECANCELED 表示时钟被 step */
if (r == -1 && errno == ECANCELED) {
/* 重新读 CLOCK_REALTIME,重算绝对截止,再 timerfd_settime */
}
TFD_TIMER_CANCEL_ON_SET 只对非连续修改(settimeofday、clock_settime、NTP step)生效;NTP 的缓慢 slew(adjtime)不会触发它。这是它和「定期重读墙上时钟」的分工:前者处理跳变,后者处理漂移。
| 项 | 记录 |
|---|---|
| 内核版本 / 架构 | uname -r、uname -m |
| clocksource | cat /sys/devices/system/clocksource/clocksource0/current_clocksource |
HZ / 高精度定时器 |
grep CONFIG_HZ= /boot/config-$(uname -r)、CONFIG_HIGH_RES_TIMERS |
| 是否虚拟化 | systemd-detect-virt |
基线 late |
period_jitter 的 min/avg/max |
负载下 late |
stress-ng 同时运行 |
| overrun | posix_overrun 输出 |
2.11.14 陷阱清单:定时器的十五个现场
| # | 现象 | 原因 | 修复 |
|---|---|---|---|
| 1 | 周期任务越来越慢 | 相对休眠把工作耗时累加进周期 | 用绝对截止 + TIMER_ABSTIME |
| 2 | 超时永远不触发 | SA_RESTART 让 read 自动重启 | 超时路径不设 SA_RESTART,或改用 poll/timerfd |
| 3 | 服务在 NTP 校时后定时器乱跳 | 用 CLOCK_REALTIME 做间隔 | 间隔用 CLOCK_MONOTONIC,闹钟才用 REALTIME |
| 4 | 合盖唤醒后看门狗晚了 8 小时 | CLOCK_MONOTONIC 在 suspend 时停表 | 跨 suspend 用 CLOCK_BOOTTIME |
| 5 | clock_nanosleep 的错误从不触发 | 按 -1/errno 检查,实际返回错误码 | 用 int rc,rc != 0 时 errno = rc |
| 6 | timerfd 读取出 EINVAL | read 的 count < 8 | 用 uint64_t,sizeof 传 8 |
| 7 | timerfd 在 ET 模式下饿死 | 只读一次,ticks 没清零 | 循环读到 EAGAIN,或读出的计数全部消费 |
| 8 | timerfd 把多次到期当一次 | 假设 read 返回 1 | 按计数补跑;监控大于 1 的频率 |
| 9 | timer_gettime 算出的截止时间不对 | 它返回相对剩余,不是绝对时间 | 设置时自己保存绝对截止 |
| 10 | 高频定时器把 CPU 打满 | SIGEV_THREAD 每次到期建线程 | 用 SIGEV_SIGNAL + 专用线程,或 timerfd |
| 11 | 定时器在容器里莫名延后 | timer slack 被调大 / cgroup CPU 限流 | 查 /proc/$pid/timerslack_ns 与 cpu.max |
| 12 | fork 后两个进程抢同一个定时器 | timerfd 的 ctx 被父子共享 | fork 后关掉不需要的一侧,或每个进程各自 timerfd_create |
| 13 | 子进程继承不到 POSIX 定时器 | POSIX 定时器不跨 fork 继承 | 在子进程重新 timer_create |
| 14 | 时钟跳变后闹钟提前/延后 | TFD_TIMER_CANCEL_ON_SET 没设,或用了单调时钟 | 实时闹钟加 CANCEL_ON_SET 并处理 ECANCELED |
| 15 | 32 位程序 2038 年时间错乱 | time_t 只有 32 位 | -D_TIME_BITS=64,并确认内核/文件系统支持 |
timerfd_create与timerfd_settime不是同一版本:timerfd_create在 Linux 2.6.22 就有,timerfd_settime/timerfd_gettime到 2.6.25 才补齐;TFD_CLOEXEC/TFD_NONBLOCK是 2.6.27。老内核上编译能过、运行会ENOSYS。TFD_TIMER_CANCEL_ON_SET需要CLOCK_REALTIME:内核对CLOCK_MONOTONIC直接返回EINVAL;对CLOCK_BOOTTIME同样不支持。- vDSO 不是所有架构都有:x86-64、arm64 有;某些嵌入式架构或 seccomp 沙箱会退回系统调用。性能测试里不要把「vDSO 的 20 ns」当成跨平台常数。
2.11.15 本章出口检查
| 检查项 | 达标线 | 没达标去哪 |
|---|---|---|
说出 CLOCK_REALTIME/MONOTONIC/BOOTTIME 的差异 | 覆盖跳变与 suspend 两点 | 2.11.1 |
| 写出绝对截止时间的加法与剩余毫秒计算 | 处理纳秒进位与向上取整 | 2.11.2 |
正确处理 nanosleep 的 EINTR 与 rem | 不重复睡已睡过的时间 | 2.11.3 |
解释 alarm 超时为什么不能设 SA_RESTART | 说出自动重启会吃掉超时 | 2.11.4 |
用 timer_create + sigwaitinfo 收定时器 | 能说出 sigev_value 与 overrun | 2.11.5 |
用 timerfd 写一个 10 次 tick 的程序 | read 8 字节、处理累计值 | 2.11.6 |
| 为 10 万连接设计超时方案 | 说出「堆 + 单 timerfd」的理由 | 2.11.7 |
Q1 一个服务用 clock_gettime(CLOCK_REALTIME) 记录请求开始时间,用同一时钟计算耗时。NTP 在请求处理期间把时钟拨慢 2 秒,耗时统计会怎样?应该改用哪个时钟?
Q2 nanosleep(&req, &rem) 返回 -1、errno=EINTR。下一次循环应该传 req 还是 rem?为什么?
Q3 为什么用 alarm() 给 read() 加超时时,sigaction 不能设 SA_RESTART?
Q4 timerfd 的 read 返回 8 字节的计数是 3,说明什么?事件循环应该怎么处理?
Q5 周期任务用 clock_nanosleep(CLOCK_MONOTONIC, 0, &period, NULL)(相对模式)每轮睡 100 ms,工作耗时 5 ms。一小时后比预期少跑多少轮?怎么改?
答案
A1 耗时可能变成负数(结束时间早于开始时间),或者被算成 0/巨大值;监控会抖动甚至误报警。应改用 CLOCK_MONOTONIC(跨 suspend 的场景用 CLOCK_BOOTTIME)。
A2 传 rem。内核在返回 EINTR 时已把「还剩多久」写进 rem;再传原始 req 会把已经睡过的时间重复计算。
A3 SA_RESTART 会让被信号打断的 read 自动重启,信号处理器返回后 read 继续阻塞,超时逻辑永远等不到 EINTR。必须让 read 以 EINTR 返回,才能检查 timed_out。
A4 自上次读取以来定时器到期了 3 次(事件循环被阻塞或处理太慢)。应读取计数并把「3 次 tick」补进业务逻辑(例如补 3 个周期),而不是只处理 1 次;同时监控 overrun,判断是否过载。
A5 实际周期约 105 ms,一小时 3600 秒跑 3600 / 0.105 ≈ 34285 轮,预期 36000 轮,少约 1715 轮。改成绝对截止:先算 next,每轮 clock_nanosleep(..., TIMER_ABSTIME, &next, NULL),然后 next += 100ms。
母本对应
| 母本 | 章节 | 本书落点 | 程度 |
|---|---|---|---|
| K | 第 23 章 定时器与休眠(p.397–423,PDF p.426–452) | 2.11.3–2.11.14 | 完整:23.1 间隔定时器、23.2 调度与精度、23.3 超时、23.4 休眠、23.5 POSIX 时钟、23.6 POSIX 定时器、23.7 timerfd |
| K | 第 10 章 时间(p.191–206) | 2.11.1–2.11.2、2.11.8 | 精选:time/gettimeofday/clock_gettime 与时间换算 |
| K | 第 20–22 章 信号(p.321–396,PDF p.350–425) | 2.11.4–2.11.5、2.11.12 | 背景:信号排队、SA_RESTART、sigwaitinfo、实时信号 |
| C | 第 8.5 节 信号 | 2.11.4 | 背景(alarm 与 EINTR 的交互) |
| O | 第 26 章 并发:介绍 | 2.11.7 | 背景(同步等待与异步通知的取舍) |
| W | — | — | 无直接落点(本章不涉及协议分层) |
| N | — | — | 无直接落点(网络超时在 2.17–2.18 展开) |
下一站:2.12 线程把执行单元从进程细化到线程:共享地址空间带来性能,也带来本章没遇到过的竞态。定时器里的
volatile sig_atomic_t只是开胃菜,真正的同步问题从下一章开始。