参考:《Linux高性能服务器编程》
服务器程序框架
服务器通常需要处理三类事件:
1. I/O事件
2. 信号事件
3. 定时事件
同步I/O模型通常用于实现Reactor模式
异步I/O模型则用于实现Proactor模式
1. 两种高效的事件处理模式
同步I/O模型通常用于实现Reactor模式
异步I/O模型则用于实现Proactor模式
1.1 Reactor模式
1.2 Proactor模式
Proactor模式将所有的I/O操作都交给主线程和内核来处理,工作线程仅仅负责业务逻辑。
2. 服务器处理的三类事件
服务器通常需要处理三类事件:
1. I/O事件
2. 信号事件
3. 定时事件
2.1 I/O复用系统调用
2.1.1 select
#include <sys/select.h>
#include <sys/time.h>
int select(int maxfdp1,fd_set *readset,
fd_set *writeset,
fd_set *exceptset,
const struct timeval *timeout)
//返回值:就绪描述符的数目,超时返回0,出错返回-1
2.1.2 poll
# include <poll.h>
int poll ( struct pollfd * fds, unsigned int nfds, int timeout);
//pollfd结构体定义如下
struct pollfd {
int fd; /* 文件描述符 */
short events; /* 等待的事件 */
short revents; /* 实际发生了的事件 */
} ;
2.1.3 epoll
#include <sys/epoll.h>
int epoll_create(int size);
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);
int epoll_wait(int epfd,
struct epoll_event * events,
int maxevents,
int timeout);
//epoll_event结构体如下
struct epoll_event {
__uint32_t events; /* Epoll events */
epoll_data_t data; /* User data variable */
};
/*
events可以是以下几个宏的集合:
EPOLLIN :表示对应的文件描述符可以读(包括对端SOCKET正常关闭);
EPOLLOUT:表示对应的文件描述符可以写;
EPOLLPRI:表示对应的文件描述符有紧急的数据可读(这里应该表示有带外数据到来);
EPOLLERR:表示对应的文件描述符发生错误;
EPOLLHUP:表示对应的文件描述符被挂断;
EPOLLET: 将EPOLL设为边缘触发(Edge Triggered)模式,
这是相对于水平触发(Level Triggered)来说的。
EPOLLONESHOT:只监听一次事件,当监听完这次事件之后,
如果还需要继续监听这个socket的话,需要再次把这个socket加入到EPOLL队列里
*/
总结:select、poll、epoll 的比较
select poll epoll 这三组I/O复用系统调用,都能同时监听多个文件描述符,等待由timeout参数指定的超时时间,直到一个或多个文件描述符上有事件发生时返回,返回值是就绪的文件描述符数量。返回0表示没有事件发生。
3组函数都通过某种结构体变量来告诉内核监听哪些文件描述符上的事件,并使用该结构体类型的参数来获取内核处理的结果。
1. select的参数类型fd_set没有文件描述符和事件的绑定,仅仅是一个文件描述符集合,因此需要提供3个这种类型的参数来分别传入和输出可读、可写及异常等事件。这就导致select不能处理更多类型的事件。
内核对fd_set集合的修改,使应用程序下次调用select前不得不重置这3个fd_set集合。
2. poll的参数类型pollfd,把文件描述符合事件都定义在其中,任何事件都统一处理,从而使编程接口简洁得多。
并且内核每次修改的是pollfd.revents成员,而pollfd.events成员保持不变,因此下次调用poll时应用程序无须重置pollfd参数的事件集参数。
3. select poll 调用每次都返回整个用户注册的事件集合(包括就绪和未就绪的),所以应用程序索引就绪文件描述符的时间复杂度为O(n);
4. epoll采用与select和poll完全不同的方式来管理用户注册的事件。它在内核中维护一个事件表,并提供一个独立的系统调用epoll_ctl来控制往其中添加、删除、修改事件。这样,每次epoll_wait调用都直接从内核事件表中取得用户注册的事件,而无需反复从用户空间读入这些事件。
5. epoll_wait系统调用的events参数仅用来返回就绪的事件,这使得应用程序索引就绪文件描述符的时间复杂度达到O(1)。
6. select允许监听的最大文件描述符数量通常有限制,poll和epoll_wait分别用nfds和maxevents参数指定最多监听多少文件描述符和事件,都能达到系统允许打开的最大文件描述符数目,即65535。
7. select和poll都只能工作在相对低效的LT模式,而epoll则有两种工作模式:LT模式、ET模式。epoll还支持EPOLLONESHOT事件,能进一步减少可读、可写和异常等事件被触发的次数。
- LT模式:采用LT工作模式的文件描述符,当epoll_wait检测到其上有事件发生并将此事件通知应用程序后,应用程序可以不立即处理该事件。当应用程序下次调用epoll_wait时,epoll_wait还会再次向应用程序通告此事件,直到该事件被处理。
- ET模式:采用ET工作模式的文件描述符,当epoll_wait检测到其上有试讲发生并将此事件通知应用程序后,应用程序必须立即处理该事件,因为后续的epoll_wait调用将不再向应用程序通知这一事件。
ET模式在很大程度上降低了同一个epoll事件被重复触发的次数,因此,效率比LT模式高。
- EPOLLONESHOT事件:使一个socket连接在任一时刻都只被一个线程处理。对于注册了EPOLLONESHOT事件的文件描述符,操作系统最多触发其上注册的一个可读、可写或者异常事件,且只触发一次,除非使用epoll_ctl函数重置该文件描述符上注册的EPOLLONESHOT事件。
8. 从实现原理上来说,select和poll都采用的是轮询的方式,即每次调用都要扫描整个注册文件描述符集合,并将就绪的文件描述符返回给用户程序,因此检测就绪事件的时间复杂度是O(n)。 epoll_wait采用的是回调的方式,内核检测到就绪的文件描述符时,触发回调函数,将该文件描述符上对应的事件插入内核就绪事件队列,内核最后在适当的时机将该就绪事件队列中的内容拷贝靠用户空间,因此epoll无须轮询整个文件描述符集合来检测就绪事件,其事件复杂度是O(1)。
当活动连接比较多的时候,epoll效率未必比select和poll高,因为此时回调函数触发的过于频繁。所以,epoll_wait适合用于连接数量多,但活动连接较少的情况。
总结对比表:
2.2 信号
信号是软件中断,由用户、系统或者进程发送给目标进程的信息,以通知目标进程某个状态的改变或系统异常。信号提供了一种处理异常事件的方法。
Linux信号产生条件:
1. 用户通过输入特殊的终端字符来给前台进程发送信号。
2. 系统异常:如浮点异常和非法内存段访问。
3. 系统状态变化:如alarm定时器到期引起SIGALRM信号。
4. 运行kill命令或调用kill函数。
kill函数:一个进程给其他进程或进程组发送信号。
raise函数:允许进程向自身发送信号。
#include <signal.h>
int kill(pid_t pid, int signo);
int raise(int signo);
信号处理方式
- 忽略此信号
- 捕捉信号
- 执行系统默认动作
signal系统调用
为一个信号设置信号处理函数。
#include <signal.h>
void (*signal(int signo, void (*func)(int)))(int);
sigaction系统调用
设置信号处理函数的更健壮的接口。
#include <signal.h>
int sigaction(int signo,
const struct sigaction *restrict act,
struct sigaction *restrict oact);
中断系统调用
如果程序在 执行处于阻塞状态的系统调用时接收到信号,并为该信号设置了信号处理函数,则默认情况下,系统调用将被中断,并且errno设置为EINTR。可以使用sigaction函数为信号设置SA_RESTART标志以自动启动被该信号中断的系统调用。
信号集
Linux使用数据结构sigset_t
来表示一组信号。
#include <bits/sigset.h>
typedef struct
{
unsigned long int _val[SIGSET_NWORDS];
}sigset_t;
//可见,sigset_t是一个长整型数组,数组的每个元素的每个位表示一个信号。
信号集函数
#include <signal.h>
int sigemptyset(sigset_t *set); //清空信号集
int sigfillset(sigset_t *set); //在信号集中设置所有信号
int sigaddset(sigset_t *set, int signo); //将信号signo添加至信号集
int sigdelset(sigset_t *set, int signo); //将信号signo从信号集删除
int sigismember(const sigset_t *set, int signo); //测试signo是否在信号集中
进程信号掩码(信号屏蔽字)
函数sigprocmask用于设置和查看进程的信号掩码:
#include <signal.h>
int sigprocmask(int how, const sigset_t *restrict set,
sigset_t *restrict oset);
oset参数:输出原来的信号掩码。
set参数:指定新的信号掩码①不为NULL时,how参数指定设置进程信号掩码的方式(SIG_BLOCK,SIG_UNBLOCK,SIG_SETMASK);②为NULL时,不改变进程的信号掩码,how参数的值也就没有意义。
被挂起的信号
设置进程信号掩码后,被屏蔽的信号将不能被进程接收。如果给进程发送一个被屏蔽的信号,则操作系统将该信号设置为进程的一个被挂起的信号。如果取消对被挂起信号的屏蔽,则它能立即被进程接收到。
函数sigpending用来获得进程当前被挂起的信号集。
#include <signal.h>
int sigpending(sigset_t *set);
//参数set用于保存被挂起的信号集
进程即使多次接收到同一个被挂起的信号,sigpending函数也只能反映一次,改信号的处理函数也只能被触发一次。
统一事件源
信号是异步事件:信号处理函数和程序的主循环是两条不同的执行路线。
信号处理函数需要尽可能快地执行完毕,以确保该信号不被屏蔽太久(为了避免一些竞态条件,信号在处理期间,系统不会再次触发它)。
一种典型的解决方案是:把信号的主要处理逻辑放在程序的主循环中,当信号处理函数被触发时,它只是简单的通知主循环程序接收到的信号,并把信号值传递给主循环,主循环再根据接收到的信号值执行目标信号对应的逻辑代码。
信号处理函数通常使用管道将信号“传递”给主循环:信号处理函数往管道的写端写入信号值,主循环则从管道的读端读出信号值。
需要使用I/O多路复用来监听管道的读端文件描述符上的可读事件,如此一来,信号事件就能和其他I/O事件一样被处理,即统一事件源。
如libevent.
3个和网络编程密切相关的信号
- SIGHUP:当 挂起 进程的控制终端时,SIGHUP信号将被触发。
- SIGPIPE:往一个读端关闭的管道或socket连接中写数据将引发SIGPIPE信号。
程序接收到SIGPIPE信号的默认行为是结束进程,而我们绝对不希望因为错误的写操作而导致程序退出。所以需要在代码中捕获并处理该信号,至少忽略它。 - SIGURG:内核通知应用程序 带外数据 到达的方法之一。
2.3 定时器
将每个定时事件分别封装成定时器,并使用某种容器类数据类数据结构(比如链表、排序链表、时间轮),将所有定时器串联起来,以实现对定时事件的统一管理。
两种高效的管理定时器的容器:时间轮和时间堆。
Linux提供三种定时方法:
1. socket选项SO_RCVTIMEO和SO_SNDTIMEO。
2. SIGALRM信号
3. I/O复用系统调用的超时参数
高性能定时器->时间堆
将所有定时器超时时间最小的一个定时器的超时值作为心搏间隔,一旦心博函数tick被调用,超时时间最小的定时器必然到期,就可以在tick函数中处理该定时器。然后,再次从剩余的定时器中找出超时时间最小的一个,并将这段最小时间设置为下一次心博间隔。如此重复就实现了较为精准的定时。