Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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、容器热迁移),以及机器挂起再恢复。服务端代码必须在设计时就选定一条线,而不是等线上出事再改。

定义 2.11.1 · 时钟 ID(clockid_t)

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 上几乎免费。

关于「读时间」的四个误解
  1. 「time(NULL) 够用了」——它只有秒级分辨率,且读的是 CLOCK_REALTIME。测量间隔、做超时都不该用它。
  2. 「gettimeofday 是标准做法」——POSIX.1-2008 已把它标为 obsolescent,推荐 clock_gettime。gettimeofday 返回 struct timeval,分辨率受 tv_usec 限制,且没有单调版本。新代码不应再用它取当前时间(历史接口兼容除外)。
  3. 「CLOCK_MONOTONIC 包含休眠时间」——不包含。笔记本合盖 8 小时后,CLOCK_MONOTONIC 只前进了几秒。需要跨 suspend 的看门狗用 CLOCK_BOOTTIME。
  4. 「时间戳用 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 的规则:

  1. 启动/首次调用 localtime/strftime 时,读环境变量 TZ;
  2. TZ 未设置 → 用 /etc/localtime(通常是 /usr/share/zoneinfo/Asia/Shanghai 的符号链接);
  3. TZ 已设置但为空,或值无法按任何格式解释 → UTC(不是系统时区);
  4. TZ=Asia/Shanghai → 读 /usr/share/zoneinfo/Asia/Shanghai;文件缺失/无法解释 → 静默回退 UTC;
  5. TZ=UTC0/TZ=:UTC → 固定偏移;TZ=EST5EDT → POSIX 规则;
  6. 改了 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;
}
时区的四个坑
  1. localtime() 返回静态 struct tm,多线程用 localtime_r;gmtime 同理。
  2. tm_isdst=-1 让 mktime 自己判断夏令时;填 0/1 可能差一小时。
  3. TZ 是进程全局:多线程程序不要一边改 TZ 一边格式化;在启动时固定。
  4. 容器里可能没有 /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/EINTRrem 给出剩余相对时间
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 下有两个关键语义:

  1. rem 参数被忽略。绝对时间没有「剩余」的概念——醒来后重新读时钟,与 dl 比较即可。
  2. EINTR 不再是错误。被信号打断后,内核不会返回 EINTR 让你续睡;它会在信号处理器返回后重新检查绝对时间,若还没到就继续睡。也就是说,绝对休眠天然抗信号打断,不需要循环。这一点与相对休眠相反,是周期任务应该用绝对模式的另一个理由。

clock_nanosleep 还支持 CLOCK_REALTIME。用实时时钟做绝对休眠意味着:如果系统时间在睡眠期间被调后,你可能多睡;被调前,你可能立刻醒来。除非你在实现「闹钟」(必须在墙上时间 7:00 响),否则一律用 CLOCK_MONOTONIC。

关于休眠的五个误解
  1. 「sleep(1) 精确睡 1 秒」——它保证至少 1 秒,可能更久(调度延迟、timer slack、系统负载)。高精度场景要接受「不早于」而不是「等于」。
  2. 「sleep 不会被信号打断」——会被打断,且返回剩余秒数。很多代码忽略返回值,醒来后以为时间到了。
  3. 「usleep(1000000) 安全」——某些实现要求参数小于 1000000,等于 1 秒会 EINVAL。用 nanosleep。
  4. 「nanosleep 被打断后重睡整个时长」——多睡一倍。必须用 rem 续睡。
  5. 「sleep(0) 什么也不做」——它至少让出一次 CPU(sched_yield 语义),在自旋重试里可能把 CPU 占满。

2.11.4 alarm 与 setitimer:信号驱动的定时器

alarm() 是最古老的定时器:每个进程一个,到期发 SIGALRM,一次性。

unsigned int left = alarm(2);   /* 2 秒后 SIGALRM;返回旧定时器剩余秒数 */
alarm(0);                       /* 取消 */

它只支持整秒,且「每个进程只有一个」——两个库函数都想用 alarm 时会互相覆盖。setitimer() 稍微好一点:每个进程每种类型一个,支持微秒,且可以周期触发。

定义 2.11.2 · `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) 取消当前定时器并返回剩余秒数。

这段代码有四个必须理解的点:

  1. sa_flags = 0。若设了 SA_RESTART,read 会在信号处理器返回后自动重启,超时永远不生效。这是 SA_RESTART 与超时逻辑的正面冲突。
  2. timed_out 必须是 volatile sig_atomic_t。处理器与主流程共享它,普通 int 可能被编译器缓存到寄存器。
  3. 存在竞态窗口:read 刚返回、alarm(0) 还没执行时,SIGALRM 可能到达,把 timed_out 置 1。此时数据已经读到,函数仍返回 r,不会误判为超时;但处理器会多执行一次,这无害。真正危险的是反过来——先检查 timed_out 再读,那是另一类竞态。
  4. alarm 与 sleep 共享 SIGALRM。在用了 alarm 的程序里调用 sleep,两者会互相打断。同理,任何第三方库调 alarm 都会破坏你的超时。

setitimer 能提供毫秒级超时,但同样有「信号打断系统调用」的副作用,而且仍然每进程每类型只有一个。要在同一个进程里同时管理多个超时,信号方案就撑不住了——这正是 POSIX 定时器与 timerfd 出现的原因。

2.11.5 POSIX 定时器:timer_create 与 sigevent

POSIX 定时器把「定时器」变成内核对象,用 timer_t 标识。一个进程可以创建任意多个,每个可以绑定不同的时钟、不同的通知方式。

定义 2.11.3 · POSIX 每进程定时器
  • 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 没有意义。

关于 timerfd 的四个误解
  1. 「read 一次读 1 字节也行」——不行。read 的 count 必须是 8,否则 EINVAL。这是内核强制的对齐要求,不是建议。
  2. 「读出的值总是 1」——不一定。事件循环被阻塞、进程被调度走、周期很短时,累计值可能大于 1。
  3. 「timerfd 会自己重复触发,不用管」——它会按 it_interval 重复,但你不读,计数就一直累加,且 fd 一直可读。漏读不会丢事件,但会丢「次数以外的时间信息」。
  4. 「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 并重新对齐即可。两种语义都合法,关键是想清楚要哪一种。

例 2.11.1 · 相对休眠的漂移有多大

设 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 与「非阻塞」混淆
等一组 fdpoll/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_RCVTIMEOalarm(全局、不可嵌套)
事件循环里的周期任务timerfd + epollSIGEV_THREAD(每次建线程)
大量连接的超时管理最小堆 + 单个 timerfd每连接一个 alarm/线程
墙上时间的闹钟(7:00 响)timerfd(CLOCK_REALTIME) + TFD_TIMER_CANCEL_ON_SET单调时钟(不认挂钟)
测量代码耗时CLOCK_MONOTONIC 或 CLOCK_THREAD_CPUTIME_IDgettimeofday
定时器问题的排查顺序
  1. 确认用的是哪条时钟:grep -n "CLOCK_" 源码。超时逻辑里出现 CLOCK_REALTIME 就是可疑点。
  2. 确认是相对还是绝对:周期任务用相对休眠,先怀疑漂移;单次超时用绝对时间,先怀疑「截止时间已过」。
  3. 看信号是否打断:strace -e trace=nanosleep,clock_nanosleep,poll,epoll_wait,注意 EINTR 与重启。
  4. 看实际唤醒间隔:perf sched、bpftrace 的 timer 探针,或简单地在回调里打印 CLOCK_MONOTONIC 差值。
  5. 看 timer slack:cat /proc/$PID/timerslack_ns;高频定时器是否被合并。
  6. 看是否被 suspend:CLOCK_MONOTONIC 停表,CLOCK_BOOTTIME 不停。

2.11.8 时间接口全表:签名、结构体与 errno

这一节回答一个查手册时最费时间的问题:同一个「失败」,到底是返回 -1 并把错误码放进 errno,还是直接把错误码当返回值?哪些 errno 是真会出现的,哪些只是文档里的泛泛之谈?

先记住三条互不相同的约定,它们决定了错误处理代码的形状:

约定代表接口正确写法典型错误写法
返回 -1,设置 errnoclock_gettime、timer_create、timerfd_settime、readif (f(...) == -1) { int e = errno; ... }把返回值当错误码
返回错误码本身,不设置 errnoclock_nanosleepint rc = clock_nanosleep(...); if (rc != 0) { errno = rc; ... }if (rc == -1),永远不成立
返回错误码,且不碰 errnopthread_* 系列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,写 *tpEFAULT、EINVAL、ENODEV、ENOTSUP、EPERMENODEV/ENOTSUP 出现在动态 POSIX 时钟(PTP)上;普通时钟只有前两个
clock_getresint clock_getres(clockid_t clk, struct timespec *res)0,写分辨率EFAULT、EINVALres 可为 NULL,此时只校验 clk
clock_settimeint clock_settime(clockid_t clk, const struct timespec *tp)0EFAULT、EINVAL、EPERM只有 CLOCK_REALTIME 可设;需要 CAP_SYS_TIME
timetime_t time(time_t *tloc)秒数,(time_t)-1 表示错EFAULT(仅当 tloc 非空且不可写)秒级;time_t 宽度是 ABI 问题,见下文
gettimeofdayint gettimeofday(struct timeval *tv, void *tz)0EFAULT已弃用;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_rstruct tm *gmtime_r(const time_t *t, struct tm *out)UTC 分解可重入;tm_isdst=0
localtime_rstruct tm *localtime_r(const time_t *t, struct tm *out)本地时区分解可重入;依赖 TZ/tzset
mktimetime_t mktime(struct tm *tm)本地 struct tm → time_ttm_isdst=-1 让系统判断;会规范化越界字段
timegmtime_t timegm(struct tm *tm)UTC struct tm → time_tGNU/BSD 扩展;不依赖时区
strftimesize_t strftime(char *buf, size_t n, const char *fmt, const struct tm *tm)tm → 字符串返回 0 表示「结果为空」或「缓冲区放不下」;POSIX 未规定 strftime 设置 errno,不要用 errno 判断
strptimechar *strptime(const char *s, const char *fmt, struct tm *tm)字符串 → tm不设置 tm_isdst;解析完要 mktime
tzsetvoid 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) {
    /* 缓冲区不够或格式为空 */
}
`mktime` 的 `tm_isdst`

tm_isdst=1 表示「这是夏令时」,0 表示「不是」,-1 表示「让 mktime 判断」。手动填 0/1 在时区切换日可能差一小时;解析用户输入时先置 -1,再让 mktime 规范化。

休眠:四个接口的边界

API签名成功失败 errno关键语义
sleepunsigned int sleep(unsigned int seconds)返回未睡完的秒数(被信号打断)或 0不设置秒级;与 SIGALRM 共享,可能被其他库的 alarm 打断
usleepint usleep(useconds_t usec)0EINTR、EINVALusec >= 1000000 在部分实现返回 EINVAL;POSIX.1-2008 已废弃
nanosleepint nanosleep(const struct timespec *req, struct timespec *rem)0EFAULT、EINVAL、EINTREINTR 时 rem 写剩余;rem 可为 NULL
clock_nanosleepint clock_nanosleep(clockid_t clk, int flags, const struct timespec *req, struct timespec *rem)0EFAULT、EINVAL、EINTR、ENOTSUPflags 只能是 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说明
alarmunsigned int alarm(unsigned int seconds)返回旧定时器剩余秒数不会失败每进程一个;alarm(0) 取消
setitimerint setitimer(int which, const struct itimerval *new, struct itimerval *old)0EFAULT、EINVALwhich 非法或 tv_usec 越界 → EINVAL
getitimerint getitimer(int which, struct itimerval *cur)0EFAULT、EINVAL返回剩余时间与周期
timer_createint timer_create(clockid_t clk, struct sigevent *sev, timer_t *tid)0,写 *tidEAGAIN、EINVAL、ENOMEM、EPERMEAGAIN:超出配额或内核内存紧张;EPERM:对非本进程 CPU 时钟建定时器
timer_settimeint timer_settime(timer_t tid, int flags, const struct itimerspec *new, struct itimerspec *old)0EFAULT、EINVALflags 只能是 0/TIMER_ABSTIME;it_value 为 0 表示解除
timer_gettimeint timer_gettime(timer_t tid, struct itimerspec *cur)0EFAULT、EINVAL返回的是「距下次到期」,不是绝对截止
timer_getoverrunint timer_getoverrun(timer_t tid)溢出次数 >= 0EINVAL只在信号通知模式下有意义
timer_deleteint timer_delete(timer_t tid)0EINVAL删除已解除的定时器仍成功;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_createint timerfd_create(clockid_t clk, int flags)返回 fdEINVAL、EMFILE、ENFILE、ENOMEMflags 只认 TFD_CLOEXEC、TFD_NONBLOCK;clk 只支持 REALTIME/MONOTONIC/BOOTTIME
timerfd_settimeint timerfd_settime(int fd, int flags, const struct itimerspec *new, struct itimerspec *old)0EFAULT、EINVAL、EBADFflags 含 TFD_TIMER_ABSTIME、TFD_TIMER_CANCEL_ON_SET
timerfd_gettimeint timerfd_gettime(int fd, struct itimerspec *cur)0EFAULT、EINVAL、EBADF绝对定时器返回的 it_value 仍是「距下次到期」
readssize_t read(int fd, void *buf, size_t count)返回 8EAGAIN、EINVAL、EINTR、EBADF、EFAULTcount < 8 → EINVAL;非阻塞且未到期 → EAGAIN
closeint close(int fd)0EINTR、EBADF关闭即删除定时器;fork 后父子共享同一对象

timerfd_settime 的 EINVAL 还包括「TFD_TIMER_CANCEL_ON_SET 与 CLOCK_MONOTONIC 一起用」:这个 flag 只对可被设置的实时时钟有意义,内核对单调时钟会直接拒绝。反过来,TFD_TIMER_ABSTIME 对三条时钟都合法,但绝对时间的零点各不相同。

结构体:每个字段谁维护

struct timespec 只有两个字段,但「谁负责归一化」是 bug 的高发区:

字段类型合法范围谁维护
tv_sectime_t(64 位 Linux 上 long)可正可负;time_t 溢出是 ABI 问题调用者
tv_nseclong[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_intervalstruct timeval微秒分辨率低 1000 倍;tv_usec 必须 < 1000000
it_valuestruct timeval微秒同样是「距下次到期」

struct sigevent 决定 POSIX 定时器如何通知。glibc 把最后几个字段藏在宏后面,直接访问 sigev_notify_function 是标准用法:

字段/宏类型何时使用注意
sigev_valueunion sigval(int 或 void *)所有通知方式随信号/线程参数带回;用来区分多个定时器
sigev_signointSIGEV_SIGNAL、SIGEV_THREAD_ID实时信号可排队;标准信号会合并
sigev_notifyint必填SIGEV_NONE/SIGNAL/THREAD/THREAD_ID
sigev_notify_functionvoid (*)(union sigval)SIGEV_THREAD每次到期由 glibc 新建线程调用
sigev_notify_attributespthread_attr_t *SIGEV_THREADNULL 表示默认属性;可设分离状态与栈大小
sigev_notify_thread_idpid_tSIGEV_THREAD_IDLinux 扩展;必须是内核线程 ID(gettid()),不是 pthread_t

SIGEV_THREAD_ID 的坑在于类型:pthread_self() 返回的是 glibc 的 pthread_t(通常是指针),而 sigev_notify_thread_id 要的是 gettid() 的整数。两者在 NPTL 下数值不同,混用会让信号投递到错误的线程,甚至返回 EINVAL。

flags 位表

flag适用接口语义常见误用
TIMER_ABSTIMEclock_nanosleep、timer_settimeit_value/req 是绝对时间点把「相对 1 秒」当绝对时间,结果立即返回
TFD_TIMER_ABSTIMEtimerfd_settime同上,但 flag 名不同与 TIMER_ABSTIME 混写,编译能过但语义错
TFD_TIMER_CANCEL_ON_SETtimerfd_settime实时时钟被非连续修改时,read 返回 ECANCELED用在 CLOCK_MONOTONIC 上 → EINVAL
TFD_CLOEXECtimerfd_create设置 FD_CLOEXEC以为 exec 后定时器自动销毁(其实会继续走)
TFD_NONBLOCKtimerfd_create设置 O_NONBLOCK忘了 EAGAIN 是正常状态而非错误
PR_SET_TIMERSLACKprctl调整 timer slack(纳秒)以为调到 0 就能「精确唤醒」

errno 速查:按错误码反查

errno可能来自现场含义处理
EINVAL几乎所有接口时钟 ID、flag、tv_nsec、timer_t、read 长度任一非法检查常量拼写与结构体归一化;不要重试
EFAULT所有带指针的接口用户缓冲区不可写/不可读编程错误;修代码
EINTRnanosleep、read、close被信号处理器打断相对休眠用 rem 续睡;read 判断是否业务信号
EAGAINtimerfd 非阻塞 read、timer_create前者是「还没到期」;后者是资源配额前者继续等;后者查 RLIMIT_SIGPENDING 与定时器数量
EPERMclock_settime、timer_create缺 CAP_SYS_TIME,或跨进程 CPU 时钟降级为不可设置;不要反复重试
ENODEV/ENOTSUPclock_gettime、clock_nanosleep动态时钟设备不存在/不支持该操作回退到 CLOCK_MONOTONIC
EMFILE/ENFILEtimerfd_create进程/系统 fd 耗尽查 ulimit -n 与泄漏;不要用「重试」掩盖
ECANCELEDtimerfd read实时时钟被 step,且设了 TFD_TIMER_CANCEL_ON_SET重新读时钟、重算绝对截止、重设定时器
EOVERFLOWclock_gettime(32 位)time_t 装不下秒数用 64 位 time_t 或 _TIME_BITS=64
关于时间 API 返回值的四个误解
  1. 「clock_nanosleep 失败返回 -1」——它返回错误码本身。写 if (clock_nanosleep(...) == -1) 会漏掉所有错误,代码看起来「从不失败」。
  2. 「timer_gettime 返回绝对截止时间」——返回的是「距下次到期」的相对值,即使定时器是用 TIMER_ABSTIME 设置的。想记录绝对截止,自己在设置时保存。
  3. 「timerfd 的 read 可以读 4 字节」——内核要求 count >= 8,否则 EINVAL。读出的 uint64_t 是累计到期次数,不是时间戳。
  4. 「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 */
    }
}

三个细节解释了前面的用户态语义:

  1. mode 在第一次唤醒后被改成 HRTIMER_MODE_ABS。所以即使相对休眠被信号打断,内核也会用「原截止时间」重算剩余,而不是重新睡一整段。这正是 rem 能给出正确剩余时间的原因。
  2. signal_pending() 检查发生在 hrtimer_cancel() 之后。定时器到期与信号到达同时发生时,内核先取消定时器,再决定返回 0 还是 EINTR;不会出现「信号丢了、定时器也没了」的窗口。
  3. 绝对模式不返回 -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,谁先读谁拿走计数,另一个只能等下一次到期。
判断「时间花在哪」的顺序
  1. strace -c -e trace=clock_gettime,clock_nanosleep,timerfd_settime,read 看有没有慢路径系统调用。
  2. cat /proc/$PID/timerslack_ns 看 slack 是否被调大。
  3. cat /proc/timer_list(需 root)看到期队列里都有谁。
  4. perf stat -e cycles,instructions 对比 vDSO 与 syscall 版本的耗时。
  5. 用 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 循环会一次性补跑所有过期任务——这是「漏读不丢事件」的直接证据。

三个边界:

  1. 堆顶已过期:rearm 把 it_value 设为 1 ns,保证立刻触发,而不是传 0(传 0 是解除定时器)。
  2. epoll_wait 被信号打断:EINTR 后 continue,不重新计算堆——timerfd 的状态还在。
  3. 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
5clock_nanosleep 的错误从不触发按 -1/errno 检查,实际返回错误码用 int rc,rc != 0 时 errno = rc
6timerfd 读取出 EINVALread 的 count < 8用 uint64_t,sizeof 传 8
7timerfd 在 ET 模式下饿死只读一次,ticks 没清零循环读到 EAGAIN,或读出的计数全部消费
8timerfd 把多次到期当一次假设 read 返回 1按计数补跑;监控大于 1 的频率
9timer_gettime 算出的截止时间不对它返回相对剩余,不是绝对时间设置时自己保存绝对截止
10高频定时器把 CPU 打满SIGEV_THREAD 每次到期建线程用 SIGEV_SIGNAL + 专用线程,或 timerfd
11定时器在容器里莫名延后timer slack 被调大 / cgroup CPU 限流查 /proc/$pid/timerslack_ns 与 cpu.max
12fork 后两个进程抢同一个定时器timerfd 的 ctx 被父子共享fork 后关掉不需要的一侧,或每个进程各自 timerfd_create
13子进程继承不到 POSIX 定时器POSIX 定时器不跨 fork 继承在子进程重新 timer_create
14时钟跳变后闹钟提前/延后TFD_TIMER_CANCEL_ON_SET 没设,或用了单调时钟实时闹钟加 CANCEL_ON_SET 并处理 ECANCELED
1532 位程序 2038 年时间错乱time_t 只有 32 位-D_TIME_BITS=64,并确认内核/文件系统支持
三个版本与平台差异
  1. 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。
  2. TFD_TIMER_CANCEL_ON_SET 需要 CLOCK_REALTIME:内核对 CLOCK_MONOTONIC 直接返回 EINVAL;对 CLOCK_BOOTTIME 同样不支持。
  3. 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 与 overrun2.11.5
用 timerfd 写一个 10 次 tick 的程序read 8 字节、处理累计值2.11.6
为 10 万连接设计超时方案说出「堆 + 单 timerfd」的理由2.11.7
即时自测 · 2.11

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 只是开胃菜,真正的同步问题从下一章开始。