Linux高性能定时器:timerfd与epoll事件循环实战
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_CLOEXECexec时自动关闭 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_sec1, tv_nsec1500000000那会返回EINVAL。正确写法是tv_sec1, tv_nsec500000000。写个辅助宏会省事很多#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_ms1000表示 1 秒后首次触发interval_ms500表示之后每 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 上限是 1024ulimit -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超时也能凑合。想清楚这一点选型就不会纠结了。