44、定时器实现:alarm、setitimer、timerfd,时间轮算法
定时器这个话题,说实话,每个嵌入式开发者都绕不开。我早年做网络协议栈的时候,一个连接超时管理没做好,整个系统就卡死了。后来我花了整整一周重构定时器模块,才真正搞明白这几种方案的差异。
今天咱们就把 Linux 下三种定时器 API 和一种经典算法——时间轮,一次性讲透。你想想看,从最简单的 alarm 到高性能的 timerfd,再到能支撑百万级定时器的时间轮,这中间其实是一条很清晰的进化路线。
一、alarm:最原始的定时器
alarm() 是 POSIX 标准里最古老的定时器接口。它的用法极其简单:
#include <unistd.h>
unsigned int alarm(unsigned int seconds);
调用一次,内核会在指定秒数后向当前进程发送 SIGALRM 信号。嗯,这里要注意——SIGALRM 的默认行为是终止进程。所以你必须先注册信号处理函数。
说白了,alarm 只适合做「超时看门狗」这种简单场景。我在早期做串口通信时用过它来检测接收超时,但后来发现精度只有秒级,根本不够用。
二、setitimer:微秒级精度
当你需要更高精度时,setitimer() 就登场了。它支持微秒级(us)的定时,而且可以设置「一次性」或「周期性」模式。
#include <sys/time.h>
struct itimerval {
struct timeval it_interval; // 周期时间
struct timeval it_value; // 首次触发时间
};
int setitimer(int which, const struct itimerval *new_value,
struct itimerval *old_value);
which 参数有三种选择:
| 参数 | 信号 | 计时方式 |
|---|---|---|
| ITIMER_REAL | SIGALRM | 真实时间(墙上时间) |
| ITIMER_VIRTUAL | SIGVTALRM | 用户态 CPU 时间 |
| ITIMER_PROF | SIGPROF | 用户态 + 内核态 CPU 时间 |
我个人习惯用 ITIMER_REAL,因为它最符合直觉——就是真实世界的时间流逝。但要注意,每个进程每种 which 只能有一个定时器。如果你需要多个定时器,setitimer 就捉襟见肘了。
三、timerfd:文件描述符化的定时器
timerfd 是 Linux 2.6.25 引入的,它把定时器变成了一个文件描述符。这意味着你可以用 select()、poll()、epoll() 来统一管理定时器和 I/O 事件。
#include <sys/timerfd.h>
int timerfd_create(int clockid, int flags);
int timerfd_settime(int fd, int flags,
const struct itimerspec *new_value,
struct itimerspec *old_value);
用法示例:
int tfd = timerfd_create(CLOCK_MONOTONIC, TFD_NONBLOCK);
struct itimerspec spec = {
.it_interval = { .tv_sec = 1, .tv_nsec = 0 }, // 周期1秒
.it_value = { .tv_sec = 1, .tv_nsec = 0 }, // 1秒后首次触发
};
timerfd_settime(tfd, 0, &spec, NULL);
// 然后就可以用 epoll 来监听 tfd 的可读事件
struct epoll_event ev = { .events = EPOLLIN, .data.fd = tfd };
epoll_ctl(epfd, EPOLL_CTL_ADD, tfd, &ev);
为什么我推荐 timerfd?因为它彻底解决了信号处理带来的异步问题。你可以在主事件循环里同步地读取定时器到期次数,完全不用操心重入和竞态。
四、时间轮算法:支撑百万级定时器
前面三种都是内核提供的 API。但当你需要管理大量定时器时——比如一个网络服务器同时维护 10 万个连接,每个连接都有超时定时器——直接用 timerfd 创建 10 万个文件描述符是不现实的。
这时候就需要在用户态实现定时器管理。时间轮(Timing Wheel)就是最经典的方案。
4.1 基本原理
时间轮的思想很简单:把时间分成一个个「槽位」(slot),每个槽位代表一个时间刻度。定时器根据到期时间挂在对应的槽位上。时钟每走一个刻度,就处理当前槽位上的所有定时器。
4.2 简单实现
#define SLOT_NUM 256
#define GRANULARITY_MS 10 // 每个槽位10ms
typedef struct timer_node {
int expire_tick; // 到期时的 tick 数
void (*callback)(void*); // 回调函数
void *arg; // 回调参数
struct timer_node *next;
} timer_node_t;
typedef struct {
timer_node_t *slots[SLOT_NUM];
int current_tick; // 当前 tick 计数
} timing_wheel_t;
void tw_add_timer(timing_wheel_t *tw, int delay_ms,
void (*cb)(void*), void *arg) {
int ticks = delay_ms / GRANULARITY_MS;
int expire = tw->current_tick + ticks;
int slot_idx = expire % SLOT_NUM;
timer_node_t *node = malloc(sizeof(timer_node_t));
node->expire_tick = expire;
node->callback = cb;
node->arg = arg;
node->next = tw->slots[slot_idx];
tw->slots[slot_idx] = node;
}
void tw_tick(timing_wheel_t *tw) {
int idx = tw->current_tick % SLOT_NUM;
timer_node_t *node = tw->slots[idx];
while (node) {
if (node->expire_tick == tw->current_tick) {
node->callback(node->arg);
}
// 处理超时或轮次未到的定时器
timer_node_t *next = node->next;
if (node->expire_tick <= tw->current_tick) {
free(node);
}
node = next;
}
tw->slots[idx] = NULL;
tw->current_tick++;
}
4.3 分层时间轮
当定时器跨度很大时(比如从 10ms 到 1 小时),单层时间轮要么槽位太多浪费内存,要么精度不够。解决方案是分层——像时钟一样,秒针、分针、时针各司其职。
| 层级 | 槽位数 | 粒度 | 最大范围 |
|---|---|---|---|
| 第一层 | 256 | 10ms | 2.56s |
| 第二层 | 256 | 2.56s | ~10.9分钟 |
| 第三层 | 256 | ~10.9分钟 | ~46.5小时 |
定时器插入时,先判断它属于哪一层。到期时,如果低层指针转完一圈,就从高层「借」一批定时器降级到低层。这个机制我在做物联网网关时用过,同时管理 5 万个设备的心跳超时,CPU 占用不到 1%。
五、如何选择?
我整理了一个简单的决策思路:
- alarm:只适合「一个定时器 + 秒级精度」的看门狗场景。代码量最少,但功能也最弱。
- setitimer:需要微秒级精度,且定时器数量很少(1-3个)时可用。但信号处理带来的异步问题很头疼。
- timerfd:现代 Linux 应用的首选。配合 epoll 可以管理几百个定时器,代码清晰,没有信号问题。
- 时间轮:当定时器数量达到千级、万级时,必须在用户态实现。时间轮是经过工业验证的成熟方案。
定时器看似简单,但选错了方案,后期维护成本会成倍增加。从 alarm 到时间轮,每种方案都有它的生态位。理解它们的本质差异,你才能在项目中做出正确的技术决策。
公众号:蓝海资料掘金营,微信deep3321