https://mp.weixin.qq.com/s/mmXLqh_NywhBXJvI45hchA

# 基础知识

  1. 非活跃 ,是指客户端(这里是浏览器)与服务器端建立连接后,长时间不交换数据,一直占用服务器端的文件描述符,导致连接资源的浪费。
  2. 定时事件:是指固定一段时间之后触发某段代码,由该段代码处理一个事件,如从内核事件表删除事件,并关闭文件描述符,释放连接资源。
  3. 定时器 ,是指利用结构体或其他形式,** 将多种定时事件进行封装起来。** 具体的,这里只涉及一种定时事件,即定期检测非活跃连接,这里将该定时事件与连接资源封装为一个结构体定时器。
  4. 定时器容器 ,是指使用某种容器类数据结构,将上述多个定时器组合起来,便于对定时事件统一管理。具体的,项目中使用升序链表将所有定时器串联组织起来。
  5. 基础 API,描述 sigaction 结构体、 sigaction 函数、 sigfillset 函数、 SIGALRM 信号、 SIGTERM 信号、 alarm 函数、 socketpair 函数、 send 函数。

# alarm 函数

#include <unistd.h>;
unsigned int alarm(unsigned int seconds);

设置信号传送闹钟,即用来设置信号 SIGALRM 在经过参数 seconds 秒数后发送给目前的进程。如果未设置信号 SIGALRM 的处理函数,那么 alarm () 默认处理终止进程.

# sigfillset 函数

1#include <signal.h>
2int sigfillset(sigset_t *set);

用来将参数 set 信号集初始化,然后把所有的信号加入到此信号集里。

# SIGALRM、SIGTERM 信号

1#define SIGALRM  14     //**由alarm系统调用产生timer时钟信号**
2#define SIGTERM  15     //终端发送的终止信号

# alarm 函数

1#include <unistd.h>;
2unsigned int alarm(unsigned int seconds);

设置信号传送闹钟,即用来设置信号 SIGALRM 在经过参数 seconds 秒数后发送给目前的进程。如果未设置信号 SIGALRM 的处理函数,那么 alarm () 默认处理终止进程.

# socketpair 函数

在 linux 下,使用 socketpair 函数能够创建一对套接字进行通信,项目中使用管道通信

#include <sys/types.h>
#include <sys/socket.h>
int socketpair(int domain, int type, int protocol, int sv[2]);
  • domain 表示协议族,PF_UNIX 或者 AF_UNIX
  • type 表示协议,可以是 SOCK_STREAM 或者 SOCK_DGRAM,SOCK_STREAM 基于 TCP,SOCK_DGRAM 基于 UDP
  • protocol 表示类型,只能为 0
  • sv [2] 表示套节字柄对,该两个句柄作用相同,均能进行读写双向操作
  • 返回结果, 0 为创建成功,-1 为创建失败

# send 函数

#include <sys/types.h>
#include <sys/socket.h>
ssize_t send(int sockfd, const void *buf, size_t len, int flags);

当套接字发送缓冲区变满时,send 通常会阻塞,除非套接字设置为非阻塞模式,当缓冲区变满时,返回 EAGAIN 或者 EWOULDBLOCK 错误,此时可以调用 select 函数来监视何时可以发送数据。


# 整体流程

   如果某一用户`connect()`到服务器之后,长时间不交换数据,一直占用服务器端的文件描述符,导致连接资源的浪费。这时候就应该利用定时器把这些超时的非活动连接释放掉,关闭其占用的文件描述符。这种情况也很常见,当你登录一个网站后长时间没有操作该网站的网页,再次访问的时候你会发现需要重新登录。

   本项目中,**服务器主循环为每一个连接创建一个定时器**,并对每个连接进行定时。另外,利用**升序时间链表容器**将所有定时器**串联**起来,若主循环接收到定时通知,则在链表中依次执行定时任务。

项目中使用的是 ** SIGALRM信号 * 来实现定时器 *,利用 alarm * 函数周期性的触发 * SIGALRM * 信号 *,* 信号处理函数利用管道通知主循环 *,当主循环在读端 pipefd[0] 读到这个信号的的时候,就会将 timeout 变量置为 true 并跳出循环,让 timer_handler() 函数取出来定时器容器上的到期任务,该定时器容器是通过升序链表来实现的,* 从头到尾对检查任务是否超时,若超时则调用定时器的回调函数 * cb_func() *,关闭该 socket 连接,并删除其对应的定时器 * del_timer **,释放所占用的资源。

  • 执行自定义的信号函数 addsig:其中定义了信号捕获函数(当捕获到 SIGALRM 和 SIGTERM 信号则执行信号处理函数:传递给主循环的信号值

alarm 函数会定期触发 SIGALRM 信号,这个信号交由 sig_handler 来处理,每当监测到有这个信号的时候,都会将这个信号写到 pipefd[1] 里面,传递给主循环:

定时器优化这个基于升序双向链表实现的定时器存在着其固有缺点:

  • 每次遍历添加和修改定时器的效率偏低 (O (n)),使用最小堆结构可以降低时间复杂度降至 (O (logn))。
  • 每次以固定的时间间隔触发 SIGALRM 信号,调用 tick 函数处理超时连接会造成一定的触发浪费,举个例子,若当前的 TIMESLOT=5 ,即每隔 5ms 触发一次 SIGALRM ,跳出循环执行 tick 函数,这时如果当前即将超时的任务距离现在还有 20ms ,那么在这个期间, SIGALRM 信号被触发了 4 次, tick 函数也被执行了 4 次,可是在这 4 次中,前三次触发都是无意义的。对此,我们可以动态的设置 TIMESLOT 的值,每次将其值设置为当前最先超时的定时器与当前时间的时间差,这样每次调用 tick 函数,超时时间最小的定时器必然到期,并被处理,然后在从时间堆中取一个最先超时的定时器的时间与当前时间做时间差,更新 TIMESLOT 的值。

# 定时器处理非活动连接

由于非活跃连接占用了连接资源,严重影响服务器的性能,通过实现一个服务器定时器,处理这种非活跃连接,释放连接资源。利用 alarm 函数周期性地触发 SIGALRM 信号,该信号的信号处理函数利用管道通知主循环执行定时器链表上的定时任务.

  • 统一事件源
  • 基于升序链表的定时器
  • 处理非活动连接

sort_timer_lst 是一个升序链表,其核心函数 tick 每隔一段时间就执行一次,以检测并处理到期的任务。判断定时器到期的依据是定时器的 expire 值小于当前的系统时间。

# 处理非活跃连接

在应用层实现 KEEPALIVE 机制,以管理所有长期处于非活跃状态的连接。

利用 alarm 函数周期性地触发 SIGALRM 信号,该信号的信号处理函数利用管道通知主循环执行定时器链表上的定时任务→关闭非活跃连接

# 信号通知逻辑

  • 创建管道,其中管道写端写入信号值,管道读端通过 I/O 复用系统监测读事件

  • 设置

    信号处理函数

    SIGALRM(时间到了触发)和 SIGTERM(kill 会触发,Ctrl+C)

    • 通过 struct sigaction 结构体和 sigaction 函数注册信号捕捉函数
    • 在结构体的 handler 参数设置信号处理函数,具体的,从管道写端写入信号的名字
  • 利用 I/O 复用系统监听管道读端文件描述符的可读事件

  • 信息值传递给主循环,主循环再根据接收到的信号值执行目标信号对应的逻辑代码

# 基于升序链表的定时器

定时器至少包含:超时时间和任务回调函数 使用链表作为容器串联所有定时器,则每个定时器还要包含下一个和上一个定时器的指针成员。


Linux 下提供了三种定时的方法:

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

项目中使用的是 SIGALRM 信号:

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

从上面的简要描述中,可以看出定时器处理非活动连接模块,主要分为两部分,其一为定时方法与信号通知流程,其二为定时器及其容器设计与定时任务的处理。

# 信号通知流程

介绍统一事件源和信号处理机制。

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

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

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

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

# 统一事件源

统一事件源,是指将信号事件与其他事件一样被处理。

具体的,信号处理函数使用管道将信号传递给主循环,信号处理函数往管道的写端写入信号值,主循环则从管道的读端读出信号值,使用 I/O 复用系统调用来监听管道读端的可读事件,这样信号事件与其他文件描述符都可以通过 epoll 来监测,从而实现统一处理。

# 信号处理机制

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

.png)

  • 信号的接收
    • 接收信号的任务是由内核代理的,当内核接收到信号后,会将其放到对应进程的信号队列中,同时向进程发送一个中断,使其陷入内核态。注意,此时信号还只是在队列中,对进程来说暂时是不知道有信号到来的。
  • 信号的检测
    • 进程从内核态返回到用户态前进行信号检测
    • 进程在内核态中,从睡眠状态被唤醒的时候进行信号检测
    • 进程陷入内核态后,有两种场景会对信号进行检测:
    • 当发现有新信号时,便会进入下一步,信号的处理。
  • 信号的处理
  • ( 内核 ) 信号处理函数是运行在用户态的,调用处理函数前,内核会将当前内核栈的内容备份拷贝到用户栈上,并且修改指令寄存器(eip)将其指向信号处理函数。
  • ( 用户 ) 接下来进程返回到用户态中,执行相应的信号处理函数。
  • ( 内核 ) 信号处理函数执行完成后,还需要返回内核态,检查是否还有其它信号未处理。
  • ( 用户 ) 如果所有信号都处理完成,就会将内核栈恢复(从用户栈的备份拷贝回来),同时恢复指令寄存器(eip)将其指向中断前的运行位置,最后回到用户态继续执行进程。

至此,一个完整的信号处理流程便结束了,如果同时有多个信号到达,上面的处理流程会在第 2 步和第 3 步骤间重复进行。

# 代码分析

# 信号处理函数

自定义信号处理函数,创建 sigaction 结构体变量,设置信号函数。

  • 信号处理函数中仅仅通过管道发送信号值,不处理信号对应的逻辑,缩短异步执行时间,减少对主程序的影响。
1//信号处理函数 
2void sig_handler(int sig) 
3{ 
4    //为保证函数的可重入性,保留原来的errno 
5    //可重入性表示中断后再次进入该函数,环境变量与之前相同,不会丢失数据 6    int save_errno = errno; 
7    int msg = sig; 
8 
9    //将信号值从管道写端写入,传输字符类型,而非整型
10    send(pipefd[1], (char *)&msg, 1, 0);
11
12    //将原来的errno赋值为当前的errno
13    errno = save_errno;
14}

项目中设置信号函数,仅关注 SIGTERM 和 SIGALRM 两个信号。

void Utils::addsig(int sig, void(handler)(int), bool restart)
{//创建sigaction结构体变量
    struct sigaction sa;
    memset(&sa, '\0', sizeof(sa));//memset是一个初始化函数,作用是将某一块内存中的全部设置为指定的值。
    //信号处理函数中仅仅发送信号值,不做对应信号处理逻辑
    sa.sa_handler = handler;
    if (restart)
    sa.sa_flags |= SA_RESTART;
    //将所有信号添加到信号集中
    sigfillset(&sa.sa_mask);
    //执行sigaction函数
    assert(sigaction(sig, &sa, NULL) != -1);
}

# 小根堆定时器

该定时器容器的思路是:将所有定时器中超时时间最小的一个定时器的超时值作为触发 SIGALRM 信号的时间间隔,这样,一旦心搏函数 tick 被调用,超时时间最小的定时器必然到期,我们就可以在 tick 函数中处理该定时器。然后,再从剩余的定时器中找到超时时间最小的一个,并将这段最小时间设置为下一次心搏间隔,如此反复,就实现了较为精确的定时。

Linux 下的 3 组 I/O 复用系统调用(select、poll、epoll)都带有定时参数,因此他们不仅能统一处理信号和 I/O 事件,也能统一处理定时事件。我们可以使用定时容器和 I/O 复用系统调用来共同实现定时器的触发。

这三个系统调用都有一个 timeout 的参数,当发生 I/O 事件时,这三个系统调用将会返回; 当指定的时间到达时,如果没有 I/O 事件发生,这三个系统调用也会返回