44、定时器实现:alarm、setitimer、timerfd,时间轮算法

定时器这个话题,说实话,每个嵌入式开发者都绕不开。我早年做网络协议栈的时候,一个连接超时管理没做好,整个系统就卡死了。后来我花了整整一周重构定时器模块,才真正搞明白这几种方案的差异。

今天咱们就把 Linux 下三种定时器 API 和一种经典算法——时间轮,一次性讲透。你想想看,从最简单的 alarm 到高性能的 timerfd,再到能支撑百万级定时器的时间轮,这中间其实是一条很清晰的进化路线。

一、alarm:最原始的定时器

alarm() 是 POSIX 标准里最古老的定时器接口。它的用法极其简单:

#include <unistd.h>
unsigned int alarm(unsigned int seconds);

调用一次,内核会在指定秒数后向当前进程发送 SIGALRM 信号。嗯,这里要注意——SIGALRM 的默认行为是终止进程。所以你必须先注册信号处理函数。

⚠ 我曾经踩过的坑:alarm 是「一次性」的。如果你需要周期性定时器,必须在信号处理函数里再次调用 alarm。而且多个 alarm 调用会互相覆盖,后一个会取消前一个。

说白了,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 就捉襟见肘了。

💡 小技巧:setitimer 的信号处理函数里不要做复杂操作。信号处理是异步的,你正在操作的数据结构可能处于不一致状态。我一般只在信号处理函数里设置一个 volatile 标志位,然后在主循环里检查。

三、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?因为它彻底解决了信号处理带来的异步问题。你可以在主事件循环里同步地读取定时器到期次数,完全不用操心重入和竞态。

核心优势:timerfd 把定时器纳入了「一切皆文件」的 Unix 哲学。配合 epoll,你可以轻松管理成百上千个定时器,而且代码结构非常清晰。

四、时间轮算法:支撑百万级定时器

前面三种都是内核提供的 API。但当你需要管理大量定时器时——比如一个网络服务器同时维护 10 万个连接,每个连接都有超时定时器——直接用 timerfd 创建 10 万个文件描述符是不现实的。

这时候就需要在用户态实现定时器管理。时间轮(Timing Wheel)就是最经典的方案。

4.1 基本原理

时间轮的思想很简单:把时间分成一个个「槽位」(slot),每个槽位代表一个时间刻度。定时器根据到期时间挂在对应的槽位上。时钟每走一个刻度,就处理当前槽位上的所有定时器。

时间轮算法结构 槽0 槽1 槽2 槽3 槽4 槽5 槽6 槽7 槽8 槽9 当前指针 → 槽0 定时器A 定时器B 定时器C 定时器D 每个槽位挂载一个定时器链表,指针每 Tick 移动一个槽位

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++;
}
关键点:这个实现只支持「单轮」定时器,即定时器到期时间不能超过 SLOT_NUM * GRANULARITY_MS。对于更长的定时器,需要引入「轮次」概念——每个节点记录还需要转几圈才到期。

4.3 分层时间轮

当定时器跨度很大时(比如从 10ms 到 1 小时),单层时间轮要么槽位太多浪费内存,要么精度不够。解决方案是分层——像时钟一样,秒针、分针、时针各司其职。

层级 槽位数 粒度 最大范围
第一层 256 10ms 2.56s
第二层 256 2.56s ~10.9分钟
第三层 256 ~10.9分钟 ~46.5小时

定时器插入时,先判断它属于哪一层。到期时,如果低层指针转完一圈,就从高层「借」一批定时器降级到低层。这个机制我在做物联网网关时用过,同时管理 5 万个设备的心跳超时,CPU 占用不到 1%。

💡 我的经验:时间轮的精髓在于「延迟处理」。你不必在定时器插入时做精确排序,只需要把它丢到对应的槽位。真正的工作在 tick 到来时才做。这种「懒处理」思想在嵌入式系统里非常实用。

五、如何选择?

我整理了一个简单的决策思路:

  • alarm:只适合「一个定时器 + 秒级精度」的看门狗场景。代码量最少,但功能也最弱。
  • setitimer:需要微秒级精度,且定时器数量很少(1-3个)时可用。但信号处理带来的异步问题很头疼。
  • timerfd:现代 Linux 应用的首选。配合 epoll 可以管理几百个定时器,代码清晰,没有信号问题。
  • 时间轮:当定时器数量达到千级、万级时,必须在用户态实现。时间轮是经过工业验证的成熟方案。
⚠ 我曾经犯过的错:在一个项目里用 setitimer 同时管理 50 个超时定时器,结果信号处理函数里频繁操作全局链表,导致死锁。后来全部改成 timerfd + epoll,问题迎刃而解。所以我的建议是——能用 timerfd 就别用信号。

定时器看似简单,但选错了方案,后期维护成本会成倍增加。从 alarm 到时间轮,每种方案都有它的生态位。理解它们的本质差异,你才能在项目中做出正确的技术决策。


公众号:蓝海资料掘金营,微信deep3321