前面的章节已经把大体框架弄好了,这一节我们添加定时器的功能。
说到定时器,那就一定会和时间有关联,我们使用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; }
int64_t microSecondsSinceEpoch() const { return micro_seconds_since_epoch_; }
static const int kMicroSecondsPerSecond = 1000 * 1000;
private:
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 Timer::restart(Timestamp now)
{
if (repeat_) //若该定时器是重复的,就改写时间到下一个执行的时刻
expiration_ = addTime(now, interval_);
else
expiration_ = Timestamp::invalid();
}
private:
const TimerCallback callback_; //定时器执行的事件
Timestamp expiration_; //定时器的执行时间,即是超时事件
const double interval_; //重复定时器执行事件的间隔时间,若是一次性定时器,该值为 0.0
const bool repeat_; //用于判断定时器是否是重复循环的
const int64_t sequence_; //用来辨别定时器的唯一标识
static std::atomic_int64_t num_created_; //用来sequence_
};
该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相似(int fd=socket(....))
// clockid 可指定为CLOCK_REALTIME(系统范围时钟)或CLOCK_MONOTONIC(不可设置的时钟,不能手动修改)
// flags 可指定为TFD_NONBLOCK(fd设置O_NONBLOCK),TFD_CLOEXEC(为fd设置close-on-exec)
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对应定时器的当前时间值
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:
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*>;
using TimerList = std::set<Entry>;
void handleRead(); //在这个函数内部执行定时器的任务
std::vector<Entry> getExpired(Timestamp now); //得到所有的超时定时器
bool insert(Timer* timer); //插入该定时器
EventLoop* loop_;
const int timerfd_; //通过timerfd_create创建的fd
Channel timerfd_channel_;
TimerList timers_;
};
该类内部也使用了一个channel去绑定timerfd_,和其他socketfd一样的。内部也有一个EventLoop变量,用来表示该定时器队列是在哪个IO线程调用的。那么就会牵扯到runInLoop()函数的啦。
该类可以给外部使用的函数接口就两个:删除定时器,添加定时器。
4.3其类的一些成员函数的实现
其构造函数
TimerQueue::TimerQueue(EventLoop* loop)
:loop_(loop)
,timerfd_(timerfd_create(CLOCK_MONOTONIC,TFD_NONBLOCK | TFD_CLOEXEC))
,timerfd_channel_(loop,timerfd_)
{
//和其他socketfd差不多,设置读回调,并让epoll进行监听
timerfd_channel_.SetReadCallback([this]() {handleRead(); });
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); }); //转交所属loop线程运行
return timer->sequence(); //返回唯一的标识
}
void TimerQueue::addTimerInLoop(Timer* timer)
{
//判断插入的定时器 的到期时间是否 小于当前最快到期时间。若是,就需要重置最快的到期时间
bool earliestChanged = insert(timer);
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类中添加成员
TimerQueue
{
private:
TimerList timers_; //这个是之前添加的容器
//新添加的,为了方便以唯一标识int64_t作为key来进行删除
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);//这个自定义函数是 获取expiration与当前时间的间隔
int ret = ::timerfd_settime(timerfd, 0, &new_value, nullptr); //启动定时器
}
bool TimerQueue::insert(Timer* timer)
{
bool earliestChanged = false;
auto when = timer->expiration(); //得到参数timer的到期时间
auto it = timers_.begin(); //获取timers_容器中的最早的到期时间
//若容器中没有元素 或者 要增添的定时器的到期时间 < timers_容器中的最早的到期时间(10点 < 11点)
if (it == timers_.end() || when < it->first) {
earliestChanged = true; //这种情况,说明需要重置最早的到期时间
}
//在两个容器中都添加定时器
timers_.emplace(Entry(when, timer));
active_timers_.emplace(timer->sequence(), timer);
assert(timers_.size() == active_timers_.size());
return earliestChanged;
}
增加删除基本讲完了,那就到了定时器任务的执行。
前面也讲了其是通过epoll来进行监听,到了指定的时间,就会触发epoll_wait(),那就会触发设置好的回调函数handleRead()。我们就可以在该回调函数中执行定时器任务。
//在这个函数内部执行定时器的任务
void TimerQueue::handleRead()
{
Timestamp now(Timestamp::now());
readTimerfd(timerfd_, now); //只是简单的调用::read()函数回应。不回应的话,就会一直触发EPOLLIN的
//获取已超时(大于now)的定时器 ,因为已超时的定时器可能不止一个的哈
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); //找到第一个未到期的定时器
std::vector<Entry> expired(timers_.begin(), end); //把[timers_.bein(),end)拷贝给expried
timers_.erase(timers_.begin(), end); //在timers_容器中删除
for (const auto& it : expired) {
active_timers_.erase(it.second->sequence()); //在active_timers_中删除
}
assert(timers_.size() == active_timers_.size());
return expired;
}
到这里基本完成的不错了,但是在删除的时候还是会有问题。想象一个场景:假如我们想要删除的一个定时器,这定时器是重复循环的。
而该定时器正在执行,那就说明在容器timers_和active_timers_中已经没有了该定时器。而这时我们想要删除该定时器却又找不到了,无从下手删除。而等到该定时器结束了,又会再次添加回到容器中,因为是重复的,那它到了时间就又会执行,这就不符合我们的删除情况了。
原因就出在想要删除的定时器却正在执行。那我们可以针对定时器正在执行的时候做一些特别的操作。
那我们可以用一个标识来表示定时器正在执行。正在执行我们想要删除的定时器时,就可以把该定时器放置在另一个容器(cancelingTimers_)中。等到定时器执行完毕后,需要重置最新的到期时间的时候,就检查该定时器是否在容器cancelingTimers_中,若是在该容器中,就算其是重复的定时器,也要把其删除。
那么在删除定时器的函数cancelInLoop和重置最新的到期时间函数reset需要修改。
而这也是我没有在讲完getExpired函数后就讲解reset函数的原因,只有解决了想要删除的定时器却正在执行的问题,reset函数才好实现。
先来看看TimerQueue类中需要新添加的成员
这时TimerQueue类就有3个容器,需要分清楚哈。
TimerQueue
{
private:
TimerList timers_; //这个为了 方便查找超时的定时器 的容器
//为了方便以唯一标识int64_t作为key来进行删除
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()
{
//省略部分......... 其中 1,2,3,是新添加的
auto expired = getExpired(now);
callingExpiredTimers_ = true; //1
cancelingTimers_.clear(); //2
for (const auto& it : expired) {
it.second->run();
}
callingExpiredTimers_ = false; //3
reset(expired, now); //到期的定时器处理完后,需要进行重置最早的到期时间
}
来看看删除的函数
void TimerQueue::cancelInLoop(int64_t timerId)
{
auto it = active_timers_.find(timerId);
if (it != active_timers_.end()) {
· //省略..........
}
//这个else if是新添加的,是为了解决定时器正在执行,却想删除该定时器的问题
else if (callingExpiredTimers_) {
cancelingTimers_.emplace(timerId, it->second);
}
}
还有最后的重置最新的超时时间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);
}
else {
delete it.second;
}
}
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。讲解了选择哪种数据结构来进行管理定时器。
该类需要解决比较多问题,其类内部有三个容器,一定要清楚哪个容器是解决了哪个问题的,这是一一对应的。这个很重要。
完整源代码:https://github.com/liwook/CPPServer/tree/main/code/server_v15