1.需要使用定时器的场景(需求)

  • 使用TCP长连接的时候,客户端需要定时向服务器发送心跳

  • 当客户端/服务器突然断电,此时无法通过协议栈感知网络变化,所以对于某每一个连接,都需要设置一个超时时间,当时间过期时,直接断开连接。

2.定时器API形式(原型)

  • 每间隔N秒执行一次。run_every
  • 经过N秒后,只执行一次。run_after
  • 每次经过某个时间点时,执行一次。run_at

3.定时器的实现(编码)

3.1 理论基础

在linux系统中定时器有分为软定时和硬件定时器。硬件定时器一般指的是CPU的一种底层寄存器,它负责按照固定时间频率产生中断信号,形成信号源。基于硬件提供的信号源,系统就可以按照信号中断来计数,计数在固定频率下对应固定的时间,根据预设的时间参数即可产生定时中断信号,这就是软定时。

现实世界中的时钟,是因为记录了间隔的数量,每一个间隔代表1s【可以抽象成时间轮】

时间是一直在流动的,这里就有一个观测粒度(linux获取时间的精度)

3.2 实现方法

3.2.1 定时函数

Function Type Precision Remark
sleep(3) unsigned int second
usleep(3) useconds_t microsecond
nanosleep(2) struct timespec nanosecond
clock_nanosleep(2) struct timespec nanosecond It differs in allowing the caller to select the clock against which the sleep interval is to be measured, and in allowingthe sleep interval to be specified as either an absolute or a relative value.
alarm(2) unsigned int second SIGALRM
setitimer(2) struct itimerval microsecond SIGALRM
timer_settime(2) struct itimerspec nanosecond notify method : struct sigevent
Timerfd API File descriptor nanosecond From linux kernel 2.6.25

前面四种函数会阻塞当前进程,alarm和setitimer依赖信号,不太可靠。

API详细介绍:(60条消息) Linux中的几种定时器_linux 定时器_私房菜的博客-CSDN博客

3.2.2 获取时间函数

Function Type Precision Remark
time(2) time_t second
ftime(3) struct timeb millisecond obsolete
gettimeofday(2) struct timeval microsecond
clock_gettime(2) struct timespec nanosecond
Time Stamp Counter 64-bit register CPU related on all x86 processors since the Pentium(TSC)

API详细介绍:

//时间换算关系
// 1s[second] = 1000ms[millisecond ] = 1000,000us[microsecond] = 1000,000,000ns[nanosecond]
void some_function_to_get_curr_time() {

    // (bt) time()函数,C库函数,精度秒,返回自1970年1月1日以来经过的秒数
    // https://man7.org/linux/man-pages/man2/time.2.html
    time_t t;
    t = time(NULL);
    std::cout << t << std::endl;

    // ftime()函数,精度毫秒。C库函数,不过已经被弃用了
    // https://man7.org/linux/man-pages/man3/ftime.3.html
    //
    //

    // (bt)gettimeofday(), 精度微妙,C库函数
    // https://man7.org/linux/man-pages/man2/settimeofday.2.html
    timeval tv;
    gettimeofday(&tv, NULL);
    std::cout << tv.tv_sec << "s " << tv.tv_usec << "us " << std::endl;

    // (bt)clock_gettime(), 精度纳秒,C库函数
    // https://man7.org/linux/man-pages/man2/clock_gettime.2.html
    timespec ts;
    clock_gettime(CLOCK_REALTIME, &ts);
    std::cout << ts.tv_sec << "s " << ts.tv_nsec << "ns " << std::endl;

    // Time Stamp Counter
    // https://stackoverflow.com/questions/42436059/using-time-stamp-counter-to-get-the-time-stamp
    //
    //
}

3.2.3 设计方案

主流做法是使用epoll+clock_gettime。使用一个容器来存储所有过期时间,然后取最近一个要过期的时间设置成epoll_wait的超时时间。

while(1){
    // 找到最早过期的计时器,返回剩余的时间,如果没有则返回0
    time = find_timer();

    // time参数为 0相当于非阻塞, -1为阻塞
    n = epoll_wait(epfd, events, MAXEVENTS, time);

    // 处理超时事件
    handle_expired_timers();

    for(int i=0;i<n ;i++){
        // 处理网络事件
        handle_events();
    }
}

其中容器的结构又有多种方式:

  • 链表
  • 最小堆
  • 时间伦
Type add exec
list O(1) O(n)
min-heap O(lgn) O(1)
time wheel O(1) O(n)
方案2 timerfd

从Linux2.6.25开始,timerfd系列API,带来了一种全新的定时机制。把超时事件转换为了文件描述符,当超时发生后该文件描述符会变成可读。于是超时事件就变成了普通的IO事件。如果未对timerfd设置阻塞,对其read操作会一直阻塞到超时发生。此外timerfd的精度达到了纳秒级。不考虑跨平台等因素,这是一个非常不错的选择。

libevent2.1的源码里也支持timerfd了,在版本说明里也很明确了说明了使用多路复用的超时参数和使用timerfd之间的差异 ,它使用了两个词”efficient”和”precise”,分别表示这种实现之间的差异,我想着这还是非常有说服力的。

每个超时事件独享一个timerfd

如果对于每一个超时事件都用timerfd_create()创建一个对应的fd,放到epoll中统一管理。这样的做法是不合适的。每增加一个定时事件,都需要额外的3个系统调用:

image.png

此外,文件描述符还是稀缺的资源,每个进程能够使用的文件描述符是受系统限制的,如果定时器过多,会造成严重的浪费。

这种方式的定时器,比较容易实现,这里我就不再浪费篇幅了。

所有超时事件共享一个timerfd

libevent就是使用的这种方式。定时时间仍然使用最小堆来保存,每个event loop共享同一个timerfd。每次事件循环之前,取出最近的一个超时的时间,将这个timerfd设置为这个超时时间。

int epoll_dispatch( ...)
{
    ...
    if (epollop->timerfd >= 0)
    {
        struct itimerspec is;
        is.it_value.tv_sec = tv->tv_sec;
        is.it_value.tv_nsec = tv->tv_usec * 1000;
        timerfd_settime(epollop->timerfd, 0, &is, NULL);
    }

    res = epoll_wait(epollop->epfd, events, epollop->nevents, -1);

    for (i = 0; i < res; i++) 
    {
        if (events[i].data.fd == epollop->timerfd){
		    //处理过期了的定时事件
        }
		//处理网络IO事件
    }
}

这样的改进规避了前一种方式提到的造成文件描述符资源浪费的问题,仅仅需要1个额外的文件描述符。

额外的系统调用从额外的3个,降到了1个。而且还有改进的空间,只有当栈顶的timeout变化时,才调用timerfd_settime()改变。

这种方式实现的定时器,精度提高了但是多了1个额外的系统调用。libevent把选择权给了用户,用户可以根据实际情况在创建event base的时候是否配置EVENT_BASE_FLAG_PRECISE_TIMER宏而选择使用哪个定时器实现。

4.Demo

easynet

5.参考文章

时间轮(TimingWheel)高性能定时任务原理解密 - 掘金 (juejin.cn)