15.添加定时器

前面的章节已经把大体框架弄好了,这一节我们添加定时器的功能。

说到定时器,那就一定会和时间有关联,我们使用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_* 入选的原因:

  1. sleep / alarm / usleep 在实现时有可能用了信号 SIGALRM,在多线程程序中处理信号是个相当麻烦的事情,应当尽量避免。
  2. nanosleep 和 clock_nanosleep 是线程安全的,但是在非阻塞网络编程中,绝对不能用让线程挂起的方式来等待一段时间,程序会失去响应。正确的做法是注册一个时间回调函数。
  3. getitimer 和 timer_create 也是用信号来 deliver 超时,在多线程程序中也会有麻烦。timer_create 可以指定信号的接收方是进程还是线程,算是一个进步,不过在信号处理函数(signal handler)能做的事情实在很受限。
  4. timerfd_create 把时间变成了一个文件描述符,该“文件”在定时器超时的那一刻变得可读,这样就能很方便地融入到 select/poll 框架中,用统一的方式来处理 IO 事件和超时事件,这也正是 Reactor 模式的长处。
  5. 传统的 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

  • 1
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值