前言
这篇的诞生也很不容易,感谢Jung Zhang学长和瑞神的橘子。
在上一篇,我们通过Redis对定时事件的处理有了一定的认识,今天我们继续按照《高性能服务器编程》上边的思路,用C++来实现一个小demo。
本篇中,我们将利用alarm函数来完成定时,通过time函数来进行计时,使用信号通知,利用链表维护定时器。所以整体的设计上精度不高,效率不高,只是为了理解整体思路的小例子,不具备实用意义。
正文
吐槽
利用信号来完成异步通知其实并不讨好。
首先,对于多线程程序来说信号就是个大麻烦,当一个进程接受到信号时要传递给哪个线程呢,信号处理函数的重入问题等等。
其次,当信号产生时,我们的主循环epoll_wait如何知道呢?在我们的第一篇里我们直接通过epoll_wait的超时参数来进行简单的定时,那么如果使用信号,如何让epoll_wait按时返回呢?
还有,程序调用设置的信号处理函数,传参就是一个很淡疼的问题。
。。。种种吐槽先到此,那么针对单线程的服务端模型,利用signal相关函数,如何做到定时呢?
利用信号统一事件源
单线程自然不存在重入等问题,那么坑点就在于如何让监听I/O事件的epoll_wait也能顺便监听信号事件,这里便是需要统一事件源,那么我们只要将一个信号事件转换为一个I/O事件就好了。
这里的核心思路就是通过一对管道来实现,将pipe[0]注册到epoll来监听可读事件,而在信号处理函数中向pipe[1]写数据,这样一旦信号产生,调用信号处理函数,信号处理函数写数据,而epoll_wait就监听到可读的fd了然后返回。
是不是很简单呢???
那么问题就来了,对于阻塞的系统调用如read,epoll_wait等,信号会直接中断它们,让它们出错返回,并设置errno为EINTR…
我去。。。那上面的思路不就是很傻很天真了吗。。。
所以我们要对这种情况(return -1 && errno = EINTR)进行处理,简单来说就是:
没事哦亲,只不过闹钟响了我们再重新epoll_wait哦~
恩,就是这样。
思路代码如下:
void sig_handler(int sig)
{
char msg = 1;
send(pipefd[1], (char *)&msg, 1, 0);//写数据
printf("send success\n");
}
void Network::addSig(int sig)
{
struct sigaction sa;
memset(&sa, 0, sizeof(struct sigaction));
sa.sa_handler =sig_handler; //设置信号处理函数(回调函数)
sa.sa_flags |= SA_RESTART; //见下文
sigemptyset(&sa.sa_mask); //清空信号集
sigaddset(&sa.sa_mask, SIGALRM);//添加要处理的信号
assert(sigaction(sig, &sa, NULL) != -1);//注册
}
//*********
//主循环
while(1){
_nfds = epoll_wait(_kdpfd, _events, curfds, -1);
if(_nfds == -1 && errno != EINTR){
perror("epoll_wait");// errno如果等于EINTR就继续吧
return -1;
}else if(_events[n].data.fd == _pipeFd[0]){
//信号到了!!!
continue;
}else if(_events[n].events & EPOLLIN){
//其他I/O事件
}
}
诶,这个SA_RESTART标志是干嘛的,看上去是重新开始的意思,那么我们的epoll_wait….
打住。。。这个标志的确可以自动重启被信号中断的系统调用(如read),但是,它不能重启epoll_wait。。。所以我们设置它主要是为了我们的I/O操作被信号中断后可以自动重启。
那么有同学可能会问,上面的处理完全没必要引入pipe啊,可以通过errno来判断啊,反正EINTR肯定就是信号事件啊。那么 这里的操作就可以简化为如下
//*******************
//主程序
addSig(SIGALRM)//设置信号
while(1){
_nfds = epoll_wait(_kdpfd, _events, curfds, -1);
if(_nfds == -1 && errno != EINTR){
perror("epoll_wait");// errno如果等于EINTR就继续吧
return -1;
}else if(_events[n].events & EPOLLIN){
//其他I/O事件
}else if(_nfds == -1 && errno == EINTR){
//信号到了
}
}
但是这里的问题便是,在addsig之后到epoll_wait之前,可能信号就到了,然而这个时候epoll_wait还没被调用,无法得知信号产生,这个信号便无法按照我们的想法被处理了。。。
这种竞态条件需要我们的addsig和epoll_wait变成一个原子操作(毕竟信号不是多线程可以通过锁来限制。。。),而这里就出现了epoll_pwait等系统调用。。
但应用的更多的是我们上边这种方式,我们先将管道读端注册好,这样一旦addsig执行成功,即使epoll_wait未调用信号就到来,我们依然能够在管道里写好数据,保证之后调用epoll_wait时能“知道”这个信号已经产生,这种方式叫做self-pipe。
至此,通过一对管道,我们将信号事件转换为I/O事件,那么下一步就是用信号来定时,处理定时事件了。
通过信号来处理定时事件
这里我通过一个例子来说明,在应用层实现keep-alive机制。
需求,当一个客户端连接上超过3*T时间没有发送请求则将其关闭,若发送过请求则时间更新(这条没实现。。。)。
这里的思路便是,当一个socket连接上时,为其设置一个定时器,放入链表中。同时整个程序每T秒查看一次定时器链表中是否有超时,如果有直接close掉。这里的定时就是通过alarm函数(定时发送SINALRM信号),再利用上文的统一事件源方式保证epoll_wait能“监听”信号事件。
主函数(其余代码见github)
int main(void)
{
TimerList<Timer> timerlist(5);
Network server(5473,5);
server.Listen();
int epollfd = server.initMainLoop();
assert(epollfd != -1);
timerlist.setEpollFd(epollfd);//
server.setAlarm();//设置第一次定时,这里其实可以长一点。。
while(1){
server.startMainLoop(timerlist);//主循环
if(server.dealTimeEvent(timerlist)){//时间到了 or 执行完一次I/O了 检查一下有没有到时间的
server.setAlarm();//时间到了,再次定时
}
}
}
后记
可以看到这次的代码写的很草率。。。主要原因是在深入了解的过程中,感觉利用信号处理十分的坑爹,同时,也有系统调用完成了这样的统一事件源和定时的工作。。signalfd,timefd可以说更胜任这样的工作(但限制于内核版本与Linux平台),所以感觉这里的例子也更多是作为学习,可能在实际应用并不多。。
下一篇我们会利用时间轮来处理定时事件(看看Libco? or 利用timefd?)。
参考资料及参考阅读
《高性能Linux服务器编程》第十一章–定时器
《Unix/Linux系统编程手册》信号相关章节及63.5小节
《Linux多线程服务端编程–使用muduo C++网络库》7.8定时器小节
Linux 新增系统调用的启示 陈硕老师的blog