timerfd与定时任务
定时任务,是网络编程当中经常需要处理的一类任务。比如说,基于长连接的服务,为了确认连接没有中断,通常都会要求客户端每隔一段时间发送一个心跳包给服务器,服务器也会返回一个响应到客户端, 告知彼此都还活着。
为了实现定时任务,我一开始的想法是再创建一个线程,在这个线程里面通过调用sleep阻塞一段时间,当sleep返回之后, 就通过上一节中介绍的eventfd来唤醒PollLoop来执行定时任务。 按照这个逻辑应该可以写出满足需求的代码,但它引入了多线程,而我们目前还有考虑并行运算所带来的任何竞争冒险的问题。 那么有没有可能,像处理多个连接那样,通过PollLoop的循环框架在单个线程中完成这件事情呢?
本文将要介绍的timerfd以及相关的系统调用就可以担此大任。我们将对它们进行封装提供一个计时器,并编写一个简单的demo。 在下一篇文章我们会将之应用到echo服务器上,让它具有超时断开连接的功能。
1. timerfd简介
参考我们以前写单片机程序时的计时器,要让服务器判断超时主动断开连接的一个比较直接的想法就是,构建一个计时器, 当计时溢出的时候产生一个事件,驱使我们调用超时的回调函数,并在这个函数中通知PollLoop循环关闭连接。我们知道,PollLoop的内核poll调用监听的是文件描述符的读写错事件。 既然有人说在linux系统中,万物皆可以是文件,那么如果我们能用文件描述符来表示计时器,不就可以接到PollLoop的循环框架下了吗。
在Linux系统中,有一组以timerfd为前缀的调用。它们分别是timerfd_create、timerfd_settime、timerfd_gettime。
我们可以通过指令$ man timerfd_create
来查看相关文档,如右图所示。
其中,timerfd_create用于创建一个计时器对象,并为之提供一个文件描述符,用于通知进程计时事件。它有两个参数,clockid说明了计时器的类型。它有以下几种选择:
- CLOCK_REALTIME: 这是一种可以设置的系统级实时时钟。
- CLOCK_MONOTONIC: 这是一种不可修改的,单调递增的计时器。
- CLOCK_BOOTTIME: 与CLOCK_MONOTONIC类似的,这也是一个不可修改的单调递增的计时器。只是当系统休眠的时候,CLOCK_MONOTONIC是不会计时的。 而它在这段时间中也会计时。
- CLOCK_REALTIME_ALARM: 功能上与CLOCK_REALTIME没有本质区别,只是当系统休眠的时候会唤醒系统。但是这要求调用者具有CAP_WAKE_ALARM的能力。
- CLOCK_BOOTTIME_ALARM: 功能上与CLOCK_BOOTTIME没有本质区别,只是当系统休眠的时候会唤醒系统。但是这要求调用者具有CAP_WAKE_ALARM的能力。
在构建timerfd的时候,我们还可以通过参数flags,来设定计时器的一些特性。目前主要是TFD_NONBLOCK用于设定文件描述符工作在非阻塞的状态下。 TFD_CLOEXEC则用于通过fork-exec创建新的进程并运行其它程序时自动关闭子进程中的文件描述符。它们可以通过位或运算进行组合,目前我们不考虑这些特性。
创建了计时器之后,我们需要通过调用timerfd_settime来启动或者停止计时。该调用有4个参数,如上边右侧的截图所示。其中,fd是将要操作的计时器的文件描述符; flags描述了计时特性,这里我们只关注TFD_TIMER_ABSTIME,表示绝对计时。如果设置了该特性,只有当计时器达到了第三个参数new_value中的it_value字段所描述的时刻,才认为计时到期。 默认情况下,采用的都是相对计时,即相对于调用timerfd_settimer的时刻,经过new_value.it_value字段描述的时间后,认为计时到期。
struct timespec {
time_t tv_sec;
long tv_nsec;
};
struct itimerspec {
struct timespec it_interval;
struct timespec it_value;
};
我们对计时器有两种需求。其一,我们希望有一个闹钟在经过了一段时间,或者到达了某个特殊时刻之后,通知我们去处理一些事务。其二,我们需要周期性的工作,即每过一段时间就去完成一个特定任务。 这两个需求都体现在timerfd_settime的第三个参数new_value上了。
该参数的数据类型是struct itimerspec
,其定义如右侧代码所示。
它有两个字段,其中it_value表示计时器第一次到期的时刻,如果是相对计时器则经过该字段描述的时间之后,计时器第一次计时到期。若是绝对计时器,则要求计时器到达该时刻。
这就满足了我们的第一个需求。
字段it_interval描述的是计时周期,计时器在第一次到期之后,每个该字段描述的一段时间之后,都会产生依次计时到期时间。这满足了我们的第二个需求。
此外,timerfd_settime还有第四个参数old_value,用于返回当前计时器的计时设置。如果我们不关心它,可以传递一个NULL。当然,我们也可以通过调用timerfd_gettime来获取计时设置。 通过这三个系统调用,我们就可以创建一个使用文件描述符来表示的计时器,设置和获取计时到期条件。下面,我们对它们进行封装,以融合到我们的PollLoop框架下。
class Timer {
private:
PollEventHandlerPtr mEventHandler;
struct timespec mOriTime;
int mFd;
};
2. Timer——计时器的封装
为了方便的使用timerfd,这里我们将它们封装到类Timer中。如右侧的代码片段所示,我们为之定义了三个私有的成员。
- mEventHandler是一个事件分发器, 用于通过PollLoop循环来监听文件描述符mFd所对应的计时器的到期事件,并调用相应的回调函数OnReadEvent。 我们将在Timer的构造函数中完成该对象的实例化工作,用户还需要通过ApplyHandlerOnLoop接口将它注册到一个PollLoop循环上。
- mFd是一个通过调用timerfd_create获得的文件描述符,它对应一个计时器。当计时到期事件发生的时候,我们都可以通过调用read(2)来获取计时溢出的次数。 所以我们所关心的计时到期事件,实质上是文件描述符mFd的可读事件。
- mOriTime是一个用于绝对计时的参考时间点。
下面左侧是Timer的构造函数,我们首先通过调用timerfd_create构建计时器,并用成员mFd记录下它的文件名描述符。在断言描述符一定大于零之后,实例化了事件分发器, 并打开对可读事件的监听功能。最后,在注册读事件的回调函数OnReadEvent。该回调函数的实现如下面右边的代码所示,我们通过调用read读取计时溢出次数,将之记录在局部变量exp中。 然后调用计时溢出的回调函数。后面我们会看到这个回调函数将由TcpServer提供,并在该回调中通过时间轮盘的形式判定网络连接是否超时。
|
|
针对我们刚刚提到的计时器的两种需求,我们在Timer中定义了两个接口RunAfter和RunEvery,来分别用于设置单次定时任务和周期定时任务。下面是单次定时任务RunAfter的实现片段, 它有两个输入参数。time表示从调用该函数开始,经历一段时间之后,执行回调函数cb中定义的任务。
因为我们采用的是绝对计时器,所以在调用timerfd_settime之前,需要先获取当前的时间。在linux系统中有调用clock_gettime来完成这一任务,我们将当前时间记录在成员变量mOriTime中。 作为计时的参考点。
void Timer::RunAfter(const timespec & time, EventCallBk cb) {
if (clock_gettime(CLOCK_REALTIME, &mOriTime) == -1) {
perror("clock_gettime failed!");
exit(1);
}
接下来,根据mOriTime和输入的计时配置构建new_value对象。RunAfter只执行一次定时任务,所以这里将字段it_interval中的秒和纳秒字段都置为0。
struct itimerspec new_value;
new_value.it_value.tv_sec = mOriTime.tv_sec + time.tv_sec;
new_value.it_value.tv_nsec = mOriTime.tv_nsec + time.tv_nsec;
new_value.it_interval.tv_sec = 0;
new_value.it_interval.tv_nsec = 0;
最后,我们调用timerfd_settime设置定时。如果成功完成定时设置,就把输入的回调任务cb赋值给mTimeOutCb。
if (timerfd_settime(mFd, TFD_TIMER_ABSTIME, &new_value, NULL) == -1) {
perror("timerfd_settime");
exit(1);
}
mTimeOutCb = std::move(cb);
}
周期定时任务RunEvery的大体与RunAfter都是一致的,只在构建计时配置对象new_value的时候,略有不同。如下面的代码片段所示,RunEvery给字段it_interval赋值了。
struct itimerspec new_value;
new_value.it_value.tv_sec = mOriTime.tv_sec;
new_value.it_value.tv_nsec = mOriTime.tv_nsec;
new_value.it_interval.tv_sec = time.tv_sec;
new_value.it_interval.tv_nsec = time.tv_nsec;
我们提供了一个周期输出日志的demo,如下面的代码片段所示。我们在main函数中, 先创建了PollLoop和Timer对象。然后通过RunEvery接口,设定计时器每隔一秒调用一次回调函数OnTimeOut。如右侧所示,在OnTimeOut中,我们直接输出函数名称。 最后在main函数中注册timer的事件分发器,并开启Loop循环。如果编译运行一切顺利,我们是可以看到程序在终端里每隔一秒输出一个"OnTimeOut"的。
|
|
3. 完
我们可以通过timerfd_create构建一个计时器并获取它的文件描述符,有了文件描述符我们就可以将计时过程融合到PollLoop的框架下。所以为之创建了一个数据类型Timer来对其进行封装。 通过timerfd_settime可以添加定时方案,我们为Timer提供了RunAfter和RunEvery两个接口,分别用于在指定时间之后执行任务,或者周期性的运行。