1. I/O复用服务器
I/O多路复用(multiplexing)的本质是通过一种机制(系统内核缓冲I/O数据),让单个进程可以监视多个文件描述符,一旦某个描述符就绪(一般是读就绪或写就绪),能够通知程序进行相应的读写操作。
Linux中基于socket的通信本质也是一种I/O,使用socket()函数创建的套接字默认都是阻塞的,这意味着当sockets API的调用不能立即完成时,线程一直处于等待状态,直到操作完成获得结果或者超时出错。会引起阻塞的socket API分为以下四种:
输入操作:recv()、recvfrom()。以阻塞套接字为参数调用该函数接收数据时,如果套接字缓冲区内没有数据可读,则调用线程在数据到来前一直睡眠。
输出操作:send()、sendto()。以阻塞套接字为参数调用该函数发送数据时,如果套接字缓冲区没有可用空间,线程会一直睡眠,直到有空间。
接受连接:accept()。以阻塞套接字为参数调用该函数,等待接受对方的连接请求。如果此时没有连接请求,线程就会进入睡眠状态。
外出连接:connect()。对于TCP连接,客户端以阻塞套接字为参数,调用该函数向服务器发起连接。该函数在收到服务器的应答前,不会返回。这意味着TCP连接总会等待至少服务器的一次往返时间。
使用阻塞模式的套接字编写网络程序比较简单,容易实现。但是在服务器端,通常要处理大量的套接字通信请求,如果线程阻塞于上述的某一个输入或输出调用时,将无法处理其他任何运算或响应其他网络请求,这么做无疑是十分低效的,当然可以采用多线程,但大量的线程占用很大的内存空间,并且线程切换会带来很大的开销。而I/O多路复用模型能处理多个connection的优点就使其能支持更多的并发连接请求。
Linux支持I/O多路复用的系统调用有select、poll、epoll,这些调用都是内核级别的。但select、poll、epoll本质上都是同步I/O,先是block住等待就绪的socket,再block住将数据从内核拷贝到用户内存空间。
2. Select方式:
Linux提供的select相关函数接口如下:
#include <sys/select.h>
#include <sys/time.h>
int select(int max_fd, fd_set *readset,fd_set *writeset, fd_set *exceptset, struct timeval *timeout)
// select函数的返回值就绪描述符的数目,超时时返回0,出错返回-1。第一个参数max_fd
//指待测试的fd个数,它的值是待测试的最大文件描述符加1,文件描述符从0开始到//max_fd-1都将被测试。中间三个参数readset、writeset和exceptset指定要让内核测试读、//写和异常条件的fd集合,如果不需要测试的可以设置为NULL。
FD_ZERO(int fd, fd_set* fds) //清空集合
FD_SET(int fd, fd_set* fds) //将给定的描述符加入集合
FD_ISSET(int fd, fd_set* fds) //将给定的描述符从文件中删除
FD_CLR(int fd, fd_set* fds) //判断指定描述符是否在集合中
整体的使用流程如下图:
基于select的I/O复用模型的是单进程执行,占用资源少,可以为多个客户端服务。但是select需要轮询每一个描述符,在高并发时仍然会存在效率问题,同时select能支持的最大连接数通常受限。
3. Poll方式:
3.1 基本原理
poll本质上和select没有区别,它将用户传入的数组拷贝到内核空间,然后查询每个fd对应的设备状态,如果设备就绪则在设备等待队列中加入一项并继续遍历,如果遍历完所有fd后没有发现就绪设备,则挂起当前进程,直到设备就绪或者主动超时,被唤醒后它又要再次遍历fd。这个过程经历了多次无谓的遍历。它没有最大连接数的限制,原因是它是基于链表来存储的,但是同样有一个缺点:
大量的fd的数组被整体复制于用户态和内核地址空间之间,而不管这样的复制是不是有意义。
poll还有一个特点是“水平触发”,如果报告了fd后,没有被处理,那么下次poll时会再次报告该fd。
从上面看,select和poll都需要在返回后,通过遍历文件描述符来获取已经就绪的socket。事实上,同时连接的大量客户端在一时刻可能只有很少的处于就绪状态,因此随着监视的描述符数量的增长,其效率也会线性下降。
3.2. 相关函数
原型:
#include <poll.h>
int poll(struct pollfd fds[], nfds_t nfds, int timeout);
参数描述:
该poll()函数返回fds集合中就绪的读、写,或出错的描述符数量,返回0表示超时,返回-1表示出错;
fds是一个struct pollfd类型的数组,用于存放需要检测其状态的socket描述符,并且调用poll函数之后fds数组不会被清空;
nfds:记录数组fds中描述符的总数量;
timeout:调用poll函数阻塞的超时时间,单位毫秒;
其中pollfd结构体定义如下:
typedefstruct pollfd {
int fd; /* 需要被检测或选择的文件描述符*/
short events; /* 对文件描述符fd上感兴趣的事件 */
short revents; /* 文件描述符fd上当前实际发生的事件*/
} pollfd_t;
一个pollfd结构体表示一个被监视的文件描述符,通过传递fds[]指示 poll() 监视多个文件描述符,其中:
结构体的events域是监视该文件描述符的事件掩码,由用户来设置这个域。
结构体的revents域是文件描述符的操作结果事件掩码,内核在调用返回时设置这个域。
4 Epoll方式
epoll是在2.6内核中提出的,相对于select和poll来说,epoll更加灵活,没有描述符限制。epoll使用一个文件描述符管理多个描述符,将用户关系的文件描述符的事件存放到内核的一个事件表中,这样在用户空间和内核空间的copy只需一次。epoll使用“事件”的就绪通知方式,通过epoll_ctl注册fd,一旦该fd就绪,内核就会采用类似callback的回调机制来激活该fd,epoll_wait便可以收到通知。
epoll的优点在于:
没有最大并发连接的限制,能打开的fd上限远大于1024(1G的内存上能监听约10万个端口)
采用回调的方式,效率提升。只有活跃可用的fd才会调用callback函数,也就是说 epoll 只管你“活跃”的连接,而跟连接总数无关,因此在实际的网络环境中,epoll的效率就会远远高于select和poll。
内存拷贝,利用mmap()文件映射内存加速与内核空间的消息传递;即epoll使用mmap减少复制开销。
4.1 工作模式
epoll对文件描述符的操作有两种模式:LT(level trigger,水平触发)和ET(edge trigger,边缘触发)。二者的区别如下:
水平触发:默认工作模式,即当epoll_wait检测到某描述符事件就绪并通知应用程序时,应用程序可以不立即处理该事件;下次调用epoll_wait时,会再次通知此事件。
边缘触发:当epoll_wait检测到某描述符事件就绪并通知应用程序时,应用程序必须立即处理该事件。如果不处理,下次调用epoll_wait时,不会再次通知此事件。(直到你做了某些操作导致该描述符变成未就绪状态了,也就是说边缘触发只在状态由未就绪变为就绪时通知一次)
边缘触发(ET模式)在很大程度上减少了epoll事件被重复触发的次数,因此效率要比LT模式高。epoll工作在ET模式的时候,必须使用非阻塞socket,以避免由于一个文件描述符的阻塞读/阻塞写操作把处理多个文件描述符的任务饿死。
4.2 相关函数
#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);
1) epoll_create函数用来创建一个epoll句柄,参数size用来告诉内核要监听的数目会有多少个。(现在size参数已经不再需要了,内核会动态调整分配的空间大小,但这个参数必须大于0,所以一般设置成1即可)
成功时返回一个文件描述符,表示epoll句柄(最后也需要close关闭)失败时返回-1
2)epoll_ctl函数用于注册要监听的事件类型,它有四个参数:
第一个参数 epfd 表示epoll句柄,即epoll_create()的返回值;
第二个参数表示对fd的操作类型,
EPOLL_CTL_ADD(注册新的fd到epfd中)
EPOLL_CTL_MOD(修改已注册的fd的监听事件)
EPOLL_CTL_DEL(从epfd中删除一个fd)
第三个参数是需要监听的fd
第四个参数是告诉内核需要监听什么事件
其中struct epoll_event结构体定义如下:
struct epoll_event {
__uint32_t events; /* Epoll events */
epoll_data_t data; /* User data variable */
};
typedefunion epoll_data {
void *ptr;
int fd;
__uint32_t u32;
__uint64_t u64;
} epoll_data_t;
域 events 可以是以下几个宏的集合:
EPOLLIN :表示对应的文件描述符可以读(包括对端SOCKET正常关闭);
EPOLLOUT:表示对应的文件描述符可以写;
EPOLLPRI:表示对应的文件描述符有紧急的数据可读(这里应该表示有带外数据到来);
EPOLLERR:表示对应的文件描述符发生错误;
EPOLLHUP:表示对应的文件描述符被挂断;
EPOLLET:将EPOLL设为边缘触发(Edge Triggered)模式,这是相对于水平触发(Level Triggered)来说的。
EPOLLONESHOT:只监听一次事件,当监听完这次事件之后,如果还需要继续监听这个socket的话,需要再次把这个socket加入到EPOLL队列里
3)epoll_wait函数等待事件的就绪,成功时返回就绪的事件数目,调用失败时返回 -1,等待超时返回 0。它也有四个参数:
第一个参数 epfd 即epoll句柄;
第二个参数 events 用来从内核得到就绪事件的集合;
第三个参数 maxevents 告诉内核这个 events 有多大;
第四个参数 timeout 表示等待时的超时时间,以毫秒为单位。