四、 定时器(容器)的原理及实现 by TEnth丶

132 阅读10分钟

本章的标题叫定时器,这是行业内常用的叫法。实际上,其确切的叫法是定时器容器。二者常混谈,主要的区别:定时器容器是容器类数据结构,比如 时间轮;定时器则是容器内容纳的一个个对象,它是对定时事件的封装。

使用定时器处理非活动连接模块,实现上分为两部分:其一为定时方法与信号通知流程;其二为定时器及其容器设计、定时任务的处理。 下面我们先介绍信号通知的原理及流程。

4.1 三种定时方法实现

在游双的《Linux高性能服务器编程》中提到:

在讨论如何组织定时器之前,我们先要介绍定时的方法。定时是指在一段时间之后触发某段代码的机制,我们可以在这段代码中依次处理所有到期的定时器。换言之,定时机制是定时器得以被处理的原动力。Linux 提供了三种定时方法,它们是:

  • socket选项SO_RECVTIMEO和SO_SNDTIMEO
  • I/O复用系统调用的超时参数
  • SIGALRM信号(本项目使用的)

socket选项SO_RECVTIMEO和SO_SNDTIMEO

socket选项SO_RCVTIMEO和SO_SNDTIMEO,它们分别用来设置socket接收数据超时时间和发送数据超时时间。因此,这两个选项仅对与数据接收和发送 相关的socket专用系统调用有效,这些系统调用包括send、sendmsg、 recv、 recvmsg、 accept 和connect。

I/O复用系统调用的超时参数

Linux下的3组I/O复用系统调用都带有超时参数,因此它们不仅能统一处理信号和I/O事件,也能统一处理定时事件。但是由于I/O复用系统调用可能在超时时间到期之前就返回(比如有I/O 事件发生),所以如果我们要利用它们来定时,就需要不断更新定时参数以反映剩余的时间。

SIGALRM信号

由alarm和setitimer函数设置的实时闹钟一旦超时,将触发SIGALRM信号。因此,我们可以利用该信号的信号处理函数来处理定时任务。但是,如果要处理多个 定时任务,我们就需要不断地触发SIGALRM信号,并在其信号处理函数中执行到期的任务。一般而言,SIGALRM信号按照固定的频率生成,即由alarm或etitimer函数设置的定时周期T保持不变如果某个定时任务的超时时间不是T的整数倍,那么它实际被执行的时间和预期的时间将略有偏差。因此定时周期T反映了定时的精度。本项目中采用了一种简单的定时器实现——基于升序链表的定时器

定时器通常至少要包含两个成员:一个超时时间(相对时间或者绝对时间)和一个任务回调函数。 有的时候还可能包含回调函数被执行时需要传人的参数,以及是否重启定时器等信息。如果使用链表作为容器来串联所有的定时器,则每个定时器还要包含指向下一个定时器的指针成员。进一步,如果链表是双向的,则每个定时器还需要包含指向前一个定时器的指针成员。

而本项目中,上述升序定时器链表的实际应用主要体现在处理非活动连接。服务器程序通常要定期处理非活动连接:给客户端发一个重连请求,或者关闭该连接,或者其他。Linux在内核中提供了对连接是否处于活动状态的定期检查机制(TCP的心跳机制),我们可以通过socket选项KEEPALIVE来激活它。不过使用这种方式将使得应用程序对连接的管理变得复杂,并且对于TCP的KEETALIVE来说,本身会加重网络中的拥塞程度。因此,我们可以考虑在应用层实现类似KEEPALIVE的机制,以管理所有长时间处于非活动状态的连接。

具体的,利用alarm函数周期性地触发SIGALRM信号,信号处理函数利用管道通知主循环,主循环接收到该信号后对升序链表上所有定时器进行处理,若该段时间内没有交换数据,则将该连接关闭,释放所占用的资源。

4.2 信号处理机制

每个进程之中,都有存着一个表,里面存着每种信号所代表的含义,内核通过设置表项中每一个位来标识对应的信号类型。

(图片来源:CSDN @JMW1407)

信号通知(类似于软中断)

Linux下的信号采用的异步处理机制,信号处理函数和当前进程是两条不同的执行路线。具体的,当进程收到信号时,操作系统会中断进程当前的正常流程,转而进入信号处理函数执行操作,完成后再返回中断的地方继续执行。

为避免信号竞态现象发生,信号处理期间系统不会再次触发它。所以,为确保该信号不被屏蔽太久,信号处理函数需要尽可能快地执行完毕。

一般的信号处理函数需要处理该信号对应的逻辑,当该逻辑比较复杂时,信号处理函数执行时间过长,会导致信号屏蔽太久。

这里的解决方案是,信号处理函数仅仅发送信号通知程序主循环,将信号对应的处理逻辑放在程序主循环中,由主循环执行信号对应的逻辑代码

信号的接收

接收信号的任务是由内核代理的,当内核接收到信号后,会将其放到对应进程的信号队列中(进程PCB主要维护了一个进程描述符,里面有着pid呀,进程状态,所以信号也存在里面),同时向进程发送一个中断,使其陷入内核态。注意,此时信号还只是在队列中,对进程来说暂时是不知道有信号到来的。

信号的检测

  • 进程从内核态返回到用户态前进行信号检测
  • 进程在内核态中,从睡眠状态被唤醒的时候进行信号检测
  • 进程陷入内核态后,有两种场景会对信号进行检测
  • 当发现有新信号时,便会进入下一步,信号的处理。

信号的处理

  • ( 内核 )信号处理函数是运行在用户态的,调用处理函数前,内核会将当前内核栈的内容备份拷贝到用户栈上,并且修改指令寄存器(eip)将其指向信号处理函数。
  • ( 用户 )接下来进程返回到用户态中,执行相应的信号处理函数。
  • ( 内核 )信号处理函数执行完成后,还需要返回内核态,检查是否还有其它信号未处理。
  • ( 用户 )如果所有信号都处理完成,就会将内核栈恢复(从用户栈的备份拷贝回来),同时恢复指令寄存器(eip)将其指向中断前的运行位置,最后回到用户态继续执行进程。

信号通知逻辑

  • 创建管道,其中管道写端写入信号值,管道读端通过I/O复用系统监测读事件
  • 设置信号处理函数SIGALRM(时间到了触发)和SIGTERM(kill会触发,Ctrl+C)
    • 通过struct sigaction结构体和sigaction函数注册信号捕捉函数
    • 在结构体的handler参数设置信号处理函数,具体的,从管道写端写入信号的名字
  • 利用I/O复用系统监听管道读端文件描述符的可读事件
  • 信息值传递给主循环,主循环再根据接收到的信号值执行目标信号对应的逻辑代码

4.3 定时器、定时器容器设计原理及处理方式

定时器设计

将连接资源和定时事件等封装起来,具体包括连接资源、超时时间和回调函数,这里的回调函数指向定时事件。

定时器容器设计

将多个定时器串联组织起来统一处理,具体包括升序链表设计。

项目中的定时器容器为带头尾指针的升序双向链表,具体的为每个连接创建一个定时器,将其添加到链表中,并按照超时时间升序排列。执行定时任务时,将到期的定时器从链表中删除。

从实现上看,主要涉及双向链表的插入,删除操作,其中添加定时器的事件复杂度是O(n) ,删除定时器的事件复杂度是O(1)。

升序双向链表主要逻辑如下:

  • add_timer函数,将目标定时器添加到链表中,添加时按照升序添加
    • 若当前链表中没有节点,直接插入
    • 否则,将定时器按升序插入
  • adjust_timer函数,当定时任务发生变化,调整对应定时器在链表中的位置
    • 客户端在设定时间内有数据收发,则当前时刻对该定时器重新设定时间,这里只是往后延长超时时间
    • 被调整的目标定时器在尾部,或定时器新的超时值仍然小于下一个定时器的超时,不用调整
    • 否则先将定时器从链表取出,重新插入链表
  • del_timer函数将超时的定时器从链表中删除
    • 常规双向链表删除结点

定时任务处理函数

该函数封装在容器类中,具体的,函数遍历升序链表容器,根据超时时间,处理对应的定时器。使用统一事件源,SIGALRM信号每次被触发,主循环中调用一次定时任务处理函数,处理链表容器中到期的定时器。

具体的逻辑如下:

  • 遍历定时器升序链表容器,从头结点开始依次处理每个定时器,直到遇到尚未到期的定时器
  • 若当前时间小于定时器超时时间,跳出循环,即未找到到期的定时器
  • 若当前时间大于定时器超时时间,即找到了到期的定时器,执行回调函数,然后将它从链表中删除,然后继续遍历

整体流程

服务器首先创建定时器容器链表,然后用统一事件源将异常事件、读写事件和信号事件统一处理,根据不同事件的对应逻辑使用定时器。

具体的:

  • 浏览器与服务器连接时,创建该连接对应的定时器,并将该定时器添加到链表上
  • 处理异常事件时,执行定时事件,服务器关闭连接,从链表上移除对应定时器
  • 处理定时信号时,将定时标志设置为true
  • 处理读事件时,若某连接上发生读事件,将对应定时器向后移动,否则,执行定时事件
  • 处理写事件时,若服务器通过某连接给浏览器发送数据,将对应定时器向后移动,否则,执行定时事件