非活跃,是指客户端(这里是浏览器)与服务器端建立连接后,长时间不交换数据,一直占用服务器端的文件描述符,导致连接资源的浪费。
非活跃,是指固定一段时间之后触发某段代码,由该段代码处理一个事件,如从内核事件表删除事件,并关闭文件描述符,释放连接资源。
定时器,是指利用结构体或其他形式,将多种定时事件进行封装起来。具体的,这里只涉及一种定时事件,即定期检测非活跃连接,这里将该定时事件与连接资源封装为一个结构体定时器。
定时器容器,是指使用某种容器类数据结构,将上述多个定时器组合起来,便于对定时事件统一管理。具体的,项目中使用升序链表将所有定时器串联组织起来。
本项目中,服务器主循环为每一个连接创建一个定时器,并对每个连接进行定时。另外,利用升序时间链表容器将所有定时器串联起来,若主循环接收到定时通知,则在链表中依次执行定时任务。
Linux下提供了三种定时的方法:
三种方法没有一劳永逸的应用场景,也没有绝对的优劣。由于项目中使用的是SIGALRM信号,这里仅对其进行介绍,另外两种方法可以查阅游双的Linux高性能服务器编程 第11章 定时器。
具体的,利用alarm函数周期性地触发SIGALRM信号,信号处理函数利用管道通知主循环,主循环接收到该信号后对升序链表上所有定时器进行处理,若该段时间内没有交换数据,则将该连接关闭,释放所占用的资源。
从上面的简要描述中,可以看出定时器处理非活动连接模块,主要分为两部分,其一为定时方法与信号通知流程,其二为定时器及其容器设计与定时任务的处理。
本节将介绍定时方法与信号通知流程,具体的涉及到基础API、信号通知流程和代码实现。
基础API,描述sigaction结构体、sigaction函数、sigfillset函数、SIGALRM信号、SIGTERM信号、alarm函数、socketpair函数、send函数。
信号通知流程,介绍统一事件源和信号处理机制。
代码实现,结合代码对信号处理函数的设计与使用进行详解。
为了更好的源码阅读体验,这里提前对代码中使用的一些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);
}
sigaction函数
#include
int sigaction(int signum, const struct sigaction *act, struct sigaction *oldact);
sigfillset函数
#include
int sigfillset(sigset_t *set);
用来将参数set信号集初始化,然后把所有的信号加入到此信号集里。
SIGALRM、SIGTERM信号
#define SIGALRM 14 //由alarm系统调用产生timer时钟信号
#define SIGTERM 15 //终端发送的终止信号
alarm函数
#include ;
unsigned int alarm(unsigned int seconds);
设置信号传送闹钟,即用来设置信号SIGALRM在经过参数seconds秒数后发送给目前的进程。如果未设置信号SIGALRM的处理函数,那么alarm()默认处理终止进程.
socketpair函数
在linux下,使用socketpair函数能够创建一对套接字进行通信,项目中使用管道通信。
#include
#include
int socketpair(int domain, int type, int protocol, int sv[2]);
send函数
#include
#include
ssize_t send(int sockfd, const void *buf, size_t len, int flags);
当套接字发送缓冲区变满时,send通常会阻塞,除非套接字设置为非阻塞模式,当缓冲区变满时,返回EAGAIN或者EWOULDBLOCK错误,此时可以调用select函数来监视何时可以发送数据。
Linux下的信号采用的异步处理机制,信号处理函数和当前进程是两条不同的执行路线。具体的,当进程收到信号时,操作系统会中断进程当前的正常流程,转而进入信号处理函数执行操作,完成后再返回中断的地方继续执行。
为避免信号竞态现象发生,信号处理期间系统不会再次触发它。所以,为确保该信号不被屏蔽太久,信号处理函数需要尽可能快地执行完毕。
一般的信号处理函数需要处理该信号对应的逻辑,当该逻辑比较复杂时,信号处理函数执行时间过长,会导致信号屏蔽太久。
这里的解决方案是,信号处理函数仅仅发送信号通知程序主循环,将信号对应的处理逻辑放在程序主循环中,由主循环执行信号对应的逻辑代码。
统一事件源
统一事件源,是指将信号事件与其他事件一样被处理。
具体的,信号处理函数使用管道将信号传递给主循环,信号处理函数往管道的写端写入信号值,主循环则从管道的读端读出信号值,使用I/O复用系统调用来监听管道读端的可读事件,这样信号事件与其他文件描述符都可以通过epoll来监测,从而实现统一处理。
信号处理机制
每个进程之中,都有存着一个表,里面存着每种信号所代表的含义,内核通过设置表项中每一个位来标识对应的信号类型。
至此,一个完整的信号处理流程便结束了,如果同时有多个信号到达,上面的处理流程会在第2步和第3步骤间重复进行。
信号处理函数
自定义信号处理函数,创建sigaction结构体变量,设置信号函数。
//信号处理函数
void sig_handler(int sig)
{
//为保证函数的可重入性,保留原来的errno
//可重入性表示中断后再次进入该函数,环境变量与之前相同,不会丢失数据
int save_errno = errno;
int msg = sig;
//将信号值从管道写端写入,传输字符类型,而非整型
send(pipefd[1], (char *)&msg, 1, 0);
//将原来的errno赋值为当前的errno
errno = save_errno;
}
信号处理函数中仅仅通过管道发送信号值,不处理信号对应的逻辑,缩短异步执行时间,减少对主程序的影响。
//设置信号函数
void addsig(int sig, void(handler)(int), bool restart = true)
{
//创建sigaction结构体变量
//创建sigaction结构体变量 struct sigaction sa;
memset(&sa, '\0', sizeof(sa));
//信号处理函数中仅仅发送信号值,不做对应逻辑处理 sa
//信号处理函数中仅仅发送信号值,不做对应逻辑处理 sa.sa_handler = handler;
if (restart)
sa.sa_flags |= SA_RESTART;
//将所有信号添加到信号集中
//将所有信号添加到信号集中 sigfillset(&sa.sa_mask);
//执行sigaction函数
assert(sigaction(sig, &sa, NULL) != -1);
}
项目中设置信号函数,仅关注SIGTERM和SIGALRM两个信号。
信号通知逻辑
代码分析
//创建管道套接字
ret = socketpair(PF_UNIX, SOCK_STREAM, 0, pipefd);
assert(ret != -1);
//设置管道写端为非阻塞,为什么写端要非阻塞?
setnonblocking(pipefd[1]);
//设置管道读端为ET非阻塞
addfd(epollfd, pipefd[0], false);
//传递给主循环的信号值,这里只关注SIGALRM和SIGTERM
addsig(SIGALRM, sig_handler, false);
addsig(SIGTERM, sig_handler, false);
//循环条件
bool stop_server = false;
//超时标志
bool timeout = false;
//每隔TIMESLOT时间触发SIGALRM信号
alarm(TIMESLOT);
while (!stop_server)
{
//监测发生事件的文件描述符
int number = epoll_wait(epollfd, events, MAX_EVENT_NUMBER, -1);
if (number < 0 && errno != EINTR)
{
break;
}
//轮询文件描述符
for (int i = 0; i < number; i++)
{
int sockfd = events[i].data.fd;
//管道读端对应文件描述符发生读事件
if ((sockfd == pipefd[0]) && (events[i].events & EPOLLIN))
{
int sig;
char signals[1024];
//从管道读端读出信号值,成功返回字节数,失败返回-1
//正常情况下,这里的ret返回值总是1,只有14和15两个ASCII码对应的字符
ret = recv(pipefd[0], signals, sizeof(signals), 0);
if (ret == -1)
{
// handle the error
continue;
}
else if (ret == 0)
{
continue;
}
else
{
//处理信号值对应的逻辑
for (int i = 0; i < ret; ++i)
{
//这里面明明是字符
switch (signals[i])
{
//这里是整型
case SIGALRM:
{
timeout = true;
break;
}
case SIGTERM:
{
stop_server = true;
}
}
}
}
}
}
}
为什么管道写端要非阻塞?
send是将信息发送给套接字缓冲区,如果缓冲区满了,则会阻塞,这时候会进一步增加信号处理函数的执行时间,为此,将其修改为非阻塞。
没有对非阻塞返回值处理,如果阻塞是不是意味着这一次定时事件失效了?
是的,但定时事件是非必须立即处理的事件,可以允许这样的情况发生。
管道传递的是什么类型?switch-case的变量冲突?
信号本身是整型数值,管道中传递的是ASCII码表中整型数值对应的字符。
switch的变量一般为字符或整型,当switch的变量为字符时,case中可以是字符,也可以是字符对应的ASCII码。
定时器处理非活动连接模块,主要分为两部分,其一为定时方法与信号通知流程,其二为定时器及其容器设计、定时任务的处理。
本篇对第二部分进行介绍,具体的涉及到定时器设计、容器设计、定时任务处理函数和使用定时器。
定时器设计,将连接资源和定时事件等封装起来,具体包括连接资源、超时时间和回调函数,这里的回调函数指向定时事件。
定时器容器设计,将多个定时器串联组织起来统一处理,具体包括升序链表设计。
定时任务处理函数,该函数封装在容器类中,具体的,函数遍历升序链表容器,根据超时时间,处理对应的定时器。
代码分析-使用定时器,通过代码分析,如何在项目中使用定时器。
项目中将连接资源、定时事件和超时时间封装为定时器类,具体的,
//连接资源结构体成员需要用到定时器类
//需要前向声明
class util_timer;
//连接资源
struct client_data
{
//客户端socket地址
sockaddr_in address;
//socket文件描述符
int sockfd;
//定时器
util_timer* timer;
};
//定时器类
class util_timer
{
public:
util_timer() : prev( NULL ), next( NULL ){}
public:
//超时时间
time_t expire;
//回调函数
void (*cb_func)( client_data* );
//连接资源
client_data* user_data;
//前向定时器
util_timer* prev;
//后继定时器
util_timer* next;
};
定时事件,具体的,从内核事件表删除事件,关闭文件描述符,释放连接资源。
//定时器回调函数
void cb_func(client_data *user_data)
{
//删除非活动连接在socket上的注册事件
epoll_ctl(epollfd, EPOLL_CTL_DEL, user_data->sockfd, 0);
assert(user_data);
//关闭文件描述符
close(user_data->sockfd);
//减少连接数
http_conn::m_user_count--;
}
项目中的定时器容器为带头尾结点的升序双向链表,具体的为每个连接创建一个定时器,将其添加到链表中,并按照超时时间升序排列。执行定时任务时,将到期的定时器从链表中删除。
从实现上看,主要涉及双向链表的插入,删除操作,其中添加定时器的事件复杂度是O(n),删除定时器的事件复杂度是O(1)。
升序双向链表主要逻辑如下,具体的,
//定时器容器类
class sort_timer_lst
{
public:
sort_timer_lst() : head( NULL ), tail( NULL ) {}
//常规销毁链表
~sort_timer_lst()
{
util_timer* tmp = head;
while( tmp )
{
head = tmp->next;
delete tmp;
tmp = head;
}
}
//添加定时器,内部调用私有成员add_timer
void 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;
return;
}
//否则调用私有成员,调整内部结点
add_timer( timer, head );
}
//调整定时器,任务发生变化时,调整定时器在链表中的位置
void adjust_timer( util_timer* timer )
{
if( !timer )
{
return;
}
util_timer* tmp = timer->next;
//被调整的定时器在链表尾部
//定时器超时值仍然小于下一个定时器超时值,不调整
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 );
}
}
//删除定时器
void del_timer( util_timer* timer )
{
if( !timer )
{
return;
}
//链表中只有一个定时器,需要删除该定时器
if( ( timer == head ) && ( timer == tail ) )
{
delete timer;
head = NULL;
tail = NULL;
return;
}
//被删除的定时器为头结点
if( timer == head )
{
head = head->next;
head->prev = NULL;
delete timer;
return;
}
//被删除的定时器为尾结点
if( timer == tail )
{
tail = tail->prev;
tail->next = NULL;
delete timer;
return;
}
//被删除的定时器在链表内部,常规链表结点删除
timer->prev->next = timer->next;
timer->next->prev = timer->prev;
delete timer;
}
private:
//私有成员,被公有成员add_timer和adjust_time调用
//主要用于调整链表内部结点
void add_timer( util_timer* timer, util_timer* lst_head )
{
util_timer* prev = lst_head;
util_timer* tmp = prev->next;
//遍历当前结点之后的链表,按照超时时间找到目标定时器对应的位置,常规双向链表插入操作
while( tmp )
{
if( timer->expire < tmp->expire )
{
prev->next = timer;
timer->next = tmp;
tmp->prev = timer;
timer->prev = prev;
break;
}
prev = tmp;
tmp = tmp->next;
}
//遍历完发现,目标定时器需要放到尾结点处
if( !tmp )
{
prev->next = timer;
timer->prev = prev;
timer->next = NULL;
tail = timer;
}
}
private:
//头尾结点
util_timer* head;
util_timer* tail;
};
使用统一事件源,SIGALRM信号每次被触发,主循环中调用一次定时任务处理函数,处理链表容器中到期的定时器。
具体的逻辑如下,
//定时任务处理函数
void tick()
{
if( !head )
{
return;
}
//获取当前时间
time_t cur = time( NULL );
util_timer* tmp = head;
//遍历定时器链表
while( tmp )
{
//链表容器为升序排列
//当前时间小于定时器的超时时间,后面的定时器也没有到期
if( cur < tmp->expire )
{
break;
}
//当前定时器到期,则调用回调函数,执行定时事件
tmp->cb_func( tmp->user_data );
//将处理后的定时器从链表容器中删除,并重置头结点
head = tmp->next;
if( head )
{
head->prev = NULL;
}
delete tmp;
tmp = head;
}
}
服务器首先创建定时器容器链表,然后用统一事件源将异常事件,读写事件和信号事件统一处理,根据不同事件的对应逻辑使用定时器。
具体的:
//定时处理任务,重新定时以不断触发SIGALRM信号
void timer_handler()
{
timer_lst.tick();
alarm(TIMESLOT);
}
//创建定时器容器链表
static sort_timer_lst timer_lst;
//创建连接资源数组
client_data *users_timer = new client_data[MAX_FD];
//超时默认为False
bool timeout = false;
//alarm定时触发SIGALRM信号
alarm(TIMESLOT);
while (!stop_server)
{
int number = epoll_wait(epollfd, events, MAX_EVENT_NUMBER, -1);
if (number < 0 && errno != EINTR)
{
break;
}
for (int i = 0; i < number; i++)
{
int sockfd = events[i].data.fd;
//处理新到的客户连接
if (sockfd == listenfd)
{
//初始化客户端连接地址
struct sockaddr_in client_address;
socklen_t client_addrlength = sizeof(client_address);
//该连接分配的文件描述符
int connfd = accept(listenfd, (struct sockaddr *)&client_address, &client_addrlength);
//初始化该连接对应的连接资源
users_timer[connfd].address = client_address;
users_timer[connfd].sockfd = connfd;
//创建定时器临时变量
util_timer *timer = new util_timer;
//设置定时器对应的连接资源
timer->user_data = &users_timer[connfd];
//设置回调函数
timer->cb_func = cb_func;
time_t cur = time(NULL);
//设置绝对超时时间
timer->expire = cur + 3 * TIMESLOT;
//创建该连接对应的定时器,初始化为前述临时变量
users_timer[connfd].timer = timer;
//将该定时器添加到链表中
timer_lst.add_timer(timer);
}
//处理异常事件
else if (events[i].events & (EPOLLRDHUP | EPOLLHUP | EPOLLERR))
{
//服务器端关闭连接,移除对应的定时器
cb_func(&users_timer[sockfd]);
util_timer *timer = users_timer[sockfd].timer;
if (timer)
{
timer_lst.del_timer(timer);
}
}
//处理定时器信号
else if ((sockfd == pipefd[0]) && (events[i].events & EPOLLIN))
{
//接收到SIGALRM信号,timeout设置为True
}
//处理客户连接上接收到的数据
else if (events[i].events & EPOLLIN)
{
//创建定时器临时变量,将该连接对应的定时器取出来
util_timer *timer = users_timer[sockfd].timer;
if (users[sockfd].read_once())
{
//若监测到读事件,将该事件放入请求队列
pool->append(users + sockfd);
//若有数据传输,则将定时器往后延迟3个单位
//对其在链表上的位置进行调整
if (timer)
{
time_t cur = time(NULL);
timer->expire = cur + 3 * TIMESLOT;
timer_lst.adjust_timer(timer);
}
}
else
{
//服务器端关闭连接,移除对应的定时器
cb_func(&users_timer[sockfd]);
if (timer)
{
timer_lst.del_timer(timer);
}
}
}
else if (events[i].events & EPOLLOUT)
{
util_timer *timer = users_timer[sockfd].timer;
if (users[sockfd].write())
{
//若有数据传输,则将定时器往后延迟3个单位
//并对新的定时器在链表上的位置进行调整
if (timer)
{
time_t cur = time(NULL);
timer->expire = cur + 3 * TIMESLOT;
timer_lst.adjust_timer(timer);
}
}
else
{
//服务器端关闭连接,移除对应的定时器
cb_func(&users_timer[sockfd]);
if (timer)
{
timer_lst.del_timer(timer);
}
}
}
}
//处理定时器为非必须事件,收到信号并不是立马处理
//完成读写事件后,再进行处理
if (timeout)
{
timer_handler();
timeout = false;
}
}
连接资源中的address是不是有点鸡肋?
确实如此,项目中虽然对该变量赋值,但并没有用到。类似的,可以对比HTTP类中address属性,只在日志输出中用到。
但不能说这个变量没有用,因为我们可以找到客户端连接的ip地址,用它来做一些业务,比如通过ip来判断是否异地登录等等。