1. 为什么需要把 timerfd 和 epoll 放在一起用做 Linux 后台服务开发的人,迟早会碰到一个绕不开的问题:一个进程要同时处理网络 IO、信号、定时任务,怎么把它们统一管起来。早期大家用alarm()或者setitimer()配信号处理,写起来简单,但坑特别多——信号处理函数里能做的事情有限,多线程环境下信号投递到哪个线程完全不可控,定时精度也一般。后来有人用select()的超时参数做定时,但那个超时精度只有微秒级且每次都要重新设置,混在 IO 逻辑里非常别扭。timerfd是 Linux 2.6.25 之后引入的一套机制,它把定时器变成了一个文件描述符。这个转变非常关键:一旦定时器变成了 fd,它就能和 socket、pipe、eventfd 一样被epoll统一监听。整个事件循环里只需要一个epoll_wait(),网络事件和定时事件从同一个出口出来,代码结构一下子清爽了。这也是为什么现在很多高性能服务框架,底层事件循环都是"epoll + timerfd + eventfd"这套组合。这篇文章面向的是已经会写基本 socket 程序、想把自己的事件循环做得更规范的开发者。我会从设计思路讲到完整可运行的代码,再把实际调试中踩过的坑一个个摊开说。看完你应该能直接把这套东西搬进自己的项目里,而不是停留在"知道有这么个东西"的阶段。需要提前说明的是,timerfd是 Linux 特有的,BSD 和 Windows 上没有对应实现,跨平台项目要自己做一层抽象。另外它依赖epoll或者poll来发挥价值,单独用虽然也能read()阻塞等待,但那就失去意义了。2. 整体设计思路与方案选型2.1 定时器方案横向对比在动手写代码之前,先把几种常见定时方案摆在一起比一比,这样你能清楚知道为什么最后选timerfd。方案精度与 epoll 集成多线程友好主要缺点alarm()/setitimer()微秒级差,靠信号差信号处理受限,易丢失select()超时微秒级一般一般每次重设,fd 数量受限epoll_wait()超时毫秒级原生好只能有一个超时,粒度粗时间轮(用户态)毫秒级需自己驱动好实现复杂,需配合 ticktimerfd纳秒级原生好仅 Linux从表里能看出来,timerfd在精度和集成度上是综合最优的。epoll_wait()自带的超时参数虽然也能做定时,但它本质上只有一个"最近到期时间",如果你有十个不同周期的任务,就得自己维护最小堆,每次算最近的那个,代码量并不比直接用timerfd少,而且精度只有毫秒。2.2 核心设计:一个 fd 就是一个定时器timerfd的设计哲学很朴素:创建一个定时器,返回一个 fd;定时器到期时,这个 fd 变成可读;你read()它,读到一个 8 字节的uint64_t,表示从上一次读取到现在一共到期了多少次。这个"到期次数"的设计很巧妙。假设你的定时器周期是 10ms,但某次事件循环被一个耗时操作卡了 55ms,那么下次read()会返回 5,告诉你"这期间到期了 5 次"。你可以选择把这 5 次合并处理(大多数场景够用),也可以循环处理 5 次(需要精确计数的场景)。这个信息如果用信号或者select超时是拿不到的,它们只会告诉你"超时了",丢了多少次完全不知道。2.3 为什么用 epoll 而不是 pollpoll也能监听timerfd,功能上没问题。但epoll有两个实际优势:一是 fd 数量大时性能不退化,poll是 O(n) 遍历;二是epoll支持边缘触发(ET)模式,配合非阻塞 fd 能减少不必要的系统调用。对于一个要长期运行、可能管理成百上千个连接的服务来说,epoll是更稳妥的选择。下面所有代码都基于epoll的**水平触发(LT)**模式,因为 LT 模式下timerfd的处理逻辑更直观,不容易漏读。提示:如果你打算用 ET 模式,timerfd的 fd 必须设成非阻塞,并且read()要循环读到EAGAIN为止,否则会丢事件。新手建议先用 LT 把逻辑跑通。3. 核心 API 细节与参数解析3.1 timerfd_create:创建定时器 fd#include <sys/timerfd.h> int timerfd_create(int clockid, int flags);clockid有两个常用取值:CLOCK_REALTIME:跟随系统实时时间,受settimeofday()和 NTP 调整影响。适合"每天凌晨 3 点执行"这类挂钟时间相关的任务。CLOCK_MONOTONIC:单调递增,不受系统时间调整影响。适合"每隔 100ms 执行一次"这类间隔型任务。绝大多数场景应该用CLOCK_MONOTONIC。我见过有服务用CLOCK_REALTIME做心跳定时,结果运维手动校准了一次系统时间,所有定时器集体错乱,心跳包瞬间发了一堆。这个坑不值得踩。flags常用两个:0:默认阻塞。TFD_NONBLOCK:非阻塞。配合epoll的 ET 模式时必须加。TFD_CLOEXEC:exec时自动关闭 fd。写库或者长期服务建议加上,避免 fd 泄漏到子进程。返回值是一个新的 fd,失败返回 -1 并设置errno。3.2 timerfd_settime:设置定时参数struct itimerspec { struct timespec it_interval; // 周期间隔 struct timespec it_value; // 首次到期时间 }; int timerfd_settime(int fd, int flags, const struct itimerspec *new_value, struct itimerspec *old_value);这里有两个字段要分清楚,新手最容易搞混:it_value:第一次到期还有多久。设成 0 表示 disarm(停止定时器)。it_interval:第一次到期之后,每隔多久重复一次。设成 0 表示只触发一次(one-shot)。所以一个"1 秒后首次触发,之后每 500ms 触发一次"的定时器是这样配的:struct itimerspec ts; ts.it_value.tv_sec = 1; ts.it_value.tv_nsec = 0; ts.it_interval.tv_sec = 0; ts.it_interval.tv_nsec = 500 * 1000 * 1000; // 500ms timerfd_settime(tfd, 0, &ts, NULL);flags参数常用0(相对时间)或TFD_TIMER_ABSTIME(绝对时间)。用绝对时间时,it_value表示的是一个绝对的时间点,配合CLOCK_MONOTONIC可以用来做"对齐到整秒"这类需求。3.3 read:读取到期次数uint64_t expirations; ssize_t n = read(tfd, &expirations, sizeof(expirations));必须读满 8 字节。如果缓冲区小于 8 字节,read()会返回EINVAL。这个错误我第一次遇到时排查了半天,因为文档里没特别强调。读到之后expirations就是自上次读取以来的到期次数。如果定时器还没到期,阻塞模式下read()会挂起,非阻塞模式下返回 -1 且errno为EAGAIN。3.4 参数计算:纳秒字段的坑timespec的tv_nsec字段合法范围是[0, 999999999]。如果你想设 1.5 秒,不能写成tv_sec=1, tv_nsec=1500000000,那会返回EINVAL。正确写法是tv_sec=1, tv_nsec=500000000。写个辅助宏会省事很多:#define MS_TO_TIMESPEC(ms, ts) do { \ (ts)->tv_sec = (ms) / 1000; \ (ts)->tv_nsec = ((ms) % 1000) * 1000000L; \ } while (0)4. 完整实操:从零搭一个定时事件循环4.1 环境准备与编译这套代码只需要 Linux 内核 2.6.25 以上,glibc 正常安装即可。编译命令:gcc -o timerfd_demo timerfd_demo.c -Wall -O2不需要链接额外的库,timerfd的接口都在 glibc 里。如果你用的是 musl libc(比如 Alpine),同样支持,头文件路径一致。4.2 完整可运行代码下面这份代码实现了一个事件循环,同时监听一个timerfd(每 500ms 触发)和一个标准输入(用来演示多 fd 混合监听)。你可以直接复制编译运行。#include <stdio.h> #include <stdlib.h> #include <string.h> #include <unistd.h> #include <errno.h> #include <stdint.h> #include <sys/epoll.h> #include <sys/timerfd.h> #define MAX_EVENTS 8 static int make_timerfd(long interval_ms, long initial_ms) { int tfd = timerfd_create(CLOCK_MONOTONIC, TFD_NONBLOCK | TFD_CLOEXEC); if (tfd < 0) { perror("timerfd_create"); return -1; } struct itimerspec ts; memset(&ts, 0, sizeof(ts)); ts.it_value.tv_sec = initial_ms / 1000; ts.it_value.tv_nsec = (initial_ms % 1000) * 1000000L; ts.it_interval.tv_sec = interval_ms / 1000; ts.it_interval.tv_nsec = (interval_ms % 1000) * 1000000L; if (timerfd_settime(tfd, 0, &ts, NULL) < 0) { perror("timerfd_settime"); close(tfd); return -1; } return tfd; } static void handle_timer(int tfd) { uint64_t expirations; ssize_t n = read(tfd, &expirations, sizeof(expirations)); if (n != sizeof(expirations)) { if (errno != EAGAIN) perror("read timerfd"); return; } printf("[timer] fired %llu time(s)\n", (unsigned long long)expirations); } int main(void) { int tfd = make_timerfd(500, 1000); if (tfd < 0) return 1; int epfd = epoll_create1(EPOLL_CLOEXEC); if (epfd < 0) { perror("epoll_create1"); return 1; } struct epoll_event ev; ev.events = EPOLLIN; ev.data.fd = tfd; if (epoll_ctl(epfd, EPOLL_CTL_ADD, tfd, &ev) < 0) { perror("epoll_ctl timerfd"); return 1; } ev.events = EPOLLIN; ev.data.fd = STDIN_FILENO; if (epoll_ctl(epfd, EPOLL_CTL_ADD, STDIN_FILENO, &ev) < 0) { perror("epoll_ctl stdin"); return 1; } struct epoll_event events[MAX_EVENTS]; printf("event loop started, type something and press enter\n"); for (;;) { int n = epoll_wait(epfd, events, MAX_EVENTS, -1); if (n < 0) { if (errno == EINTR) continue; perror("epoll_wait"); break; } for (int i = 0; i < n; i++) { int fd = events[i].data.fd; if (fd == tfd) { handle_timer(tfd); } else if (fd == STDIN_FILENO) { char buf[256]; ssize_t r = read(STDIN_FILENO, buf, sizeof(buf) - 1); if (r <= 0) { printf("stdin closed, exit\n"); goto done; } buf[r] = '\0'; printf("[stdin] %s", buf); } } } done: close(tfd); close(epfd); return 0; }4.3 关键步骤拆解第一步,创建 timerfd。用CLOCK_MONOTONIC保证不受系统时间调整影响,加TFD_NONBLOCK是为了后面万一切 ET 模式不用改,加TFD_CLOEXEC是防御性编程。第二步,设置定时参数。这里initial_ms=1000表示 1 秒后首次触发,interval_ms=500表示之后每 500ms 一次。注意it_value和it_interval都要填,只填一个的话行为不符合预期——只填it_value就是 one-shot,只填it_interval则首次触发时间不确定。第三步,注册到 epoll。ev.data.fd存 fd 本身,回调时用它区分事件来源。生产代码里更常见的做法是ev.data.ptr指向一个结构体,里面带 fd、类型、回调函数指针,这样扩展性更好。第四步,事件循环。epoll_wait的 timeout 传 -1 表示无限等待,因为定时逻辑已经交给timerfd了,不需要靠超时参数兜底。EINTR要单独处理,信号打断时继续循环,不要当成错误退出。第五步,读取到期次数。这一步必须做,否则 LT 模式下epoll_wait会一直返回这个 fd 可读,陷入忙循环。这是新手最常犯的错误之一。4.4 运行效果与验证编译运行后,你会看到每秒开始每 500ms 打印一次[timer] fired 1 time(s)。如果你在终端输入一行文字回车,会看到[stdin]的输出,说明两类事件在同一个循环里被正确处理。想验证"合并到期"的行为,可以在handle_timer里加一句sleep(2),然后观察输出。你会发现某次打印的到期次数变成了 4 左右,这就是前面说的"卡住期间累积的到期次数"。这个特性在做限流、统计类任务时非常有用。5. 常见问题与排查技巧实录5.1 高频问题速查表现象可能原因排查方向epoll_wait一直返回 timerfd 可读没有read()或读的字节数不对检查是否读满 8 字节timerfd_settime返回 EINVALtv_nsec超出范围检查纳秒字段是否 < 1e9定时器完全不触发it_value设成了 00 表示 disarm,要设非零首次触发时间不对混淆了it_value和it_interval首次看it_value,周期看it_interval多线程下事件丢失多个线程同时epoll_wait同一个 epfd用 EPOLLONESHOT 或单线程收事件系统时间调整后定时错乱用了CLOCK_REALTIME换成CLOCK_MONOTONICfd 泄漏到子进程没加TFD_CLOEXEC创建时补上该 flag5.2 几个文档里不会写的坑坑一:read()缓冲区大小。我一开始用int接收到期次数,结果read()返回EINVAL。原因是内核要求缓冲区至少 8 字节,int只有 4 字节。必须用uint64_t。这个错误信息很隐晦,perror只打印 "Invalid argument",不看源码根本猜不到。坑二:LT 模式下忘记 read 导致 CPU 打满。有一次线上服务 CPU 突然飙到 100%,查了半天发现是某个定时器回调里抛异常提前返回了,没走到read()。LT 模式下 fd 一直可读,epoll_wait立刻返回,形成忙循环。解决办法是在回调最外层用 RAII 或者goto保证read()一定被执行,或者干脆切 ET 模式。坑三:timerfd_settime的old_value参数。这个参数可以传 NULL,但如果你想实现"暂停后恢复"的功能,就需要先读出来旧值。注意读出来的it_value是剩余时间,不是原始设置值,恢复时要重新计算。坑四:精度不是无限的。虽然接口是纳秒级,但实际精度受内核HZ配置和调度延迟影响。普通服务器上做到 1ms 精度没问题,想做到 10 微秒级就得考虑实时内核或者忙等待方案了。别被"纳秒"两个字误导。坑五:fork 之后的行为。timerfd在fork后子进程会继承,但父子进程共享同一个定时器状态。如果子进程也去read(),会跟父进程抢到期事件。正确做法是 fork 后子进程立即close()掉不需要的 timerfd,或者干脆在 fork 前就规划好 fd 归属。5.3 一个实用的封装思路实际项目里不会只有一个定时器。我的做法是封装一个Timer类,内部持有timerfd,注册到全局epoll,回调时调用用户传入的std::function。多个定时器就是多个timerfd,各自独立,互不干扰。相比时间轮方案,这种"一定时器一 fd"的做法在定时器数量不多(几百个以内)时完全够用,而且逻辑简单不容易出错。数量上千之后再考虑时间轮优化。注意:每个timerfd都占用一个 fd,系统默认 fd 上限是 1024(ulimit -n可查)。如果你的服务要开几千个定时器,要么调大上限,要么改用时间轮。这是选型时必须提前算清楚的账。6. 性能特征与适用边界6.1 开销到底有多大timerfd的创建和设置都是系统调用,单次开销在微秒级。事件触发时,内核把 fd 标记为可读并唤醒epoll_wait,这个路径和普通 IO 事件完全一样,没有额外负担。实测下来,单进程管理几百个timerfd,每秒触发几千次,CPU 占用可以忽略不计。真正需要注意的是回调执行时间。如果定时器回调里做了耗时操作,会阻塞整个事件循环,导致其他定时器和 IO 事件延迟。所以回调里只做轻量逻辑,重活丢到线程池或者用eventfd唤醒工作线程。6.2 什么场景不适合用超高频定时(微秒级):系统调用开销占比过高,考虑忙等待或者专门的实时方案。海量定时器(上万级):fd 数量成为瓶颈,时间轮更合适。跨平台需求:非 Linux 平台没有timerfd,需要抽象层或者换方案。需要挂钟时间语义:比如"每天固定时刻执行",用CLOCK_REALTIME配合TFD_TIMER_ABSTIME可以,但要处理系统时间跳变,逻辑比想象中复杂。6.3 和其他机制的配合timerfd经常和eventfd、signalfd一起出现,构成完整的事件循环基础设施。eventfd用来做线程间唤醒,signalfd把信号也变成 fd,三者加上 socket,全部由同一个epoll管理。这套组合是很多高性能服务的标准骨架,理解了timerfd,另外两个也就触类旁通了。我在实际项目里踩过几次坑之后,现在的习惯是:任何需要定时的逻辑,先问自己"这个定时器会不会被卡住、卡住后要不要补触发"。如果答案是"要补",那timerfd的到期计数就是刚需;如果"无所谓",那用epoll_wait超时也能凑合。想清楚这一点,选型就不会纠结了。
1. 为什么需要把 timerfd 和 epoll 放在一起用做 Linux 后台服务开发的人,迟早会碰到一个绕不开的问题:一个进程要同时处理网络 IO、信号、定时任务,怎么把它们统一管起来。早期大家用alarm()或者setitimer()配信号处理,写起来简单,但坑特别多——信号处理函数里能做的事情有限,多线程环境下信号投递到哪个线程完全不可控,定时精度也一般。后来有人用select()的超时参数做定时,但那个超时精度只有微秒级且每次都要重新设置,混在 IO 逻辑里非常别扭。timerfd是 Linux 2.6.25 之后引入的一套机制,它把定时器变成了一个文件描述符。这个转变非常关键:一旦定时器变成了 fd,它就能和 socket、pipe、eventfd 一样被epoll统一监听。整个事件循环里只需要一个epoll_wait(),网络事件和定时事件从同一个出口出来,代码结构一下子清爽了。这也是为什么现在很多高性能服务框架,底层事件循环都是"epoll + timerfd + eventfd"这套组合。这篇文章面向的是已经会写基本 socket 程序、想把自己的事件循环做得更规范的开发者。我会从设计思路讲到完整可运行的代码,再把实际调试中踩过的坑一个个摊开说。看完你应该能直接把这套东西搬进自己的项目里,而不是停留在"知道有这么个东西"的阶段。需要提前说明的是,timerfd是 Linux 特有的,BSD 和 Windows 上没有对应实现,跨平台项目要自己做一层抽象。另外它依赖epoll或者poll来发挥价值,单独用虽然也能read()阻塞等待,但那就失去意义了。2. 整体设计思路与方案选型2.1 定时器方案横向对比在动手写代码之前,先把几种常见定时方案摆在一起比一比,这样你能清楚知道为什么最后选timerfd。方案精度与 epoll 集成多线程友好主要缺点alarm()/setitimer()微秒级差,靠信号差信号处理受限,易丢失select()超时微秒级一般一般每次重设,fd 数量受限epoll_wait()超时毫秒级原生好只能有一个超时,粒度粗时间轮(用户态)毫秒级需自己驱动好实现复杂,需配合 ticktimerfd纳秒级原生好仅 Linux从表里能看出来,timerfd在精度和集成度上是综合最优的。epoll_wait()自带的超时参数虽然也能做定时,但它本质上只有一个"最近到期时间",如果你有十个不同周期的任务,就得自己维护最小堆,每次算最近的那个,代码量并不比直接用timerfd少,而且精度只有毫秒。2.2 核心设计:一个 fd 就是一个定时器timerfd的设计哲学很朴素:创建一个定时器,返回一个 fd;定时器到期时,这个 fd 变成可读;你read()它,读到一个 8 字节的uint64_t,表示从上一次读取到现在一共到期了多少次。这个"到期次数"的设计很巧妙。假设你的定时器周期是 10ms,但某次事件循环被一个耗时操作卡了 55ms,那么下次read()会返回 5,告诉你"这期间到期了 5 次"。你可以选择把这 5 次合并处理(大多数场景够用),也可以循环处理 5 次(需要精确计数的场景)。这个信息如果用信号或者select超时是拿不到的,它们只会告诉你"超时了",丢了多少次完全不知道。2.3 为什么用 epoll 而不是 pollpoll也能监听timerfd,功能上没问题。但epoll有两个实际优势:一是 fd 数量大时性能不退化,poll是 O(n) 遍历;二是epoll支持边缘触发(ET)模式,配合非阻塞 fd 能减少不必要的系统调用。对于一个要长期运行、可能管理成百上千个连接的服务来说,epoll是更稳妥的选择。下面所有代码都基于epoll的**水平触发(LT)**模式,因为 LT 模式下timerfd的处理逻辑更直观,不容易漏读。提示:如果你打算用 ET 模式,timerfd的 fd 必须设成非阻塞,并且read()要循环读到EAGAIN为止,否则会丢事件。新手建议先用 LT 把逻辑跑通。3. 核心 API 细节与参数解析3.1 timerfd_create:创建定时器 fd#include <sys/timerfd.h> int timerfd_create(int clockid, int flags);clockid有两个常用取值:CLOCK_REALTIME:跟随系统实时时间,受settimeofday()和 NTP 调整影响。适合"每天凌晨 3 点执行"这类挂钟时间相关的任务。CLOCK_MONOTONIC:单调递增,不受系统时间调整影响。适合"每隔 100ms 执行一次"这类间隔型任务。绝大多数场景应该用CLOCK_MONOTONIC。我见过有服务用CLOCK_REALTIME做心跳定时,结果运维手动校准了一次系统时间,所有定时器集体错乱,心跳包瞬间发了一堆。这个坑不值得踩。flags常用两个:0:默认阻塞。TFD_NONBLOCK:非阻塞。配合epoll的 ET 模式时必须加。TFD_CLOEXEC:exec时自动关闭 fd。写库或者长期服务建议加上,避免 fd 泄漏到子进程。返回值是一个新的 fd,失败返回 -1 并设置errno。3.2 timerfd_settime:设置定时参数struct itimerspec { struct timespec it_interval; // 周期间隔 struct timespec it_value; // 首次到期时间 }; int timerfd_settime(int fd, int flags, const struct itimerspec *new_value, struct itimerspec *old_value);这里有两个字段要分清楚,新手最容易搞混:it_value:第一次到期还有多久。设成 0 表示 disarm(停止定时器)。it_interval:第一次到期之后,每隔多久重复一次。设成 0 表示只触发一次(one-shot)。所以一个"1 秒后首次触发,之后每 500ms 触发一次"的定时器是这样配的:struct itimerspec ts; ts.it_value.tv_sec = 1; ts.it_value.tv_nsec = 0; ts.it_interval.tv_sec = 0; ts.it_interval.tv_nsec = 500 * 1000 * 1000; // 500ms timerfd_settime(tfd, 0, &ts, NULL);flags参数常用0(相对时间)或TFD_TIMER_ABSTIME(绝对时间)。用绝对时间时,it_value表示的是一个绝对的时间点,配合CLOCK_MONOTONIC可以用来做"对齐到整秒"这类需求。3.3 read:读取到期次数uint64_t expirations; ssize_t n = read(tfd, &expirations, sizeof(expirations));必须读满 8 字节。如果缓冲区小于 8 字节,read()会返回EINVAL。这个错误我第一次遇到时排查了半天,因为文档里没特别强调。读到之后expirations就是自上次读取以来的到期次数。如果定时器还没到期,阻塞模式下read()会挂起,非阻塞模式下返回 -1 且errno为EAGAIN。3.4 参数计算:纳秒字段的坑timespec的tv_nsec字段合法范围是[0, 999999999]。如果你想设 1.5 秒,不能写成tv_sec=1, tv_nsec=1500000000,那会返回EINVAL。正确写法是tv_sec=1, tv_nsec=500000000。写个辅助宏会省事很多:#define MS_TO_TIMESPEC(ms, ts) do { \ (ts)->tv_sec = (ms) / 1000; \ (ts)->tv_nsec = ((ms) % 1000) * 1000000L; \ } while (0)4. 完整实操:从零搭一个定时事件循环4.1 环境准备与编译这套代码只需要 Linux 内核 2.6.25 以上,glibc 正常安装即可。编译命令:gcc -o timerfd_demo timerfd_demo.c -Wall -O2不需要链接额外的库,timerfd的接口都在 glibc 里。如果你用的是 musl libc(比如 Alpine),同样支持,头文件路径一致。4.2 完整可运行代码下面这份代码实现了一个事件循环,同时监听一个timerfd(每 500ms 触发)和一个标准输入(用来演示多 fd 混合监听)。你可以直接复制编译运行。#include <stdio.h> #include <stdlib.h> #include <string.h> #include <unistd.h> #include <errno.h> #include <stdint.h> #include <sys/epoll.h> #include <sys/timerfd.h> #define MAX_EVENTS 8 static int make_timerfd(long interval_ms, long initial_ms) { int tfd = timerfd_create(CLOCK_MONOTONIC, TFD_NONBLOCK | TFD_CLOEXEC); if (tfd < 0) { perror("timerfd_create"); return -1; } struct itimerspec ts; memset(&ts, 0, sizeof(ts)); ts.it_value.tv_sec = initial_ms / 1000; ts.it_value.tv_nsec = (initial_ms % 1000) * 1000000L; ts.it_interval.tv_sec = interval_ms / 1000; ts.it_interval.tv_nsec = (interval_ms % 1000) * 1000000L; if (timerfd_settime(tfd, 0, &ts, NULL) < 0) { perror("timerfd_settime"); close(tfd); return -1; } return tfd; } static void handle_timer(int tfd) { uint64_t expirations; ssize_t n = read(tfd, &expirations, sizeof(expirations)); if (n != sizeof(expirations)) { if (errno != EAGAIN) perror("read timerfd"); return; } printf("[timer] fired %llu time(s)\n", (unsigned long long)expirations); } int main(void) { int tfd = make_timerfd(500, 1000); if (tfd < 0) return 1; int epfd = epoll_create1(EPOLL_CLOEXEC); if (epfd < 0) { perror("epoll_create1"); return 1; } struct epoll_event ev; ev.events = EPOLLIN; ev.data.fd = tfd; if (epoll_ctl(epfd, EPOLL_CTL_ADD, tfd, &ev) < 0) { perror("epoll_ctl timerfd"); return 1; } ev.events = EPOLLIN; ev.data.fd = STDIN_FILENO; if (epoll_ctl(epfd, EPOLL_CTL_ADD, STDIN_FILENO, &ev) < 0) { perror("epoll_ctl stdin"); return 1; } struct epoll_event events[MAX_EVENTS]; printf("event loop started, type something and press enter\n"); for (;;) { int n = epoll_wait(epfd, events, MAX_EVENTS, -1); if (n < 0) { if (errno == EINTR) continue; perror("epoll_wait"); break; } for (int i = 0; i < n; i++) { int fd = events[i].data.fd; if (fd == tfd) { handle_timer(tfd); } else if (fd == STDIN_FILENO) { char buf[256]; ssize_t r = read(STDIN_FILENO, buf, sizeof(buf) - 1); if (r <= 0) { printf("stdin closed, exit\n"); goto done; } buf[r] = '\0'; printf("[stdin] %s", buf); } } } done: close(tfd); close(epfd); return 0; }4.3 关键步骤拆解第一步,创建 timerfd。用CLOCK_MONOTONIC保证不受系统时间调整影响,加TFD_NONBLOCK是为了后面万一切 ET 模式不用改,加TFD_CLOEXEC是防御性编程。第二步,设置定时参数。这里initial_ms=1000表示 1 秒后首次触发,interval_ms=500表示之后每 500ms 一次。注意it_value和it_interval都要填,只填一个的话行为不符合预期——只填it_value就是 one-shot,只填it_interval则首次触发时间不确定。第三步,注册到 epoll。ev.data.fd存 fd 本身,回调时用它区分事件来源。生产代码里更常见的做法是ev.data.ptr指向一个结构体,里面带 fd、类型、回调函数指针,这样扩展性更好。第四步,事件循环。epoll_wait的 timeout 传 -1 表示无限等待,因为定时逻辑已经交给timerfd了,不需要靠超时参数兜底。EINTR要单独处理,信号打断时继续循环,不要当成错误退出。第五步,读取到期次数。这一步必须做,否则 LT 模式下epoll_wait会一直返回这个 fd 可读,陷入忙循环。这是新手最常犯的错误之一。4.4 运行效果与验证编译运行后,你会看到每秒开始每 500ms 打印一次[timer] fired 1 time(s)。如果你在终端输入一行文字回车,会看到[stdin]的输出,说明两类事件在同一个循环里被正确处理。想验证"合并到期"的行为,可以在handle_timer里加一句sleep(2),然后观察输出。你会发现某次打印的到期次数变成了 4 左右,这就是前面说的"卡住期间累积的到期次数"。这个特性在做限流、统计类任务时非常有用。5. 常见问题与排查技巧实录5.1 高频问题速查表现象可能原因排查方向epoll_wait一直返回 timerfd 可读没有read()或读的字节数不对检查是否读满 8 字节timerfd_settime返回 EINVALtv_nsec超出范围检查纳秒字段是否 < 1e9定时器完全不触发it_value设成了 00 表示 disarm,要设非零首次触发时间不对混淆了it_value和it_interval首次看it_value,周期看it_interval多线程下事件丢失多个线程同时epoll_wait同一个 epfd用 EPOLLONESHOT 或单线程收事件系统时间调整后定时错乱用了CLOCK_REALTIME换成CLOCK_MONOTONICfd 泄漏到子进程没加TFD_CLOEXEC创建时补上该 flag5.2 几个文档里不会写的坑坑一:read()缓冲区大小。我一开始用int接收到期次数,结果read()返回EINVAL。原因是内核要求缓冲区至少 8 字节,int只有 4 字节。必须用uint64_t。这个错误信息很隐晦,perror只打印 "Invalid argument",不看源码根本猜不到。坑二:LT 模式下忘记 read 导致 CPU 打满。有一次线上服务 CPU 突然飙到 100%,查了半天发现是某个定时器回调里抛异常提前返回了,没走到read()。LT 模式下 fd 一直可读,epoll_wait立刻返回,形成忙循环。解决办法是在回调最外层用 RAII 或者goto保证read()一定被执行,或者干脆切 ET 模式。坑三:timerfd_settime的old_value参数。这个参数可以传 NULL,但如果你想实现"暂停后恢复"的功能,就需要先读出来旧值。注意读出来的it_value是剩余时间,不是原始设置值,恢复时要重新计算。坑四:精度不是无限的。虽然接口是纳秒级,但实际精度受内核HZ配置和调度延迟影响。普通服务器上做到 1ms 精度没问题,想做到 10 微秒级就得考虑实时内核或者忙等待方案了。别被"纳秒"两个字误导。坑五:fork 之后的行为。timerfd在fork后子进程会继承,但父子进程共享同一个定时器状态。如果子进程也去read(),会跟父进程抢到期事件。正确做法是 fork 后子进程立即close()掉不需要的 timerfd,或者干脆在 fork 前就规划好 fd 归属。5.3 一个实用的封装思路实际项目里不会只有一个定时器。我的做法是封装一个Timer类,内部持有timerfd,注册到全局epoll,回调时调用用户传入的std::function。多个定时器就是多个timerfd,各自独立,互不干扰。相比时间轮方案,这种"一定时器一 fd"的做法在定时器数量不多(几百个以内)时完全够用,而且逻辑简单不容易出错。数量上千之后再考虑时间轮优化。注意:每个timerfd都占用一个 fd,系统默认 fd 上限是 1024(ulimit -n可查)。如果你的服务要开几千个定时器,要么调大上限,要么改用时间轮。这是选型时必须提前算清楚的账。6. 性能特征与适用边界6.1 开销到底有多大timerfd的创建和设置都是系统调用,单次开销在微秒级。事件触发时,内核把 fd 标记为可读并唤醒epoll_wait,这个路径和普通 IO 事件完全一样,没有额外负担。实测下来,单进程管理几百个timerfd,每秒触发几千次,CPU 占用可以忽略不计。真正需要注意的是回调执行时间。如果定时器回调里做了耗时操作,会阻塞整个事件循环,导致其他定时器和 IO 事件延迟。所以回调里只做轻量逻辑,重活丢到线程池或者用eventfd唤醒工作线程。6.2 什么场景不适合用超高频定时(微秒级):系统调用开销占比过高,考虑忙等待或者专门的实时方案。海量定时器(上万级):fd 数量成为瓶颈,时间轮更合适。跨平台需求:非 Linux 平台没有timerfd,需要抽象层或者换方案。需要挂钟时间语义:比如"每天固定时刻执行",用CLOCK_REALTIME配合TFD_TIMER_ABSTIME可以,但要处理系统时间跳变,逻辑比想象中复杂。6.3 和其他机制的配合timerfd经常和eventfd、signalfd一起出现,构成完整的事件循环基础设施。eventfd用来做线程间唤醒,signalfd把信号也变成 fd,三者加上 socket,全部由同一个epoll管理。这套组合是很多高性能服务的标准骨架,理解了timerfd,另外两个也就触类旁通了。我在实际项目里踩过几次坑之后,现在的习惯是:任何需要定时的逻辑,先问自己"这个定时器会不会被卡住、卡住后要不要补触发"。如果答案是"要补",那timerfd的到期计数就是刚需;如果"无所谓",那用epoll_wait超时也能凑合。想清楚这一点,选型就不会纠结了。