目录
前面的章节已经把大体框架弄好了,这一节我们添加定时器的功能。
说到定时器,那就一定会和时间有关联,我们使用c++11的std::chrono去获取当前时间,这个时间的精度是可以到纳秒的,我们就只取到微妙级别就足以应付了。
1.Timestamp类
关于时间的调用,封装在一个叫做Timestamp的类中。Timestamp类是对时间戳的封装,内部有个成员变量micro_seconds_since_epoch_,是其微妙数。
class Timestamp {
public:
// 默认构造函数,初始化为无效时间戳
Timestamp();
// 使用微秒级别的时间戳初始化
explicit Timestamp(int64_t microSecondsSinceEpoch);
// 获取当前的时间戳
static Timestamp now();
// 返回一个无效的时间戳,通常表示时间未初始化
static Timestamp invalid() { return Timestamp(); }
// 将时间戳转换为可读的字符串格式(“年月日 时:分:秒”)
std::string toString() const;
// 将时间戳转换为格式化字符串,可以选择是否显示微秒
std::string toFormattedString(bool showMicroseconds = true) const;
// 检查时间戳是否有效
bool valid() const { return micro_seconds_since_epoch_ > 0; }
// 获取自 epoch(1970年1月1日)以来的微秒数
int64_t microSecondsSinceEpoch() const { return micro_seconds_since_epoch_; }
// 微秒到秒的转换常量
static const int kMicroSecondsPerSecond = 1000 * 1000;
private:
// 存储自 epoch(1970年1月1日)以来的微秒数
int64_t micro_seconds_since_epoch_;
};
这个类比较简单,就不多说了。
2.Timer定时器类
时间类解决了,那么我们还需要定义一个定时器类,用于表明该定时器会在何时做何事。起个名字叫做Timer。
class Timer {
public:
// 定义一个定时器回调类型,用于存储定时器超时后的回调函数
using TimerCallback = std::function<void()>;
// 构造函数,初始化定时器的相关参数
Timer(TimerCallback cb, Timestamp when, double interval)
: callback_(std::move(cb)) // 将回调函数移动到成员中
, expiration_(when) // 设置定时器的超时时间
, interval_(interval) // 设置定时器的时间间隔
, repeat_(interval > 0.0) // 判断定时器是否为重复执行的
, sequence_(num_created_++) // 创建一个唯一的序列号来标识每个定时器
{}
// 执行定时器的回调函数
void run() const { callback_(); }
// 获取定时器的超时时间
Timestamp expiration() const { return expiration_; }
// 判断定时器是否为重复
bool repeat() const { return repeat_; }
// 获取定时器的唯一序列号
int64_t sequence() const { return sequence_; }
// 重启定时器,设置下一个执行的时间
void restart(Timestamp now) {
if (repeat_) // 若该定时器是重复的,就改写时间到下一个执行的时刻
expiration_ = addTime(now, interval_);
else
expiration_ = Timestamp::invalid(); // 一次性定时器,设置为无效
}
private:
const TimerCallback callback_; // 定时器执行的回调函数
Timestamp expiration_; // 定时器的执行时间,即超时事件
const double interval_; // 定期重发的时间间隔(单位:秒)
const bool repeat_; // 是否为重复定时器的标识
const int64_t sequence_; // 唯一的标识符,用于区分不同的定时器
// 使用原子整型来管理创建定时器的计数,确保多线程环境下安全
static std::atomic_int64_t num_created_; // 统计已创建的定时器数量
};
该Timer类也很容易理解,该类定义的定时器的执行时间,执行事件,是否是重复的等等,并用sequence_作为辨别定时器的唯一标识。
Timer类就是对应一个超时任务,保存了超时时刻Timestamp,超时回调函数,以及超时任务类型(一次 or 周期)。
3.定时函数的选择:使用timerfd_* 系列函数
定时函数,可用于让程序等待一段时间或安排计划任务。
而timerfd_* 入选的原因:
- sleep / alarm / usleep 在实现时有可能用了信号 SIGALRM,在多线程程序中处理信号是个相当麻烦的事情,应当尽量避免。
- nanosleep 和 clock_nanosleep 是线程安全的,但是在非阻塞网络编程中,绝对不能用让线程挂起的方式来等待一段时间,程序会失去响应。正确的做法是注册一个时间回调函数。
- getitimer 和 timer_create 也是用信号来 deliver 超时,在多线程程序中也会有麻烦。timer_create 可以指定信号的接收方是进程还是线程,算是一个进步,不过在信号处理函数(signal handler)能做的事情实在很受限。
- timerfd_create 把时间变成了一个文件描述符,该“文件”在定时器超时的那一刻变得可读,这样就能很方便地融入到 select/poll 框架中,用统一的方式来处理 IO 事件和超时事件,这也正是 Reactor 模式的长处。
- 传统的 Reactor 利用select/poll/epoll的timeout来实现定时功能,但 poll 和 epoll 的定时精度只有毫秒,远低于 timerfd_settime 的定时精度。
3.1.timerfd_* 系列函数的使用
timerfd 3个接口: timerfd_create,timerfd_settime,timerfd_gettime。
这里需要注意下如何设置重复的定时器。
#include <sys/timerfd.h>
// 创建一个定时器对象.
// 返回与之关联的文件描述符 (fd),与创建 socket 的方式相似.
// 参数 clockid 用于指定使用的时钟类型,
// 可以选择 CLOCK_REALTIME(系统范围时钟)或 CLOCK_MONOTONIC(不可调整的时钟,不能手动修改)
// 参数 flags 可指定为 TFD_NONBLOCK(设置 fd 为非阻塞模式)或 TFD_CLOEXEC(在 exec 时关闭 fd)。
int timerfd_create(int clockid, int flags);
// 启动或停止与指定 fd 关联的定时器。
// 参数 flags 可以指定为 0:启动一个相对定时器,由 new_value->it_value 指定相对超时时间;
// 或使用 TFD_TIMER_ABSTIME 启动一个绝对定时器,由 new_value->it_value 指定绝对超时时间。
// 参数 old_value 用于保存旧的定时值,以便后续需要时查看或复原。
// 函数返回 0 表示设置成功。
int timerfd_settime(int fd, int flags, const struct itimerspec *new_value, struct itimerspec *old_value);
// 如果定时器不需要重复触发,可以将 interval 设置为 0。
struct timespec {
time_t tv_sec; // 秒数
long tv_nsec; // 纳秒数
};
// 定时器规格结构体,用于指定定时器的间隔时间和初始过期时间。
struct itimerspec {
struct timespec it_interval; // 定时器触发的间隔时间
struct timespec it_value; // 定时器首次到期的时间
};
// 获取 fd 关联的定时器的当前时间值。
// 函数返回 0 表示成功,curr_value 将填充当前定时器的时间状态。
int timerfd_gettime(int fd, struct itimerspec *curr_value);
我们可以让创建的timerfd去绑定一个channel,这就和其他socketfd一样的,并设置好回调函数,接着交由epoll去监听该channel,该tiemrfd的定时时间到后就会触发epoll_wait(),那就可以在回调函数中执行定时任务了。
4.TimerQueue定时器容器类
我们有可能想设置多个定时器任务,那这时就需要去管理这么多个定时器,才不会混乱。那我们可以定义一个定时器容器类,用于管理这多个定时器,该类叫做TimerQueue。
4.1 对定时器操作的一些要求
我们想要的是能高效添加定时器,也能高效删除定时器。能快速地根据当前时间找到已经到期的定时器。
4.2容器的选择
那最简单的就是已按到期时间排好序的线性表作为数据结构,那其复杂度是O(N),其性能不符合我们的要求。
那另一种常用的方法就是优先队列了,这是一种二叉堆结构,但是不能高效删除堆中的某个元素,这不符合我们的高效删除要求。
想要满足高效删除的需求,那就还有二叉搜索树(例如std::set,std::map),其插入删除复杂度都是O(logN)。
若是简单的把Timer按照到期时间先后拍序,那使用std::map<Timestamp,Timer*>容器,但这不行,因为这样无法处理两个Timer到期时间相同的情况。仅仅用Timestamp作为key是无法进行区分,我们也可以使用mulitimap。但我们还是不是用mulitimap/mulitiset,可以把pair<Timestamp,Timer*>作为key,这样即使两个Timer的到期时间相同,那它们的地址也必定不同的。
那也不需要使用std::map<pair<Timestamp,Timer*>,Timer*>,可以就使用std::set<pair<Timestamp,Timer*>>,这样会简单点。
经过上面的分析,来看看TimerQueue的数据成员。
class TimerQueue {
public:
// 构造函数,接受一个 EventLoop 指针,用于定时器队列的管理
explicit TimerQueue(EventLoop* loop);
// 析构函数,负责清理定时器队列资源
~TimerQueue();
// 添加一个定时器,返回定时器的唯一标识符
int64_t addTimer(Timer::TimerCallback cb, Timestamp when, double interval);
// 取消一个已添加的定时器,使用其唯一标识符进行识别
void cancel(int64_t timerId);
private:
// 定义一个条目类型,包含时间戳和定时器指针的对
using Entry = std::pair<Timestamp, Timer*>;
// 定义一个定时器列表,使用 std::set 存储按时间戳排序的定时器条目
using TimerList = std::set<Entry>;
// 处理读取定时器超时事件的函数,在此函数内部执行定时器的任务
void handleRead();
// 获取所有超时的定时器条目,返回一个条目向量
std::vector<Entry> getExpired(Timestamp now);
// 插入定时器到定时器列表中
bool insert(Timer* timer);
EventLoop* loop_; // 关联的事件循环
const int timerfd_; // 通过 timerfd_create 创建的文件描述符
Channel timerfd_channel_; // 用于监听定时器超时事件的 Channel 对象
TimerList timers_; // 存储当前活动的定时器列表
};
该类内部也使用了一个channel去绑定timerfd_,和其他socketfd一样的。内部也有一个EventLoop变量,用来表示该定时器队列是在哪个IO线程调用的。那么就会牵扯到runInLoop()函数的啦。
该类可以给外部使用的函数接口就两个:删除定时器,添加定时器。
4.3其类的一些成员函数的实现
其构造函数
TimerQueue::TimerQueue(EventLoop* loop)
: loop_(loop) // 初始化成员变量 loop_,记录关联的事件循环
, timerfd_(timerfd_create(CLOCK_MONOTONIC, TFD_NONBLOCK | TFD_CLOEXEC)) // 创建定时器文件描述符
, timerfd_channel_(loop, timerfd_) // 初始化 Channel 对象,绑定到 timerfd_
{
// 设置读取回调函数,类似于其他 socket 文件描述符
// 该回调会在定时器到期时被调用,执行 handleRead 函数
timerfd_channel_.SetReadCallback([this]() { handleRead(); });
// 启用对 timerfd 的读事件监听
timerfd_channel_.enableReading();
}
来看看添加定时器的函数
int64_t TimerQueue::addTimer(Timer::TimerCallback cb, Timestamp when, double interval) {
// 创建一个新的定时器对象,使用给定的回调、到期时间和间隔时间
auto timer = new Timer(std::move(cb), when, interval);
// 将添加定时器的请求转发到所属的事件循环线程中执行,确保线程安全
loop_->runInLoop([this, &timer]() { addTimerInLoop(timer); });
// 返回新创建定时器的唯一标识符(序列号)
return timer->sequence();
}
void TimerQueue::addTimerInLoop(Timer* timer) {
// 将定时器插入定时器列表,判断插入的定时器的到期时间是否小于当前最快到期时间。
// 如果是,则需要重置最快的到期时间。
bool earliestChanged = insert(timer);
// 如果插入的定时器是当前最快到期的定时器,则重置 timerfd 的到期时间
if (earliestChanged)
resetTimerfd(timerfd_, timer->expiration()); // 重新设置定时器到期时间
}
addTimer函数使用了runInLoop()函数,这是为了可以在不是该所属的loop线程也能进行添加。
把定时器insert()插入到timers_容器中,并判断是否需要resetTimerfd()重置到期时间。这两个函数的实现后面会仔细解说的,先不着急,先知道增加定时器的流程。
接着说说删除定时器的函数。
在现有的条件下要删除定时器就麻烦了。在增加定时器的时候,该函数通过返回唯一的标识符timerId来标识该定时器。
那要删除定时器的时候,我们也是给删除函数传递该标识符timerId,这只是int64_t。而我们的容器timers_是std::set<pair<Timestamp,Timer*>,这样我们就不能通过timerId作为key来高效的找到该定时器了。
要是按照这样的,就只能线性地找到每个定时器来进行比较起标识符,这个性能就比较差了。
所以,为了可以方便高效地进行删除我们想要删除的定时器,那可以再添加一个新容器来进行删除了。那我们就可以添加一个以定时器标识符为key的容器,那可以选择std::unordered_map<int64_t,Timer*>。
std::unordered_map是c++11新增的。 unordered_map 容器和 map 容器仅有一点不同,即 map 容器中存储的数据是有序的,而 unordered_map 容器中是无序的。std::unordered_map的底部实现是哈希表,其查询和增删的复杂度是O(1),这方面性能很好。
在TimerQueue类中添加成员
class TimerQueue {
private:
TimerList timers_; // 存储当前活动的定时器列表,按时间戳自动排序,确保按到期时间管理定时器
// 新添加的容器,用于以唯一标识符 int64_t 作为键,快速进行定时器的删除操作
std::unordered_map<int64_t, Timer*> active_timers_;
};
那么这时就有两个容器了。那么这两个容器的元素和容量大小都要相等才行,也就是说要增加定时器和要删除定时器的时候,都要在这两个容器中操作。同删同减。
那接着要在前面的增加定时器函数中添加 在active_timers_中添加定时器。
新添加active_timers_后的删除函数
void TimerQueue::cancel(int64_t timerId) {
// 将取消定时器的请求转发到所属的事件循环线程中执行,以确保线程安全
loop_->runInLoop([this, timerId]() { cancelInLoop(timerId); });
}
void TimerQueue::cancelInLoop(int64_t timerId) {
// 在定时器队列的事件循环中执行的取消定时器操作
auto it = active_timers_.find(timerId); // 在活动定时器映射中查找指定的定时器
if (it != active_timers_.end()) { // 如果找到了定时器
// 从定时器列表中移除定时器条目
timers_.erase(Entry(it->second->expiration(), it->second));
// 释放定时器对象的内存
delete it->second;
// 从活动定时器映射中移除该定时器的条目
active_timers_.erase(it);
}
// 确保定时器列表和活动定时器映射的大小一致
assert(timers_.size() == active_timers_.size());
}
那接下来来看看增加定时器函数的内部实现,inset()函数和resetTimerfd()函数。
void resetTimerfd(int timerfd, Timestamp expiration) {
struct itimerspec new_value; // 定义新的定时器值结构
memset(&new_value, 0, sizeof(new_value)); // 清零结构体
new_value.it_value = howMuchTimeFronNow(expiration); // 计算从现在到指定过期时间的间隔
// 启动定时器,设置新的定时器值
int ret = ::timerfd_settime(timerfd, 0, &new_value, nullptr);
}
bool TimerQueue::insert(Timer* timer) {
bool earliestChanged = false; // 标记是否需要重置到期时间
auto when = timer->expiration(); // 获取定时器的到期时间
auto it = timers_.begin(); // 获取定时器列表中最早的到期时间
// 判断是否需要更新最早到期时间
// 若容器中没有元素,或者要添加的定时器的到期时间小于当前最早的到期时间
if (it == timers_.end() || when < it->first) {
earliestChanged = true; // 需要重置最早的到期时间
}
// 在两个容器中都添加定时器
timers_.emplace(Entry(when, timer)); // 将定时器添加到 timers_ 容器中
active_timers_.emplace(timer->sequence(), timer); // 将定时器添加到 active_timers_ 容器中
// 断言确保两个容器的大小相等
assert(timers_.size() == active_timers_.size());
return earliestChanged; // 返回是否更新了最早的到期时间
}
增加删除基本讲完了,那就到了定时器任务的执行。
前面也讲了其是通过epoll来进行监听,到了指定的时间,就会触发epoll_wait(),那就会触发设置好的回调函数handleRead()。我们就可以在该回调函数中执行定时器任务。
// 在这个函数内部执行定时器的任务
void TimerQueue::handleRead() {
Timestamp now(Timestamp::now()); // 获取当前时间戳
readTimerfd(timerfd_, now); // 读取 timerfd,清除超时事件。如果不调用,会不停触发 EPOLLIN
// 获取所有已超时的定时器,因为超时的定时器可能不止一个
auto expired = getExpired(now);
// 遍历已超时的定时器,执行它们的回调函数
for (const auto& it : expired) {
it.second->run(); // 执行定时器的任务
}
// 处理完到期的定时器后,重置最早的到期时间
reset(expired, now);
}
// 得到所有的超时定时器
std::vector<TimerQueue::Entry> TimerQueue::getExpired(Timestamp now) {
Entry sentry(now, reinterpret_cast<Timer*>(UINTPTR_MAX)); // 创建一个哨兵条目,以当前时间为到期时间
// 查找第一个未到期的定时器
auto end = timers_.lower_bound(sentry); // 使用 lower_bound 找到第一个未到期的定时器
// 把 [timers_.begin(), end) 范围内的已超时定时器拷贝到 expired 向量中
std::vector<Entry> expired(timers_.begin(), end);
// 从 timers_ 容器中删除这些已超时的定时器
timers_.erase(timers_.begin(), end);
// 遍历已超时的定时器,移除它们在 active_timers_ 中的记录
for (const auto& it : expired) {
active_timers_.erase(it.second->sequence()); // 在 active_timers_ 中删除
}
// 确保 timers_ 和 active_timers_ 的大小一致
assert(timers_.size() == active_timers_.size());
return expired; // 返回所有已超时的定时器
}
到这里基本完成的不错了,但是在删除的时候还是会有问题。想象一个场景:假如我们想要删除的一个定时器,这定时器是重复循环的。
而该定时器正在执行,那就说明在容器timers_和active_timers_中已经没有了该定时器。而这时我们想要删除该定时器却又找不到了,无从下手删除。而等到该定时器结束了,又会再次添加回到容器中,因为是重复的,那它到了时间就又会执行,这就不符合我们的删除情况了。
原因就出在想要删除的定时器却正在执行。那我们可以针对定时器正在执行的时候做一些特别的操作。
那我们可以用一个标识来表示定时器正在执行。正在执行我们想要删除的定时器时,就可以把该定时器放置在另一个容器(cancelingTimers_)中。等到定时器执行完毕后,需要重置最新的到期时间的时候,就检查该定时器是否在容器cancelingTimers_中,若是在该容器中,就算其是重复的定时器,也要把其删除。
那么在删除定时器的函数cancelInLoop和重置最新的到期时间函数reset需要修改。
而这也是我没有在讲完getExpired函数后就讲解reset函数的原因,只有解决了想要删除的定时器却正在执行的问题,reset函数才好实现。
先来看看TimerQueue类中需要新添加的成员
这时TimerQueue类就有3个容器,需要分清楚哈。
class TimerQueue {
private:
TimerList timers_; // 用于存储当前活动的定时器,便于按到期时间查找超时的定时器
// 为了方便以唯一标识符 int64_t 作为键进行快速删除
std::unordered_map<int64_t, Timer*> active_timers_;
// 新添加的容器,用于解决定时器正在执行时想要删除该定时器的情况
std::unordered_map<int64_t, Timer*> cancelingTimers_;
// 标识定时器是否在执行
std::atomic_bool callingExpiredTimers_;
};
后来添加的,在这新添的容器可以更加的简化,使用std::unordered_set<int64_t>就行啦。不需要std::unordered_map<int64_t, Timer*>。因为就只是通过定时器的sequeue去判断该定时器是否需要删除而已嘛。这个就留读者先去实现吧,也是很容易的呢。
先来看看怎样标识定时器正在执行,在handleRead()中添加callingExpiredTimers_。
void TimerQueue::handleRead() {
// 省略部分 ... (例如,获取当前时间戳和读取定时器文件描述符)
// 获取所有已超时的定时器
auto expired = getExpired(now);
// 1. 将标志设为 true,表示当前正在处理超时定时器
callingExpiredTimers_ = true;
// 2. 清空正在取消的定时器列表
cancelingTimers_.clear();
// 遍历已超时的定时器,执行它们的回调函数
for (const auto& it : expired) {
it.second->run(); // 执行定时器的任务
}
// 3. 将标志设为 false,表示超时定时器的处理已完成
callingExpiredTimers_ = false;
// 处理完后,重置最早的到期时间
reset(expired, now);
}
来看看删除的函数
void TimerQueue::cancelInLoop(int64_t timerId) {
auto it = active_timers_.find(timerId); // 查找指定的定时器
if (it != active_timers_.end()) {
// 省略......(这里执行取消定时器的逻辑,比如从 timers_ 中移除定时器、删除它的内存等)
}
// 这个 else if 是新添加的,是为了解决定时器正在执行,却想删除该定时器的问题
else if (callingExpiredTimers_) {
// 如果当前正在执行超时定时器的回调
cancelingTimers_.emplace(timerId, it->second); // 将要取消的定时器放入 cancelingTimers_ 中
}
}
还有最后的重置最新的超时时间reset函数
void TimerQueue::reset(const std::vector<Entry>& expired, Timestamp now) {
// 遍历所有已超时的定时器
for (const auto& it : expired) {
// 如果该定时器是重复的且没有在 cancelingTimers_ 容器中找到该定时器
if (it.second->repeat() &&
cancelingTimers_.find(it.second->sequence()) == cancelingTimers_.end()) {
it.second->restart(now); // 重启定时器,设置下次到期时间
insert(it.second); // 将重启后的定时器插入到 timers_ 中
} else {
delete it.second; // 如果不是重复定时器或已在取消队列中,删除定时器
}
}
// 检查 timers_ 是否为空
if (!timers_.empty()) {
// 获取下一个超时时间
Timestamp nextExpire = timers_.begin()->second->expiration(); // 获取最早的超时时间
// 如果时间有效,则重置最早的超时时间
if (nextExpire.valid()) {
resetTimerfd(timerfd_, nextExpire); // 重置定时器
}
}
}
到这里就基本可以了哈,这一节的代码还不能去执行。
这还不是结束,我们调用增加/删除的函数还没实现,是通过EventLoop去调用增删定时器的函数,这就留到下一节再去解决。这一节要好好理解在TimerQueue类中的各种情况以及解决的方式。
5.总结
我们这一节添加了Timestamp类,该类是对时间戳的封装,也是为了方便定时器使用有关时间的函数。
添加了Timer类,Timer类就是对应一个超时任务,保存了超时时刻Timestamp,超时回调函数,以及超时任务类型(一次 or 周期)。
接着讲解了如何在linux中实现定时,选择的定时函数,timerfd_* 系列函数的使用。
最后到了管理多个定时的类TimerQueue。讲解了选择哪种数据结构来进行管理定时器。
该类需要解决比较多问题,其类内部有三个容器,一定要清楚哪个容器是解决了哪个问题的,这是一一对应的。这个很重要。