仿 muduo 高并发服务器项目:从 timerfd 到时间轮实现定时任务机制

前言

最近我在实现一个仿 muduo 的 C++ 高并发服务器项目。对于一个网络服务器来说,除了处理客户端连接上的读写事件,还有一类非常重要的事件:超时事件

比如:

  • 客户端长时间没有发送数据,需要主动关闭连接
  • 服务端需要定期发送心跳包
  • 某些任务希望延迟一段时间后执行
  • 定时清理无效连接或过期资源

在 Reactor 模型中,读写事件通常由 epoll 监听 socket fd 完成。那么定时事件应该怎么接入事件循环呢?

今天主要完成了两个实验:

  • 使用 Linux 的 timerfd,理解"定时器也可以被抽象成文件描述符"
  • 实现一个基础时间轮 TimerWheel,用于管理大量定时任务

这两个部分结合起来,就可以为后续服务器中的连接超时管理打基础。

一、timerfd:把定时器抽象成 fd

Linux 提供了 timerfd 机制,可以把定时器封装成一个文件描述符。这样一来,定时器就可以像 socket 一样被 epoll 监听。

这点非常适合 Reactor 模型,因为事件循环本身就是围绕 fd 展开的。

我先写了一个简单的 timerfd.cpp 实验程序:

复制代码
#include <stdio.h>
#include <unistd.h>
#include <fcntl.h>
#include <stdint.h>
#include <sys/timerfd.h>

int main()
{
    int timerfd = timerfd_create(CLOCK_MONOTONIC, 0);
    if (timerfd < 0) {
        perror("timerfd_create error");
        return -1;
    }

    struct itimerspec itime;
    itime.it_value.tv_sec = 1;
    itime.it_value.tv_nsec = 0;
    itime.it_interval.tv_sec = 1;
    itime.it_interval.tv_nsec = 0;

    timerfd_settime(timerfd, 0, &itime, NULL);

    while(1) {
        uint64_t times;
        int ret = read(timerfd, &times, 8);
        if (ret < 0) {
            perror("read error");
            return -1;
        }
        printf("超时了,距离上一次超时了%ld次\n", times);
    }

    close(timerfd);
    return 0;
}

这里的核心接口是:

复制代码
int timerfd = timerfd_create(CLOCK_MONOTONIC, 0);

CLOCK_MONOTONIC 表示使用单调递增时钟,不受系统时间修改影响。对于服务器定时任务来说,这比直接依赖系统实时时间更稳定。

接着通过 timerfd_settime 设置定时器:

复制代码
struct itimerspec itime;
itime.it_value.tv_sec = 1;
itime.it_value.tv_nsec = 0;
itime.it_interval.tv_sec = 1;
itime.it_interval.tv_nsec = 0;

timerfd_settime(timerfd, 0, &itime, NULL);

其中:

  • it_value 表示第一次超时时间,这里是 1 秒后第一次触发
  • it_interval 表示后续周期触发间隔,这里是每隔 1 秒触发一次

然后通过 read 等待定时器触发:

复制代码
uint64_t times;
int ret = read(timerfd, &times, 8);

如果当前还没有超时事件,read 会阻塞。等定时器超时后,read 返回,并把触发次数写入 times

times 的含义是:距离上一次读取期间,定时器累计超时了多少次

例如服务器因为某些原因一段时间内没有及时读取 timerfd,那么下一次读取时,times 可能大于 1。

这个实验说明了一件事:定时器可以被抽象成 fd。后续就可以把 timerfd 加入 epoll,让定时事件和网络 IO 事件统一由事件循环处理。

二、为什么还需要时间轮

有了 timerfd 之后,是否就能直接管理所有连接超时了?

理论上可以,但如果每一个连接都创建一个系统定时器,代价会比较高,也不方便统一管理。

在高并发服务器里,可能同时有成千上万个连接,每个连接都需要一个空闲超时任务。如果每个连接都单独维护系统级定时器,复杂度和资源开销都会变大。

因此更常见的做法是:

  • timerfd 只负责产生周期性的时钟滴答,比如每秒触发一次
  • 时间轮负责管理大量业务定时任务
  • 每次 timerfd 触发后,推动时间轮向前走一格
  • 时间轮走到某个槽位时,执行该槽位上的过期任务

这样就把系统定时器和业务定时任务拆开了。

可以简单理解为:

复制代码
timerfd:负责报时,每秒告诉服务器"时间到了"
时间轮:负责管理哪些任务应该在什么时候执行

三、时间轮的核心设计

这次实现了两个核心类:

  • TimerTask:表示一个定时任务
  • TimerWheel:表示时间轮,负责添加、刷新、取消和触发任务

1. TimerTask 定时任务对象

TimerTask 保存一个具体的定时任务:

复制代码
using TaskFunc = std::function<void()>;
using ReleaseFunc = std::function<void()>;

class TimerTask{
private:
    uint64_t _id;
    uint32_t _timeout;
    bool _canceled;
    TaskFunc _task_cb;
    ReleaseFunc _release;

public:
    TimerTask(uint64_t id, uint32_t delay, const TaskFunc &cb)
        : _id(id), _timeout(delay), _task_cb(cb), _canceled(false)
    {}

    ~TimerTask() {
        if (_canceled == false) _task_cb();
        _release();
    }

    void Cancel() { _canceled = true; }
    void SetRelease(const ReleaseFunc &cb) { _release = cb; }
    uint32_t DelayTime() { return _timeout; }
};

这里几个成员变量的含义是:

  • _id:定时任务的唯一编号
  • _timeout:定时任务的超时时间
  • _canceled:表示任务是否被取消
  • _task_cb:真正要执行的任务回调
  • _release:任务释放时执行的清理回调,用来从时间轮的管理表中移除任务

这个设计里比较巧妙的一点是:任务回调是在析构函数中执行的

复制代码
~TimerTask() {
    if (_canceled == false) _task_cb();
    _release();
}

也就是说,当时间轮某个槽位被清空时,槽位中保存的 shared_ptr<TimerTask> 会被释放。如果这个定时任务没有被刷新到其他槽位,也没有被取消,那么对象析构时就会执行 _task_cb

如果任务已经被取消,则只做资源清理,不执行业务回调。

2. TimerWheel 时间轮结构

TimerWheel 的核心结构如下:

复制代码
class TimerWheel {
private:
    using WeakTask = std::weak_ptr<TimerTask>;
    using PtrTask = std::shared_ptr<TimerTask>;

    int _tick;
    int _capacity;
    std::vector<std::vector<PtrTask>> _wheel;
    std::unordered_map<uint64_t, WeakTask> _timers;
};

几个成员变量的作用:

  • _tick:当前秒针位置
  • _capacity:时间轮大小,目前设置为 60
  • _wheel:时间轮数组,每个槽位保存一组定时任务
  • _timers:通过任务 id 快速找到对应任务

这里 _wheel 使用 shared_ptr 保存任务,_timers 使用 weak_ptr 保存任务。

这样做的好处是:

  • 时间轮槽位拥有任务对象的生命周期
  • 哈希表只是用来查找任务,不强行延长对象生命周期
  • 当任务真正过期并释放后,哈希表中的记录也会被清理

四、关键接口实现

1. 添加定时任务 TimerAdd

复制代码
void TimerAdd(uint64_t id, uint32_t delay, const TaskFunc &cb) {
    PtrTask pt(new TimerTask(id, delay, cb));
    pt->SetRelease(std::bind(&TimerWheel::RemoveTimer, this, id));

    int pos = (_tick + delay) % _capacity;
    _wheel[pos].push_back(pt);
    _timers[id] = WeakTask(pt);
}

添加定时任务时,先创建 TimerTask 对象,然后根据当前秒针位置和延迟时间计算任务应该放在哪个槽位:

复制代码
int pos = (_tick + delay) % _capacity;

假设当前 _tick 在 10,任务延迟 5 秒执行,那么它应该放到第 15 个槽位。

如果超过了时间轮容量,就通过取模回到前面的槽位。

2. 刷新定时任务 TimerRefresh

复制代码
void TimerRefresh(uint64_t id) {
    auto it = _timers.find(id);
    if (it == _timers.end()) {
        return;
    }

    PtrTask pt = it->second.lock();
    int delay = pt->DelayTime();
    int pos = (_tick + delay) % _capacity;
    _wheel[pos].push_back(pt);
}

刷新定时任务的作用是:把任务重新放到未来的某个槽位,从而延长它的生命周期

这个接口非常适合连接超时管理。

比如服务器设置连接 60 秒没有数据就关闭。如果某个连接在第 30 秒收到了新数据,说明连接仍然活跃,就可以刷新它的定时任务,让它重新从当前时间开始计算 60 秒。

这里通过 _timers 找到任务对应的 weak_ptr,再调用 lock() 得到 shared_ptr,然后重新放入新的槽位。

有一个后续可以优化的点:weak_ptr::lock() 之后最好判断一下是否为空。

复制代码
PtrTask pt = it->second.lock();
if (!pt) {
    return;
}

因为任务可能已经释放,如果不判断就直接访问,存在空指针风险。当前 demo 中流程比较简单,还没有触发这个问题,但后续接入完整服务器时需要补上。

3. 取消定时任务 TimerCancel

复制代码
void TimerCancel(uint64_t id) {
    auto it = _timers.find(id);
    if (it == _timers.end()) {
        return;
    }

    PtrTask pt = it->second.lock();
    if (pt) pt->Cancel();
}

取消任务并不是立即从所有槽位中删除任务,而是把任务标记为取消:

复制代码
void Cancel() { _canceled = true; }

等任务对象后续析构时,会检查 _canceled

复制代码
if (_canceled == false) _task_cb();

如果任务已经被取消,就不会执行真正的业务回调。

这种方式避免了在时间轮多个槽位中查找并删除任务,逻辑更简单。

4. 推动时间轮 RunTimerTask

复制代码
void RunTimerTask() {
    _tick = (_tick + 1) % _capacity;
    _wheel[_tick].clear();
}

这个函数应该每秒执行一次,相当于秒针向前走一步。

当清空当前槽位时,该槽位中保存的 shared_ptr<TimerTask> 会被释放。如果某个任务没有被其他槽位引用,那么它就会析构,并执行对应的超时回调。

这就是当前时间轮触发定时任务的核心机制。

五、测试代码与现象

为了测试时间轮,我写了一个简单的 Test 类:

复制代码
class Test {
public:
    Test() { std::cout << "构造" << std::endl; }
    ~Test() { std::cout << "析构" << std::endl; }
};

void DelTest(Test *t) {
    delete t;
}

然后在 main 函数中添加一个 5 秒后删除 Test 对象的定时任务:

复制代码
int main()
{
    TimerWheel tw;

    Test *t = new Test();

    tw.TimerAdd(888, 5, std::bind(DelTest, t));

    for(int i = 0; i < 5; i++) {
        sleep(1);
        tw.TimerRefresh(888);
        tw.RunTimerTask();
        std::cout << "刷新了一下定时任务,重新需要5s中后才会销毁\n";
    }

    tw.TimerCancel(888);

    while(1) {
        sleep(1);
        std::cout << "-------------------\n";
        tw.RunTimerTask();
    }

    return 0;
}

这段测试代码主要验证两个功能:

1. 验证刷新逻辑

任务最开始设置为 5 秒后执行。

但是在前 5 秒中,每秒都会调用一次:

复制代码
tw.TimerRefresh(888);

这就相当于不断把任务重新放到当前时间 5 秒后的槽位中。

所以这个对象不会在最初的 5 秒时刻析构。

这对应到服务器项目中,就是客户端不断发送数据时,连接的超时时间会被不断刷新。

2. 验证取消逻辑

刷新结束后调用:

复制代码
tw.TimerCancel(888);

这会把任务标记为取消。后续即使时间轮走到对应槽位,任务对象析构,也不会执行删除 Test 对象的回调。

也就是说,取消操作可以阻止超时任务真正执行。

六、今天实现的项目价值

今天这部分虽然还是实验代码,但已经把服务器定时器机制的核心思路跑通了。

总结一下:

  • timerfd 可以把定时事件抽象成 fd,后续可以接入 epoll
  • 时间轮可以统一管理大量定时任务,适合高并发场景
  • TimerAdd 用于添加超时任务
  • TimerRefresh 用于刷新活跃连接的过期时间
  • TimerCancel 用于取消不需要执行的任务
  • RunTimerTask 用于推动时间轮并触发过期任务

对于仿 muduo 服务器来说,这套机制后续可以应用在连接管理中:

复制代码
客户端连接建立 -> 添加超时任务
客户端发送数据 -> 刷新超时任务
客户端主动关闭 -> 取消超时任务
长时间无数据 -> 时间轮触发关闭连接

这也是高并发服务器中非常常见的连接生命周期管理方式。

七、后续优化方向

当前代码还是单线程 demo 版本,后续接入完整服务器时,还需要继续完善:

  1. TimerRefreshweak_ptr::lock() 后需要判断是否为空
  2. 需要把 timerfd 加入 epoll,由事件循环统一驱动时间轮
  3. 时间轮目前容量固定为 60,只适合 60 秒以内的延迟任务,后续可以扩展为更通用的设计
  4. 接入多线程 Reactor 后,需要考虑定时任务操作的线程安全问题
  5. 定时任务回调中如果涉及连接释放,需要注意对象生命周期管理

八、总结

今天主要完成了仿 muduo 项目中定时器模块的基础实验。

我先通过 timerfd.cpp 理解了 Linux 中定时器 fd 的用法,然后通过 timewheel.cpp 实现了一个简单的时间轮,用来管理定时任务的添加、刷新、取消和触发。

这部分内容后续会和 EventLoopPollerChannel 结合起来,让服务器不仅能处理 socket 读写事件,也能处理连接超时这类定时事件。

从项目角度看,这一步是在为后面的连接管理和高并发稳定性打基础。

相关推荐
AI的探索之旅1 小时前
从 Ubuntu 14.04 到 24.04:TI AM335x 开发环境完整迁移与 Agent 接管方案
linux·数据库·嵌入式硬件·ubuntu·postgresql
阿pin1 小时前
Android随笔-pipe是什么?
android·linux·pipe
小趴菜え1 小时前
升级Ubuntu20.04版本至Ubuntu22.04
linux·运维
辰三1 小时前
统信 UOS + GBase 8s 国产化环境部署实战:从虚拟机到数据库连接完整指南
linux·c#
小羊Yveesss1 小时前
2026年外贸建站服务器怎么选?访问速度、稳定性和海外收录怎么判断
大数据·运维·服务器
Darkwanderor2 小时前
Linux进程优先级操作
linux·运维·c语言·c++
智者知已应修善业2 小时前
【P12159蓝桥杯数组翻转】2025-4-24
c语言·c++·经验分享·笔记·算法·蓝桥杯
盐焗鹌鹑蛋2 小时前
【C++】多态
java·jvm·c++
AOwhisky2 小时前
Linux(CentOS)系统管理入门笔记(第一期)——从 Multics 到主流发行版
linux·运维·笔记·centos·云计算