基础知识
非活跃
,是指客户端(这里是浏览器)与服务器端建立连接后,长时间不交换数据,一直占用服务器端的文件描述符,导致连接资源的浪费。
定时事件
,是指固定一段时间之后触发某段代码,由该段代码处理一个事件,如从内核事件表删除事件,并关闭文件描述符,释放连接资源。
定时器
,是指利用结构体或其他形式,将多种定时事件进行封装起来。具体的,这里只涉及一种定时事件,即定期检测非活跃连接,这里将该定时事件与连接资源封装为一个结构体定时器。
定时器容器
,是指使用某种容器类数据结构,将上述多个定时器组合起来,便于对定时事件统一管理。具体的,项目中使用升序链表将所有定时器串联组织起来。
整体概述
本项目中,服务器主循环为每一个连接创建一个定时器,并对每个连接进行定时。另外,利用升序时间链表容器将所有定时器串联起来,若主循环接收到定时通知,则在链表中依次执行定时任务。
Linux
下提供了三种定时的方法:
- socket选项SO_RECVTIMEO和SO_SNDTIMEO
- SIGALRM信号
- I/O复用系统调用的超时参数
三种方法没有一劳永逸的应用场景,也没有绝对的优劣。由于项目中使用的是SIGALRM
信号,这里仅对其进行介绍,另外两种方法可以查阅游双的Linux高性能服务器编程 第11章 定时器
。
在Linux环境下,有几种不同的方法可以实现定时或超时的功能,主要涉及到网络编程和系统调用。以下是您提到的三种方法的详细分析:
1. Socket选项SO_RECVTIMEO和SO_SNDTIMEO
这两个Socket选项用于设置接收(SO_RECVTIMEO
)和发送(SO_SNDTIMEO
)操作的超时时间。这种方法直接在socket层面控制数据的接收和发送,使得操作在指定的时间内没有完成时能够自动返回,不会无限期地等待。设置这些选项的典型代码如下:
struct timeval timeout;
timeout.tv_sec = 10; // 10秒
timeout.tv_usec = 0; // 0微秒
// 设置接收超时
setsockopt(socket_fd, SOL_SOCKET, SO_RECVTIMEO, (const char*)&timeout, sizeof(timeout));
// 设置发送超时
setsockopt(socket_fd, SOL_SOCKET, SO_SNDTIMEO, (const char*)&timeout, sizeof(timeout));
这种方法对于避免网络延迟导致的程序卡死非常有效,特别是在网络条件不稳定的环境中。
2. SIGALRM信号
SIGALRM
是一个由alarm函数生成的信号,常用于在程序中实现定时功能。你可以设定一个计时器,在指定的秒数后,系统会向当前进程发送SIGALRM信号,触发信号处理函数执行。以下是使用SIGALRM
的一个基本示例:
#include <signal.h>
#include <unistd.h>
#include <stdio.h>
void handle_sigalrm(int sig) {
printf("Alarm triggered!\n");
}
int main() {
signal(SIGALRM, handle_sigalrm); // 设置SIGALRM的处理函数
alarm(5); // 设置定时器为5秒
pause(); // 暂停,等待任何信号
return 0;
}
这种方法适用于需要在一段时间后执行特定任务的场景,但在多线程环境下需要特别小心,因为信号的处理可能会中断其他线程的执行。
3. I/O复用系统调用的超时参数
I/O复用是一种效率较高的在单一进程或线程中处理多个I/O流的技术。Linux提供了如select
、poll
和epoll
等系统调用来实现I/O复用。这些系统调用允许用户指定一个超时时间,系统会在超时时间内阻塞等待,如果在这段时间内有I/O就绪,则返回就绪的I/O数量,否则返回0。以下是使用select
的一个示例:
#include <sys/select.h>
int main() {
struct timeval tv;
tv.tv_sec = 10; // 10秒
tv.tv_usec = 0; // 0微秒
fd_set readfds;
FD_ZERO(&readfds);
FD_SET(STDIN_FILENO, &readfds);
// 等待输入或超时
int ret = select(STDIN_FILENO + 1, &readfds, NULL, NULL, &tv);
if (ret == -1) {
perror("select error");
} else if (ret == 0) {
printf("Timeout occurred! No data within ten seconds.\n");
} else {
printf("Data is available now.\n");
}
return 0;
}
这种方法适合在需要同时监视多个文件描述符的情况下使用,例如网络服务器中处理多个客户端连接。
每种方法都有其适用场景和限制,选择哪一种取决于具体的需求和上下文。在实际的系统设计和开发中,可能需要结合使用这些方法以达到最佳效果。
具体的,利用alarm
函数周期性地触发SIGALRM
信号,信号处理函数利用管道通知主循环,主循环接收到该信号后对升序链表上所有定时器进行处理,若该段时间内没有交换数据,则将该连接关闭,释放所占用的资源。
从上面的简要描述中,可以看出定时器处理非活动连接模块,主要分为两部分,其一为定时方法与信号通知流程,其二为定时器及其容器设计与定时任务的处理。
基础API
为了更好的源码阅读体验,这里提前对代码中使用的一些API进行简要介绍,更丰富的用法可以自行查阅资料。
sigaction结构体
struct sigaction {
void (*sa_handler)(int);
void (*sa_sigaction)(int, siginfo_t *, void *);
sigset_t sa_mask;
int sa_flags;
void (*sa_restorer)(void);
}
- sa_handler是一个函数指针,指向信号处理函数
- sa_sigaction同样是信号处理函数,有三个参数,可以获得关于信号更详细的信息
- sa_mask用来指定在信号处理函数执行期间需要被屏蔽的信号
- sa_flags用于指定信号处理的行为
-
- SA_RESTART,使被信号打断的系统调用自动重新发起
- SA_NOCLDSTOP,使父进程在它的子进程暂停或继续运行时不会收到 SIGCHLD 信号
- SA_NOCLDWAIT,使父进程在它的子进程退出时不会收到 SIGCHLD 信号,这时子进程如果退出也不会成为僵尸进程
- SA_NODEFER,使对信号的屏蔽无效,即在信号处理函数执行期间仍能发出这个信号
- SA_RESETHAND,信号处理之后重新设置为默认的处理方式
- SA_SIGINFO,使用 sa_sigaction 成员而不是 sa_handler 作为信号处理函数
- sa_restorer一般不使用
sigaction函数
#include <signal.h>
int sigaction(int signum, const struct sigaction *act, struct sigaction *oldact);
- signum表示操作的信号。
- act表示对信号设置新的处理方式。
- oldact表示信号原来的处理方式。
- 返回值,0 表示成功,-1 表示有错误发生。
sigfillset函数
#include <signal.h>
int sigfillset(sigset_t *set);
用来将参数set信号集初始化,然后把所有的信号加入到此信号集里。
SIGALRM、SIGTERM信号
#define SIGALRM 14 //由alarm系统调用产生timer时钟信号
#define SIGTERM 15 //终端发送的终止信号
alarm函数
#include <unistd.h>;
unsigned 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函数来监视何时可以发送数据。
信号通知流程
Linux下的信号采用的异步处理机制,信号处理函数和当前进程是两条不同的执行路线。具体的,当进程收到信号时,操作系统会中断进程当前的正常流程,转而进入信号处理函数执行操作,完成后再返回中断的地方继续执行。
为避免信号竞态现象发生,信号处理期间系统不会再次触发它。所以,为确保该信号不被屏蔽太久,信号处理函数需要尽可能快地执行完毕。
一般的信号处理函数需要处理该信号对应的逻辑,当该逻辑比较复杂时,信号处理函数执行时间过长,会导致信号屏蔽太久。
这里的解决方案是,信号处理函数仅仅发送信号通知程序主循环,将信号对应的处理逻辑放在程序主循环中,由主循环执行信号对应的逻辑代码。
统一事件源
统一事件源,是指将信号事件与其他事件一样被处理。
具体的,信号处理函数使用管道将信号传递给主循环,信号处理函数往管道的写端写入信号值,主循环则从管道的读端读出信号值,使用I/O复用系统调用来监听管道读端的可读事件,这样信号事件与其他文件描述符都可以通过epoll来监测,从而实现统一处理。
信号处理机制
每个进程之中,都有存着一个表,里面存着每种信号所代表的含义,内核通过设置表项中每一个位来标识对应的信号类型。
- 信号的接收
-
- 接收信号的任务是由内核代理的,当内核接收到信号后,会将其放到对应进程的信号队列中,同时向进程发送一个中断,使其陷入内核态。注意,此时信号还只是在队列中,对进程来说暂时是不知道有信号到来的。
- 信号的检测
-
- 进程从内核态返回到用户态前进行信号检测
- 进程在内核态中,从睡眠状态被唤醒的时候进行信号检测
- 进程陷入内核态后,有两种场景会对信号进行检测:
- 当发现有新信号时,便会进入下一步,信号的处理。
- 信号的处理
-
- ( 内核 )信号处理函数是运行在用户态的,调用处理函数前,内核会将当前内核栈的内容备份拷贝到用户栈上,并且修改指令寄存器(eip)将其指向信号处理函数。
- ( 用户 )接下来进程返回到用户态中,执行相应的信号处理函数。
- ( 内核 )信号处理函数执行完成后,还需要返回内核态,检查是否还有其它信号未处理。
- ( 用户 )如果所有信号都处理完成,就会将内核栈恢复(从用户栈的备份拷贝回来),同时恢复指令寄存器(eip)将其指向中断前的运行位置,最后回到用户态继续执行进程。
- 至此,一个完整的信号处理流程便结束了,如果同时有多个信号到达,上面的处理流程会在第2步和第3步骤间重复进行。
代码分析
信号处理函数
自定义信号处理函数,创建sigaction结构体变量,设置信号函数。
// Utils类的信号处理函数
void Utils::sig_handler(int sig) {
// 为保证函数的可重入性,保留原来的errno
int save_errno = errno;
// 将信号类型存储在消息中
int msg = sig;
// 向管道写入信号消息,用于通知主循环
send(u_pipefd[1], (char*)&msg, 1, 0);
// 恢复原来的errno,保持函数可重入性
errno = save_errno;
}
信号处理函数中仅仅通过管道发送信号值,不处理信号对应的逻辑,缩短异步执行时间,减少对主程序的影响。
// 注册信号处理函数
/**
* @param sig 需要注册的信号
* @param handler 信号处理函数
* @param restart 是否在信号处理完成后重新启动被信号中断的系统调用
*
* 该函数通过sigaction接口注册信号及其处理函数,相比直接使用signal函数,sigaction更加强大和灵活
* 它可以设置在接收到信号后是否重新启动系统调用(通过SA_RESTART标志实现)
* 同时,sa_mask用于阻塞除了要注册的信号外的所有其他信号,以确保信号处理过程中的安全性
* 断言用于确保信号注册成功,否则程序将中断执行,从而保证了程序的健壮性
*/
void Utils::addsig(int sig, void(handler)(int), bool restart) {
struct sigaction sa;
memset(&sa, '\0', sizeof(sa)); // 初始化sigaction结构体
sa.sa_handler = handler; // 设置信号处理函数
if (restart) {
sa.sa_flags |= SA_RESTART; // 设置SA_RESTART标志,以支持信号处理完成后自动重启被中断的系统调用
}
sigfillset(&sa.sa_mask); // 设置掩码,阻塞所有信号,除了要注册的信号
assert(sigaction(sig, &sa, nullptr) != -1); // 注册信号处理函数,确保注册成功
}
项目中设置信号函数,仅关注SIGTERM和SIGALRM两个信号。
信号通知逻辑
- 创建管道,其中管道写端写入信号值,管道读端通过I/O复用系统监测读事件
- 设置信号处理函数SIGALRM(时间到了触发)和SIGTERM(kill会触发,Ctrl+C)
-
- 通过struct sigaction结构体和sigaction函数注册信号捕捉函数
- 在结构体的handler参数设置信号处理函数,具体的,从管道写端写入信号的名字
- 利用I/O复用系统监听管道读端文件描述符的可读事件
- 信息值传递给主循环,主循环再根据接收到的信号值执行目标信号对应的逻辑代码
void WebServer::eventListen() {
// 创建监听套接字
m_listenfd = socket(PF_INET, SOCK_STREAM, 0);
assert(m_listenfd >= 0); // 确保套接字创建成功
// 根据m_OPT_LINGER的值设置套接字的linger选项
if (0 == m_OPT_LINGER) {
struct linger tmp = {0, 1};
setsockopt(m_listenfd, SOL_SOCKET, SO_LINGER, &tmp, sizeof(tmp));
}
else if (1 == m_OPT_LINGER) {
struct linger tmp = {1, 1};
setsockopt(m_listenfd, SOL_SOCKET, SO_LINGER, &tmp, sizeof(tmp));
}
// 准备绑定的地址结构
struct sockaddr_in address;
bzero(&address, sizeof(address));
address.sin_family = AF_INET;
address.sin_addr.s_addr = htonl(INADDR_ANY);
address.sin_port = htons(m_port);
// 设置套接字选项,允许地址复用
int flag = 1;
setsockopt(m_listenfd, SOL_SOCKET,SO_REUSEADDR, &flag,sizeof(flag));
// 将套接字绑定到地址
int ret = bind(m_listenfd, (struct sockaddr*)&address, sizeof(address));
assert(ret >= 0); // 确保绑定成功
// 开始监听连接
ret = listen(m_listenfd, 5);
assert(ret >= 0); // 确保监听成功
// 初始化utils工具类
utils.init(TIMESLOT);
// 创建epoll事件表
epoll_event events[MAX_EVENT_NUMBER];
m_epollfd = epoll_create(5);
assert(m_epollfd != -1); // 确保epoll创建成功 f
// 将监听套接字添加到epoll事件表
utils.addfd(m_epollfd, m_listenfd, false, m_LISTENTrigmode);
http_conn::m_epollfd = m_epollfd;
// 创建管道用于epoll的边缘触发
ret= socketpair(PF_UNIX, SOCK_STREAM, 0, m_pipefd);
assert(ret != -1); // 确保管道创建成功
utils.setnonblocking(m_pipefd[1]);
utils.addfd(m_epollfd, m_pipefd[0], false, 0);
// 添加信号处理
utils.addsig(SIGPIPE, SIG_IGN);
utils.addsig(SIGALRM, utils.sig_handler, false);
utils.addsig(SIGTERM, utils.sig_handler, false);
// 设置定时器
alarm(TIMESLOT);
// 为工具类Utils设置管道和epollfd
Utils::u_pipefd = m_pipefd;
Utils::u_epollfd = m_epollfd;
}
为什么管道写端要非阻塞?
send是将信息发送给套接字缓冲区,如果缓冲区满了,则会阻塞,这时候会进一步增加信号处理函数的执行时间,为此,将其修改为非阻塞。
没有对非阻塞返回值处理,如果阻塞是不是意味着这一次定时事件失效了?
是的,但定时事件是非必须立即处理的事件,可以允许这样的情况发生。
管道传递的是什么类型?switch-case的变量冲突?
信号本身是整型数值,管道中传递的是ASCII码表中整型数值对应的字符。
switch的变量一般为字符或整型,当switch的变量为字符时,case中可以是字符,也可以是字符对应的ASCII码。
定时器处理非活动连接模块,主要分为两部分,其一为定时方法与信号通知流程,其二为定时器及其容器设计、定时任务的处理。
本篇对第二部分进行介绍,具体的涉及到定时器设计、容器设计、定时任务处理函数和使用定时器。
定时器设计
,将连接资源和定时事件等封装起来,具体包括连接资源、超时时间和回调函数,这里的回调函数指向定时事件。
定时器容器设计
,将多个定时器串联组织起来统一处理,具体包括升序链表设计。
定时任务处理函数
,该函数封装在容器类中,具体的,函数遍历升序链表容器,根据超时时间,处理对应的定时器。
代码分析-使用定时器
,通过代码分析,如何在项目中使用定时器。
定时器设计
项目中将连接资源、定时事件和超时时间封装为定时器类,具体的,
- 连接资源包括客户端套接字地址、文件描述符和定时器
- 定时事件为回调函数,将其封装起来由用户自定义,这里是删除非活动socket上的注册事件,并关闭
- 定时器超时时间 = 浏览器和服务器连接时刻 + 固定时间(TIMESLOT),可以看出,定时器使用绝对时间作为超时值,这里alarm设置为5秒,连接超时为15秒。
//连接资源结构体成员需要用到定时器类
class util_timer;
// 用于保存客户端相关数据的结构体
struct client_data
{
sockaddr_in address; // 客户端的socket地址
int sockfd; // socket文件描述符
util_timer* timer; // 指向定时器的指针
};
// 定时器类
class util_timer {
public:
util_timer(): prev(nullptr), next(nullptr) {} // 构造函数,初始化前后指针为nullptr
public:
time_t expire; // 定时器的超时时间,使用时间戳表示
void (*cb_func)(client_data *); // 定时器超时后的回调函数
client_data* user_data; // 用户数据
util_timer* prev; // 指向前一个定时器
util_timer* next; // 指向下一个定时器
};
定时事件,具体的,从内核事件表删除事件,关闭文件描述符,释放连接资源。
class Utils;
/**
* 回调函数,用于处理客户端连接的关闭操作
*
* 该函数从epoll事件表中移除与客户端连接关联的事件,并关闭客户端套接字
* 同时减少活动用户计数
*
* @param user_data 指向客户端数据的指针,包含套接字文件描述符等信息
*/
void cb_func(client_data* user_data) {
// 从epoll表中移除客户端连接的事件
epoll_ctl(Utils::u_epollfd, EPOLL_CTL_DEL, user_data->sockfd, 0);
// 确保user_data不为NULL,虽然在当前逻辑中已经进行了操作,但额外的断言可以提高代码的健壮性
assert(user_data);
// 关闭客户端套接字
close(user_data->sockfd);
// 减少活动用户计数
http_conn::m_user_count--;
}
定时器容器设计
项目中的定时器容器为带头尾结点的升序双向链表,具体的为每个连接创建一个定时器,将其添加到链表中,并按照超时时间升序排列。执行定时任务时,将到期的定时器从链表中删除。
从实现上看,主要涉及双向链表的插入,删除操作,其中添加定时器的事件复杂度是O(n),删除定时器的事件复杂度是O(1)。
升序双向链表主要逻辑如下,具体的,
- 创建头尾节点,其中头尾节点没有意义,仅仅统一方便调整
- add_timer函数,将目标定时器添加到链表中,添加时按照升序添加
-
- 若当前链表中只有头尾节点,直接插入
- 否则,将定时器按升序插入
- adjust_timer函数,当定时任务发生变化,调整对应定时器在链表中的位置
-
- 客户端在设定时间内有数据收发,则当前时刻对该定时器重新设定时间,这里只是往后延长超时时间
- 被调整的目标定时器在尾部,或定时器新的超时值仍然小于下一个定时器的超时,不用调整
- 否则先将定时器从链表取出,重新插入链表
- del_timer函数将超时的定时器从链表中删除
-
- 常规双向链表删除结点
/**
* 向定时器列表中添加一个新的定时器
*
* 此函数负责将一个新的定时器对象添加到一个维护中的定时器列表里
* 定时器列表通过每个定时器对象的前后指针链接在一起,以便于快速访问和管理
*
* 参数:
* timer: 指向新定时器对象的指针这个定时器对象已经在外部被创建,并且是有效的
*
* 返回:
* 无返回值
*
* 注意:
* 如果尝试添加一个空的定时器指针,函数将直接返回不执行任何操作
* 如果定时器列表当前为空,这个新的定时器将被设置为列表的头和尾
* 如果新定时器的过期时间早于当前所有定时器,则将其添加为列表的新头部
* 否则,递归地在列表中找到合适的插入位置
*/
void sort_timer_lst::add_timer(util_timer* timer) {
// 检查传入的定时器指针是否为空
if (!timer)
return;
// 如果列表为空,将这个定时器直接设置为头和尾
if (!head) {
head = tail = timer;
return;
}
// 如果新定时器的过期时间早于当前头部定时器
if (timer->expire < head->expire) {
// 将新定时器插入到头部
timer->next = head;
head->prev = timer;
head = timer;
}
// 否则,调用递归版本的函数来找到并插入到正确的位置
add_timer(timer,head);
}
/**
* 调整定时器列表中的定时器项
*
* 本函数用于在定时器列表中调整某个定时器的位置。定时器列表是一个双向链表,
* 该函数根据定时器的过期时间来调整其在列表中的位置,以保证过期时间最近的定时器位于列表头部。
*
* @param timer 待调整的定时器指针
*/
void sort_timer_lst::adjust_timer(util_timer *timer)
{
if (!timer) // 如果传入的 timer 是空指针,则直接返回
{
return;
}
util_timer *tmp = timer->next; // 获取 timer 的下一个定时器
if (!tmp || (timer->expire < tmp->expire)) // 如果下一个定时器不存在或者当前定时器的过期时间小于下一个定时器的过期时间,直接返回,不需要调整位置
{
return;
}
if (timer == head) // 如果当前定时器是链表头节点
{
head = head->next; // 将链表头节点指向下一个定时器
head->prev = NULL; // 将新的头节点的前驱指针置空
timer->next = NULL; // 将当前定时器的后继指针置空
add_timer(timer, head); // 重新将当前定时器插入链表中合适的位置
}
else // 如果当前定时器不是头节点
{
timer->prev->next = timer->next; // 将当前定时器前一个节点的后继指针指向当前定时器的下一个节点
timer->next->prev = timer->prev; // 将当前定时器下一个节点的前驱指针指向当前定时器的前一个节点
add_timer(timer, timer->next); // 重新将当前定时器插入链表中合适的位置
}
}
/**
* 从定时器列表中删除指定的定时器。
*
* 该函数负责从双向链表中删除指定的定时器对象。根据定时器在链表中的位置(头部、尾部或中间),
* 进行相应的删除操作,并保持链表的完整性。如果定时器不存在于链表中,则该函数不执行任何操作。
*
* 参数:
* - timer: 一个指向待删除定时器的指针。如果指针为nullptr,则函数直接返回。
*
* 返回值:
* - 无返回值。
*/
void sort_timer_lst::del_timer(util_timer* timer) {
if (!timer) {
return;
}
// 如果定时器既是链表的头部也是尾部,则直接删除该定时器,并将链表置为空。
if ((timer == head) && (timer == tail)) {
delete timer;
head = nullptr;
tail = nullptr;
return;
}
// 如果定时器位于链表的头部,则将头指针移动到下一个定时器,并删除当前头部定时器。
if (timer == head) {
head = head->next;
head->prev = nullptr;
delete timer;
return;
}
// 如果定时器位于链表的尾部,则将尾指针移动到前一个定时器,并删除当前尾部定时器。
if (timer == tail) {
tail = tail->prev;
tail->next = nullptr;
delete timer;
return;
}
// 如果定时器位于链表的中间,则调整定时器前后指针,以绕过待删除的定时器,然后删除该定时器。
timer->prev->next = timer->next;
timer->next->prev = timer->prev;
delete timer;
}
void sort_timer_lst::add_timer(util_timer *timer, util_timer* lst_head) {
// 初始化两个指针,prev 指向链表头部,tmp 指向链表的第一个元素。
util_timer* prev = lst_head;
util_timer* tmp = prev->next;
// 遍历链表,找到合适的位置插入新的定时器对象。
while (tmp) {
// 如果新定时器的过期时间小于当前节点的过期时间,执行插入操作。
if (timer->expire < tmp->expire) {
// 将新定时器插入到 prev 和 tmp 之间。
prev->next = timer;
timer->next = tmp;
tmp->prev = timer;
timer->prev = prev;
// 插入完成后跳出循环。
break;
}
// 如果没有找到合适的位置,继续遍历链表。
prev = tmp;
tmp = tmp->next;
}
// 如果 tmp 为 nullptr,说明已到链表尾部或链表为空,需要在尾部插入新定时器。
if (!tmp) {
// 将新定时器添加到链表末尾。
prev->next = timer;
timer->prev = prev;
timer->next = nullptr; // 新的尾节点的 next 总是 nullptr。
tail = timer; // 更新链表的尾指针。
}
}
定时任务处理函数
使用统一事件源,SIGALRM信号每次被触发,主循环中调用一次定时任务处理函数,处理链表容器中到期的定时器。
具体的逻辑如下,
- 遍历定时器升序链表容器,从头结点开始依次处理每个定时器,直到遇到尚未到期的定时器
- 若当前时间小于定时器超时时间,跳出循环,即未找到到期的定时器
- 若当前时间大于定时器超时时间,即找到了到期的定时器,执行回调函数,然后将它从链表中删除,然后继续遍历
/**
* 执行定时器列表的tick方法,用于处理到期的定时器。
*
* 该方法遍历定时器列表,检查每个定时器是否已到期。如果定时器已到期,则执行相应的回调函数,
* 并从列表中删除该定时器。这个过程将持续进行,直到列表中没有未到期的定时器为止。
*
* 请注意,该方法假设调用时已经获取了必要的锁,以保证线程安全。
*/
void sort_timer_lst::tick() {
// 如果列表为空,直接返回,无需处理。
if (!head) {
return;
}
// 获取当前时间,用于判断定时器是否已到期。
time_t cur = time(nullptr);
// 从头结点开始遍历定时器列表。
util_timer* tmp = head;
while (tmp) {
// 如果当前定时器未到期,停止处理。
if (cur < tmp->expire) {
break;
}
// 如果当前定时器已经到期,执行定时器的回调函数,并传递用户数据。
tmp->cb_func(tmp->user_data);
// 从列表中删除当前定时器。
head = tmp->next;
if (head) {
// 如果删除后有新的头结点,更新其前指针。
head->prev = nullptr;
}
// 释放当前定时器占用的内存。
delete tmp;
// 继续处理下一个定时器(如果有的话)。
tmp = head;
}
}
代码分析-如何使用定时器
服务器首先创建定时器容器链表,然后用统一事件源将异常事件,读写事件和信号事件统一处理,根据不同事件的对应逻辑使用定时器。
具体的,
- 浏览器与服务器连接时,创建该连接对应的定时器,并将该定时器添加到链表上
- 处理异常事件时,执行定时事件,服务器关闭连接,从链表上移除对应定时器
- 处理定时信号时,将定时标志设置为true
- 处理读事件时,若某连接上发生读事件,将对应定时器向后移动,否则,执行定时事件
- 处理写事件时,若服务器通过某连接给浏览器发送数据,将对应定时器向后移动,否则,执行定时事件
// 事件循环函数,处理所有事件,包括新客户端连接、读写事件等
void WebServer::eventLoop() {
// 用于标识是否超时,用于定时器处理
bool timeout = false;
// 用于标识是否停止服务器
bool stop_server = false;
// 主循环,不断轮询和处理事件,直到stop_server为true
while (!stop_server) {
// 调用epoll_wait等待事件发生,m_epollfd为epoll句柄,events为事件数组,MAX_EVENT_NUMBER为数组大小,-1表示不超时
int number = epoll_wait(m_epollfd, events, MAX_EVENT_NUMBER, -1);
// 如果epoll_wait返回值小于0且不是因为中断引起,则视为epoll出错
if (number < 0 && errno != EINTR ) {
LOG_ERROR("%s", "epoll failure");
break;
}
// 遍历发生的事件数组,处理每一个事件
for (int i = 0; i < number; i++) {
int sockfd = events[i].data.fd;
// 如果事件对应的socket为监听socket,则有新的客户端连接请求
if (sockfd == m_listenfd) {
bool flag = dealclientdata();
// 如果处理客户端数据失败,则跳过当前循环,继续等待其他事件
if (false == flag)
continue;
}
// 如果事件为挂起读、连接关闭或错误,则处理对应的定时器
else if(events[i].events & (EPOLLRDHUP | EPOLLHUP | EPOLLERR)) {
util_timer *timer = users_timer[sockfd].timer;
deal_timer(timer, sockfd);
}
// 如果事件对应的socket为管道的读端且有读事件,则处理信号
else if((sockfd == m_pipefd[0]) && (events[i].events & EPOLLIN)) {
bool flag = dealwithsignal(timeout, stop_server);
// 如果处理信号失败,则记录错误日志
if (false == flag) {
LOG_ERROR("%s", "deal client data failure");
}
}
// 如果事件为读事件,则处理读操作
else if (events[i].events & EPOLLIN) {
dealwithread(sockfd);
}
// 如果事件为写事件,则处理写操作
else if(events[i].events & EPOLLOUT) {
dealwithwrite(sockfd);
}
}
// 如果有超时发生,则处理定时器,并记录信息
if (timeout) {
utils.timer_handler();
LOG_INFO("%s", "timer tick");
timeout = false;
}
}
}
有小伙伴问,连接资源中的address是不是有点鸡肋?
确实如此,项目中虽然对该变量赋值,但并没有用到。类似的,可以对比HTTP类中address属性,只在日志输出中用到。
但不能说这个变量没有用,因为我们可以找到客户端连接的ip地址,用它来做一些业务,比如通过ip来判断是否异地登录等等。
参考