首页 关于
树枝想去撕裂天空 / 却只戳了几个微小的窟窿 / 它透出天外的光亮 / 人们把它叫做月亮和星星
目录

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说明了计时器的类型。它有以下几种选择:

在构建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中。如右侧的代码片段所示,我们为之定义了三个私有的成员。

下面左侧是Timer的构造函数,我们首先通过调用timerfd_create构建计时器,并用成员mFd记录下它的文件名描述符。在断言描述符一定大于零之后,实例化了事件分发器, 并打开对可读事件的监听功能。最后,在注册读事件的回调函数OnReadEvent。该回调函数的实现如下面右边的代码所示,我们通过调用read读取计时溢出次数,将之记录在局部变量exp中。 然后调用计时溢出的回调函数。后面我们会看到这个回调函数将由TcpServer提供,并在该回调中通过时间轮盘的形式判定网络连接是否超时。

Timer::Timer() {
    mFd = timerfd_create(CLOCK_REALTIME, 0);
    assert(mFd > 0);
    mEventHandler = PollEventHandlerPtr(new PollEventHandler(mFd));
    mEventHandler->EnableRead(true);
    mEventHandler->EnableWrite(false);
    mEventHandler->SetReadCallBk(std::bind(&Timer::OnReadEvent, this));
}
void Timer::OnReadEvent() {
    uint64_t exp;
    ssize_t s = read(mFd, &exp, sizeof(exp));
    if (mTimeOutCb)
        mTimeOutCb();
}

针对我们刚刚提到的计时器的两种需求,我们在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"的。

        int main(int argc, char *argv[]) {
            PollLoopPtr loop = CreatePollLoop();
            TimerPtr timer = TimerPtr(new Timer());
        
            struct timespec t = { 1, 0 };
            timer->RunEvery(t, std::bind(OnTimeOut, timer));
        
            ApplyOnLoop(timer, loop);
            loop->Loop(10000);
            return 0;
        }
        void OnTimeOut(TimerPtr const & timer) {
            std::cout << __FUNCTION__ << std::endl;
        }

3. 完

我们可以通过timerfd_create构建一个计时器并获取它的文件描述符,有了文件描述符我们就可以将计时过程融合到PollLoop的框架下。所以为之创建了一个数据类型Timer来对其进行封装。 通过timerfd_settime可以添加定时方案,我们为Timer提供了RunAfter和RunEvery两个接口,分别用于在指定时间之后执行任务,或者周期性的运行。




Copyright @ 高乙超. All Rights Reserved. 京ICP备16033081号-1