一、概述本项目中定时器的使用
1.1 定时器的作用
本项目定时器主要用于控制客户端的存活时间,定时删除不活跃的文件描述符。(不活跃的文件描述符指的是,客户端在一定时间内没有发送请求报文给服务器,服务器也没有发送响应报文,则服务器认为客户端已经断开连接,需要将其从epoll内核事件表中删除和关闭文件描述符,并删除对应的定时器)
1.2 定时器处理非活跃连接
本项目中定时器处理非活跃连接的实现原理是:
每次有客户端连接到服务器,服务器就为其创建一个定时器,并将其加入到定时器容器中。 但是实际上,本项目只在主线程中开启一个真实的定时器,其他的定时器都是通过定时器容器来模拟的。
具体来说,定时器容器是一个升序链表,链表中的每个节点对应一个客户端的定时器,每个定时器都有一个超时时间变量(绝对时间),主线程中真实的定时器每隔一段时间(倒计时结束)就会遍历定时器容器,检查每个定时器的超时时间是否小于当前时间,如果小于当前时间,说明该客户端节点过期了,就会执行定时器节点的回调函数,回调函数中会关闭对应的文件描述符,并将其从epoll内核事件表中删除。
遍历容器的过程如下所示:
二、定时器容器的实现
定时器容器其实是一种数据结构,不是真实的定时器,它的作用是用于管理所有的定时器节点(处理非活跃连接)。常用的数据结构有双向升序链表、时间轮、时间堆(二叉树)等,本项目中使用的是双向升序链表。
函数指针
在定时器的实现中会大量用到函数指针,所以需要先复习一下函数指针的用法。
函数指针:C++中每个函数的函数名就是一个指针,可以通过函数名获取函数的地址,然后将函数地址赋值给函数指针,通过函数指针调用函数。
函数指针的定义如下:
1 | 返回数据类型 (* 函数指针名)(函数参数列表); |
举个例子,定义一个int test(int a)
函数,然后定义一个函数指针int (*p)(int)
,将test
函数的地址赋值给p
,然后通过p
调用test
函数。
1 |
|
2.1 用户节点的定义client_data
项目中将客户端连接资源信息封装在一个结构体(client_data)中,包括客户端socket地址、文件描述符、定时器类等信息。然后将所有的定时器节点放入一个双向升序链表中。
1 | //前向声明util_timer定时器类 |
用户结构与定时器容器之间的关系:
2.2 节点中的定时器节点类util_timer
在用户结构体中,有一个util_timer类的指针,这个类是定时器类,作为定时器容器中的一个节点,用于管理定时器。由于定时器容器是双向升序链表,所以定时器类中还有前向和后向指针。
定时器类的定义如下:
1 | //定时器节点:双向升序链表的节点 |
由于节点到期后处理的操作是fd移出epoll并关闭socket连接,所以定时器类中的回调函数cb_func
是一个函数指针,指向一个处理函数,用于处理到期后的操作。
1 | //删除epoll中非活动连接的客户端socket、关闭连接 |
2.3 定时器容器(双向升序链表)
项目中为每个用户连接创建一个util_timer
类的定时器节点,并在主程序的Utils
实例中维护一个双向升序链表(sort_timer_lst
),用于管理所有的定时器节点。
跟普通的数据结构一样,双向升序链表也有插入节点、删除节点、调整节点等操作。
- 插入节点:
add_timer
函数将新节点插入到链表中,保持链表的升序性- 如果链表为空,直接作为头-尾节点插入
- 如果链表不为空,遍历链表,找到合适的位置插入
- 删除节点:
del_timer
函数将指定节点从链表中删除 - 调整节点:
adjust_timer
函数将指定节点调整到合适的位置- 当客户端与服务器有数据交互时,需要重启定时器,这时候对应节点的定时器时间会往后延迟过期时间,所以节点在链表中的位置也需要往后调整
- 当被调整的目标节点在链表尾部,或者定时时间仍然小于下一个节点的定时时间时,不用调整
- 否则,将目标节点从链表中删除,重新插入到链表中(
add_timer
函数)
1 | //定时器容器:双向升序链表 |
1 | //添加定时器 |
三、定时实现与信号通信流程
项目中实现定时器到时中断后跟主程序的通信是通过信号和管道实现的。
3.1 管道的创建
管道(pipe)是一种半双工通信方式,传输方式固定只能从写端传到读端,可以实现父子进程之间的通信。在本项目中,主线程(epoll
监听)和信号处理函数(sig_handler
)之间的通信是通过管道实现的。
管道也是一种文件描述符,所以本项目创建一个长度为2的int
型数组,用于存放管道的读写文件描述符。在C++中通过socketpair
函数创建管道。
其中,pipefd[0]
是读端,pipefd[1]
是写端。读端加入到主线程的epoll
监听中,写端在信号处理函数中写入数据。当程序中唯一的定时器到时后,会触发SIGALRM
信号并自动触发信号处理函数sig_handler
,信号处理函数中向管道的写端写入数据,主线程中的epoll
监听到读端有数据,就会对定时器容器进行遍历,处理到期的定时器。
3.1.1 socketpair函数创建管道
在Linux中,使用socketpair
函数创建一对无名套接字,并将套接字的文件描述符存放在int
型数组中。函数原型如下:
1 |
|
domain
:协议族,可以是PF_UNIX
(UNIX域协议族)或PF_INET
(IPv4协议族)type
:套接字类型,可以是SOCK_STREAM
(字节流TCP套接字)或SOCK_DGRAM
(数据报UDP套接字)protocol
:协议类型,只能为0sv[2]
:存放套接字文件描述符的数组(sv[0]
是读端,sv[1]
是写端;对应本项目中的pipefd[2]
)- 返回值
ret
:成功返回0,失败返回-1
本项目中前面学过的epoll
实现中,已经在Utils
工具类中封装了关于epoll
添加文件描述符的函数addfd
以及设置文件描述符阻塞方式的函数setnonblocking
,所以通过socketpair
创建管道后,将读端加入到epoll
监听中直接调用该函数即可。
将管道文件描述符设置为非阻塞,是为了避免管道套接字缓冲区写满了,阻塞导致异步执行的信号处理函数sig_handler
执行时间过长影响主线程的正常工作。
在webserver.cpp
中创建管道的代码如下(eventListen
函数):
1 | //通过socketpair创建全双工管道,管道也是一种文件描述符 |
3.1.2 管道中传递的信号值
项目中管道中传递的数据是信号值,即SIGALRM
和SIGTERM
信号的值。其中,SIGALRM
信号代表定时器到时,SIGTERM
信号代表服务器关闭(用户在终端执行了Ctrl+C
)。
这两个信号在库函数中有定义,可以直接使用。SIGALRM
的值是14,SIGTERM
的值是15。
1 |
3.2 信号通信流程
在Linux中,信号是一种异步通知机制,用于通知进程发生了某种事件。信号是由内核或其他进程发送给目标进程的,目标进程在接收到信号后会中断当前的正常流程,执行信号处理函数。
需要先将本项目中关注的两种信号SIGALRM
和SIGTERM
的信号处理函数sig_handler
注册到系统中,然后在信号处理函数中实现对应的功能。
注册函数:项目中在lst_timer.cpp
中定义了信号处理函数sig_handler
的实现,其中信号处理函数sig_handler
只简单地向管道的写端写入信号值。后续的操作交由主线程去处理,这样能保证异步处理不耗时的工作,防止影响主线程。
3.2.1 addsig函数:注册绑定信号-信号处理函数
C++中信号注册主要通过sigaction
结构体对信号属性进行封装设置,然后通过sigaction()
函数注册信号处理函数。
sigaction
结构体定义如下:
1 | struct sigaction { |
其中,结构体中的信号处理函数sa_handler
就是后面还会讲到的sig_handler
函数。
sa_mask
是一个信号集合,用于在信号处理函数执行期间阻塞的信号,防止信号处理函数执行过程中被其他信号打断。
sigaction
函数原型如下:
1 |
|
signum
:注册的信号值,即SIGALRM
和SIGTERM
act
:新的信号处理方式(属性),即sigaction
结构体oldact
:旧的信号处理方式(属性),用于保存之前的信号处理方式,如果不关心可以传入nullptr
因此本项目实现的addsig
信号注册函数如下:
1 | //添加绑定信号函数 |
3.2.2 sig_handler函数:信号处理函数
当内核检测到信号发生时,检测signal位图信息(也就是前面注册过的),然后通知用户态调用对应的信号处理函数。
具体流程如下:
由上图可知,Linux下信号采用异步机制,信号处理函数和当前进程是两条不同的执行路线。
在注册时我们选择了屏蔽方式,所以为了确保信号不会被屏蔽太久,本项目中信号处理函数仅仅通过管道发送信号值,不处理信号对应的逻辑(由主程序处理),缩短异步执行时间,减少对主程序的影响。
- 内核的工作
- 内核检测和接收信号,同时向用户进程发送一个中断,使其进入内核态
- 当信号处理函数执行完毕后,还会返回内核态,检查是否还有其它信号未处理
- 用户态的工作
- 用户进程接收内核的中断
- 进入信号处理函数,执行信号处理函数的逻辑
- 所有的信号处理完毕后,返回用户态,继续执行用户进程的正常流程(恢复到中断前运行的位置)
1 | //信号处理函数:处理信号SIGALRM-SIGTERM |
3.2.3 主程序中注册信号
在webserver.cpp
中的eventListen
函数中,注册了两个信号SIGALRM
和SIGTERM
,并绑定了信号处理函数sig_handler
。
另外,我们除了SIGALRM
和SIGTERM
信号外,还注册了SIGPIPE
信号,将其处理方式设置为SIG_IGN
,即忽略SIGPIPE
信号。SIGPIPE
信号是在读取已关闭的管道时产生的,如果不处理SIGPIPE
信号,当读取已关闭的管道时会导致程序退出。
1 | //绑定不同信号(SIGPIPE-SIGALRM-SIGTERM)的信号处理函数(忽略 or sig_handler发送sig标识) |
同时在eventListen
函数中开启唯一的定时器,通过alarm
函数设置定时器的超时时间,当定时器到时后会发送注册过的SIGALRM
信号后,触发信号处理函数sig_handler
。
1 | //启动定时器,每TIMESLOT秒发送SIGALRM信号(整个程序中只有一个真实的定时器,定时器容器中的是存储超时的绝对时间来与这个唯一的timeout处理进行比较) |
四、完整的定时器使用流程(主循环中)
首先,服务端开启时,创建一个定时器容器,并创建一个全双工管道,将管道的读端加入到epoll
监听中。注册两个信号SIGALRM
和SIGTERM
,并绑定信号处理函数sig_handler
。
然后,开启唯一的定时器,通过alarm
函数设置唯一真实定时器的超时时间,当定时器到时后会发送注册过的SIGALRM
信号后,触发信号处理函数sig_handler
将信号值写入管道发送给主线程,由主线程决定执行什么操作。
之后,主线程epoll管道读端监听到有管道数据,会调用dealwithsignal
函数解析信号值,根据信号值的不同重置timeout
orstop_server
标识符(处理定时器操作or关闭服务器)。
最后,根据用户的连接请求具体地实现定时器使用:
- 当客户端与服务器连接时(连接事件),为其创建一个用户结构(结构体中包含定时器节点,并将定时器节点加入到定时器容器中)
- 当客户端与服务器有数据交互时(读/写事件),需要重置该定时器节点,调整定时器在链表中的位置
- 当定时器到时后,处理定时信号,将
timeout
标志设置为true
,在主线程中遍历定时器容器,处理删除到期的定时器节点
除了SIGALRM
信号外,我们还注册了SIGTERM
信号,这里顺便讲一下,当管道读端接收到SIGTERM
信号时,主线程会将stop_server
标志设置为true
,退出eventLoop
的while
循环,关闭服务器。
主线程循环中epoll监听到管道读端有数据
1 | //主循环:epoll_wait阻塞监听事件 |
针对timeout标志的处理
定时器到时后,调用timer_handler
函数处理链表上到期的节点,处理完后重开定时器。
具体实现如下:
1 | //主函数发现定时器超时,调用该函数查找超时定时器并处理 |
其中tick
函数是定时器容器中的一个函数,用于处理链表上到期的节点。由于容器是升序的,所以当找到第一个未到期的节点时,就可以结束遍历。
1 | //SIGALRM信号每次被触发,主循环管道读端监测出对应的超时信号后就会调用timer_handler进而调用定时器容器中通过tick函数查找并处理超时定时器 |
对于需要删除的非活跃连接,执行定时器节点中的回调函数cb_func
,在回调函数中关闭对应的文件描述符,并将其从epoll内核事件表中删除。执行完回调函数后就可以
在容器中delete
删除该定时器节点了。
五、总结
本文完成了Webserver项目中通过定时器实现了对非活跃连接的客户端的处理,主要知识点有管道、信号机制、定时器容器等。
完成了定时器,项目基本已经完善了,但是为了对服务器运行状态进行监控维护,最后还需要添加日志系统,下一篇文章将会讲解日志系统的实现。具体内容请看下一篇博客WebServer学习8:通用日志系统的设计