按照惯例,在学习IO模型和IO复用知识之前需要明确几个基本概念:同步和异步、阻塞和非阻塞。
在不同的领域,同步和异步的概念会有比较大的差异,我们这里讨论的上下文仅限于Linux环境下的IO模型。
先举个例子,比如Linux环境下的read和write函数就是典型的同步函数,这两个函数在执行期主要包括两个阶段:
1、判断内核中的读写资源是否就绪。
2、与内核传递读写资源。
read和write函数的执行模式又可分为阻塞模式和非阻塞模式。阻塞模式和非阻塞模式影响函数第一阶段的行为。在阻塞模式下,read和write函数在第一阶段会一直等待内核中的读写资源就绪,然后才执行第二阶段处理,并返回执行结果。只要返回成功,就表示数据的读或写真的完成了。
对于非阻塞模式,如果read和write函数在第一阶段发现内核中的读写资源未就绪,会立刻返回EAGAIN;如果资源就绪,也会立刻执行第二阶段处理,并返回执行结果,只要返回成功,就表示数据的读或写真的完成了。
所以,对于同步IO模型,如果IO函数返回成功,对应的IO动作就真的完成了。也就是说,在资源就绪的情况下,同步IO模型的IO请求和IO执行是同时完成的(或者说是一种立等可取的状态)。
如果执行异步IO函数,例如aio_read或aio_write,这些函数也会返回成功,但此时并不代表IO动作真的完成了,只是表示IO动作的请求已经提交了,主进程接下来可以去做别的事情了,是否执行成功,内核会另行通知。换句话说,主进程只是告诉内核要做一个什么事,至于内核做不做、什么时候做、做的怎么样都无所谓,后面内核通过消息或回调函数给主进程一个结果就行。由此可知,异步通信机制需要消息和回调函数 ,它依赖于系统的信号机制或者是一个异步通信的框架。所以,在缺少这些复杂机制的情况下,通常我们的设计实现都是基于同步阻塞或同步非阻塞模型的。
为了便于记忆,这里可以打个比方,同步IO就好像我们去商店买酱油,要么是商店没有酱油(EAGAIN),我们先回家,下次再来;要么是商店有酱油(OK),我们拿着酱油回家。异步IO就是我们上淘宝买酱油,只是下个单,具体的结果要等快递员来了才知道。
阻塞IO模型是最常见的一种IO模型,因为字符终端文件、网络socket文件、管道文件pipe/fifo的缺省IO方式都是阻塞模式。程序调用read/write类函数从上面这些文件读写数据时,如果数据或缓存还没有准备好,函数的调用进程就进入被阻塞状态。等到数据读取或发送完成,阻塞状态解除,函数返回,进程继续执行。
总结:同步阻塞模型的优点是代码逻辑简单,容易实现,如果要同时处理多个IO文件描述符(连接套接字),就需要为每个连接创建一个线程或进程。这种方式对于实现海量连接的高并发服务器,基本不可行。
对于同步非阻塞模型,首先设备文件是以非阻塞的方式打开,用户进程调用read/write类函数从文件读写数据时不会立即成功,一般都会返回一个错误码,函数返回之后,进程可以继续调用其它函数干点别的事情,然后再执行read/write类函数调用,整个过程就是一个大的循环,直到函数返回成功为止。通常也被称为用户层轮询(polling)。
总结:表面上看,这种模型在轮询过程中也没闲着,还能做点兼职,但是一旦在做兼职的时候,IO资源准备好了,它也无法对系统的IO请求做出快速响应。
首先,系统提供了被称为多路复用监控函数(select、poll、epoll等函数)的系统调用。函数的输入参数可以包含多个I/O文件描述符(多个socket描述符),操作系统内核收到用户进程的函数调用后,先将进程阻塞,再监听输入参数指定的所有文件描述符,当其中任何一个文件描述符的IO资源就绪时,多路复用监控函数才会返回,这个时候用户进程再调用read/write操作完成文件的读写。
总结:这个模型的关键是,进程阻塞在监控函数上,而不是阻塞在IO函数上,另外,监控函数可以同时监控多个IO文件描述符(这种多对一的关系也就是所谓的复用)。由于监控函数不能立即返回,且监控函数被阻塞,所以这个模型仍然是一种特殊的同步阻塞模型。使用这种模型的好处就是可以同时监控大量的连接描述符而不需要每个连接对应一个进程或线程,更容易支持服务器的高并发访问。如果只有少量IO文件描述符,这种模型的性能可能比模型1还要差。
int select(int maxfdp1, fd_set *readset, fd_set *writeset, fd_set *exceptset,struct timeval *timeout)
参数说明:
maxfdp1:文件描述符的最大值再加一,通过这个最大值可以减少内核的无效遍历次数。
readset:作为输入参数,通知内核监控这个集合中的描述符需要是否可读。作为输出参数,返回当前可读的全部描述符。
writeset:作为输入参数,通知内核监控这个集合中的描述符需要是否可写。作为输出参数,返回当前可写的全部描述符。
exceptset:作为输入参数,通知内核监控这个集合中的描述符需要是否异常。作为输出参数,返回当前出现异常的全部描述符。
注:在Linux系统中,数据结构fd_set包含了一个long类型的数组,数组的每一个bit对应一个描述符,这个数组的大小(即包含的bit数)由内核宏定义‘_FD_SETSIZE’决定,所以正常情况下,fd_set数据结构最多只能记录1024个文件描述符。
timeout:一个指向timeval结构体的指针。
如果是NULL:表示等待无限长的时间,但可能被信号中断,此时函数返回 -1,变量 erro为 EINTR
如果timeval中的时间是零,则函数不等待,直接返回。
如果timeval中的时间不是零,则按指定的时间等待,直到超时或资源就绪。
返回值:被信号中断,返回-1,否则总是返回当前处于就绪状态的描述符数量。
select调用的代码架构
{
/* 创建监听套接字listenfd */
listenfd = socket(AF_INET, SOCK_STREAM, 0);
bind(listenfd, (struct sockaddr*)&servaddr, socklen);
listen(listenfd, listenq);
FD_ZERO(&allset);
FD_SET(listenfd, &allset); /* 将监听套接字加入套接字集合 */
for ( ; ; ) {
readset = allset; /* 防止select函数修改输入参数 */
nready = select(maxfd + 1, &readset, NULL, NULL, NULL);
if (FD_ISSET(listenfd, &readset)) { /* 接受来自客户端的连接请求,并创建连接套接字connfd */
connfd = accept(listenfd, (struct sockaddr*) &cliaddr, &socklen);
FD_SET(connfd, &allset); /* 将连接套接字加入套接字集合 */
}
/* 遍历select返回的套接字集合,处理其中处于就绪状态的套接字 */
for (int i = listenfd+1; i <= maxfd; i++){
if (FD_ISSET(i, &readset)){
nread = read(i, buf, MAXLINE);
write(connfd, buf, nread);
}
}
}
}
问题总结:select机制存在的最大问题在于输入参数fd_set限制了被监控的描述符不能超过1024个。(当然,默认情况下,Linux系统单个进程能够打开的文件描述符也就只有1024个,如下图所示。但是函数的限制和系统的默认设置是两回事,如果是系统默认设置,通过命令行、配置文件、以及系统函数都可以修改这个限制。如果是函数限制,对于开发人员,只要你用这个函数,就摆脱不了这个限制。)
int poll(struct pollfd fds[], nfds_t nfds, int timeout)
参数说明:
fds:指向一个 pollfd类型的结构体数组,具体信息如下:
typedef struct pollfd {
int fd; /* 需要检测的文件描述符*/
short events; /* 对文件描述符fd上期望的事件 */
short revents; /* 文件描述符fd上当前实际发生的事件*/
} pollfd_t;
event事件表:
值 | 含义 | 值 | 含义 |
---|---|---|---|
POLLIN | 有数据可读 | POLLOUT | 数据可写 |
POLLRDNORM | 普通数据可读 | POLLWRNORM | 普通数据可写 |
POLLRDBAND | 优先级带外数据可读 | POLLWRBAND | 优先级带外数据可写 |
POLLPRI | 有紧迫数据可读 | POLLRDHUP | 套接字半关闭 |
POLLERR | 错误发生 | POLLHUP | 套接字关闭 |
POLLNVAL | 不是一个打开的文件 |
其中,POLLERR 、POLLHUP 、POLLNVAL只能是revents中的返回值,不能是events中的期望值。
nfds:nfds_t类型的参数,用于标记数组fds中的结构体元素的总数量
timeout:是poll函数调用阻塞的时间(单位:毫秒)。
如果timeout==0, poll() 函数立即返回,不阻塞,
如果timeout>0,按指定的时间等待,直到超时或资源就绪。
如果timeout==INFTIM(-1),poll() 函数会一直阻塞下去,直到所检测的socket描述符上有感兴趣的事件发生
返回值:-1表示调用失败;>=0,表示当前处于就绪状态的描述符数量。
函数应用举例:
struct pollfd fds;
fds[nIndex].events=POLLIN | POLLOUT | POLLERR;// 对socket描述符fd上的读、写、异常事件感兴趣
// 当 poll()函数返回时,要判断所检测的socket描述符上发生的事件,可以这样做:
if ((fds[nIndex].revents & POLLIN) == POLLIN) { // 检测是否有可读或新的TCP连接请求
// 接收数据或调用accept()接收连接请求
}
if ((fds[nIndex].revents & POLLOUT) == POLLOUT) { // 检测是否可写
// 发送数据
}
if ((fds[nIndex].revents & POLLERR) == POLLERR) { // 检测是否有异常:
// 异常处理
}
问题总结:poll机制与select原理上差异不大,但是因为采用数组传入描述符集合,解决了select机制最大1024个描述符的限制,但是每次调用这两个函数的时候,都需要向内核传递全部的描述符集合,且内核也需要不断轮询全部描述符,所以,如果描述符的数量级越高,性能会不断恶化。
(1) 创建一个epoll文件描述符
int epoll_create(int size)
参数说明:
size:参数size用来告诉内核需要监听的描述符数量。从2.6.28内核开始,这个参数了就不再需要,可以是任意值。函数:int epoll_create1(int flags)也可实现类似的功能。另外有两点需要注意:
1. epoll_create函数执行成功返回的epoll句柄(fd)也是一个文件描述符,具体信息可以查看/proc/进程id/fd/。
2. 使用完epoll后,必须调用close()关闭,否则系统的文件描述符会产生泄露。
(2)注册需要监听的文件描述符以及所关心的事件
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event)
参数说明:
epfd : epoll_create函数创建的epoll文件描述符。
op : 操作标志。
EPOLL_CTL_ADD:增加需要检测的IO文件描述符;
EPOLL_CTL_MOD:修改IO文件描述符希望监听的事件;
EPOLL_CTL_DEL:删除一个正在监听的IO文件描述符;
fd:需要监听的IO文件描述符
event:指向epoll_event结构体的指针,告诉内核需要监听什么事件和对应的文件描述符,具体信息如下:
struct epoll_event {
__uint32_t events; /* Epoll events */
epoll_data_t data; /* User data variable */
};
typedef union 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队列里。
epoll_event结构体中最重要的是events字段,它描述了需要内核监控的IO事件类型和事件触发方式(事件边沿触发或条件状态触发)。一般编程中经常关注的的事件包括:EPOLLIN、EPOLLOUT。另外,还可以通过EPOLLET设置IO描述符的事件触发方式为边沿触发。data字段一般放入事件对应的IO描述符,这样用户收到事件时,立刻就知道是针对哪个IO描述符的。EPOLLONESHOT事件针对的场景是:一线程读完某套接字上数据后开始处理这些数据,此时该套接字上又有新数据可读(即EPOLLIN再次被触发),另一线程被唤醒读新的数据,最终造成2个线程同时操作一个套接字 。EPOLLONESHOT事件保证一个套接字在任一时刻只被一个线程处理,线程处理完该套接字后,需要立即重置新注册EPOLLONESHOT事件,保证下次EPOLLIN事件能被触发。
所谓LT(Level Trigger)触发方式,只要被监控的IO文件描述符对应的IO资源处于就绪状态,内核就会持续通知用户(即epoll_wait()函数立即返回),直到处于就绪状态的IO资源被用户全部处理完,否则内核会一直通知下去,即每次调用epoll_wait()函数都会立刻返回。而对于ET(Edge Trigger)触发方式,内核只会在被监控描述符对应的IO资源由不可用变为可用状态时,才会通过epoll_wait()函数通知用户一次,如果用户因为某种原因没有处理这些IO资源,内核也不会再通知了。由此可知,对于同样的输入处理,如果用户无法一次读取全部的数据,采用LT模式时,内核会通过多次回调通知用户,直到用户将数据全部取出。如果采用ET模式,用户要自己负责连续分多次读取完全部的数据,中间的各种异常处理必须完备。所以,一般认为ET模式内核效率高,但用户层代码设计更复杂,才可保证整体效率高。而LT模式因为产生多次回调,内核效率较低,但是用户层代码逻辑相对简单,更容易维护。
(3)等待epoll中包含的IO文件描述符有期望的事件产生
int epoll_wait(int epfd, struct epoll_event * events, int maxevents, int timeout)
参数说明:
epfd : epoll_create函数创建的epoll文件描述符。
events : 指向一个用户层分配好的epoll_event结构体数组,内核将已发生的事件集合通过此数组返回给用户。
maxevents : 告之内核输入的events数组有多大。
timeout : 等待的最长时间(单位:毫秒),0:立即返回,-1:一直阻塞
返回值:-1表示调用失败;0表示超时返回,>0表示需要处理的events数量。
LT模式的软件处理架构
int main()
{
/* 客户端发起连接请求以后,在服务器accept之前,可能会立刻主动中断TCP连接 */
/* 如果服务器监听套接字设置成阻塞模式,服务器就会一直阻塞在accept上 */
/* 对于IO复用模式,就绪队列中的可能有其他描述符得不到处理 */
setnonblocking(listenfd); /* 所以,这里设置服务器的监听套接字为非阻塞模式 */
/* 创建 epoll 句柄,把监听套接字加入到epoll监控表里 */
epollfd = epoll_create(MAX_EVENTS);
ev.events = EPOLLIN; /* 如果采用ET模式,需要设置为:EPOLLIN | EPOLLET */
ev.data.fd = listenfd;
epoll_ctl(epollfd, EPOLL_CTL_ADD, listenfd, &ev);
for (;;) {
nfds = epoll_wait(epollfd, events, MAX_EVENTS, -1);/* 等待有事件发生 */
Lt_process(events, nfds , epollfd, listenfd);
}
}
void Lt_process(struct epoll_event* events, int nfds, int epollfd, int listenfd)
{
/* 处理所有事件 */
for (n = 0; n < nfds; ++n)
{
int sockfd = events[i].data.fd;
if (sockfd == listenfd){ /* 接受来自客户端的连接请求,并创建连接套接字connfd */
connfd = accept(listenfd, (struct sockaddr *)&cliaddr,&socklen);
setnonblocking(connfd);
ev.events = EPOLLIN; /* 如果采用ET模式,需要设置为:EPOLLIN | EPOLLET *
ev.data.fd = connfd;
epoll_ctl(epollfd, EPOLL_CTL_ADD, connfd, &ev); /* 将连接套接字加入epoll监控表 */
continue;
}
if (events[n].events == EPOLLIN){
nread = read(sockfd, buf, MAXLINE); /* LT模式的读取很简单,不考虑是否完全读空 */
Lt_write(epollfd, sockfd, &events[n]); /* LT模式的数据发送有点特殊 */
}
if (events[i].events & EPOLLOUT) {
/* 先将连接套接字在epoll监控表设置为只监控读,等到缓冲区全部写满,在设置监控写 */
ev.events = EPOLLIN;
ev.data.fd = sockfd;
epoll_ctl(epollfd, EPOLL_CTL_MOD, sockfd, &ev);
Lt_write(epollfd, sockfd, &events[n]);
}
}
}
/* LT模式下,只要缓冲区未满,EPOLLOUT事件就会持续,正确的发送数据方式是:
直接尝试写操作,直到出现EAGAIN错误,再把连接套接字加入到epoll监控表 */
void Lt_write(int epollfd, int connfd, struct epoll_event* event)
{
nwrite, data_size = strlen(buf);
n = data_size; // 记录需要发送的总字节数,
while (n > 0) {
nwrite = write(connfd, buf + data_size - n, n);
if (nwrite >= 0) // 本次发送成功,则刷新剩余待发送的字节数
{
n -= nwrite;
}
else if (nwrite == -1 && errno == EAGAIN) // EAGAIN错误表示发送资源不可用,将套接字加入到epoll监控表
{
ev.data.fd = connfd;
ev.events = events->events | EPOLLOUT;
epoll_ctl(epollfd, EPOLL_CTL_MOD, connfd, &ev);
break;
}
else
{
// 其他异常,关闭本地连接
close(connfd);
}
}
}
ET模式的软件处理架构
void Et_process(struct epoll_event* events, int nfds, int epollfd, int listenfd)
{
/* 处理所有事件 */
for (i = 0; i < nfds; ++i) {
if (events[i].data.fd == listenfd) {
/* 在ET模式下,多个连接请求同时到达,也只会触发一次,必须循环读取全部的连接请求 */
while ((conn_sock = accept(listenfd, (struct sockaddr *)&cliaddr, &addrlen)) > 0) {
setnonblocking(conn_sock);
ev.events = EPOLLIN | EPOLLET;
ev.data.fd = conn_sock;
epoll_ctl(epollfd, EPOLL_CTL_ADD, connfd, &ev) == -1);
continue;
}
if (events[i].events & EPOLLIN) {
n = 0; /*在ET模式下,只要可读,就一直读,直到返回0,或者 errno = EAGAIN */
while ((nread = read(fd, buf + n, BUFSIZ-1)) > 0) {n += nread;}
/* 如果需要发送,就在epoll监控表中设置监听套接字的EPOLLOUT事件 */
ev.data.fd = events[i].data.fd;
ev.events = events[i].events | EPOLLOUT;
epoll_ctl(epollfd, EPOLL_CTL_MOD, fd, &ev);
}
if (events[i].events & EPOLLOUT) {
/* 在ET模式下,只要可写,就一直写,直到数据发送完,或者出错 errno = EAGAIN */
int nwrite, data_size = strlen(buf);
n = data_size; // 记录需要发送的总字节数,
while (n > 0) {
nwrite = write(fd, buf + data_size - n, n);
if (nwrite >= 0) // 本次发送成功,则刷新剩余待发送的字节数
{
n -= nwrite;
}
else if (nwrite == -1 && errno == EAGAIN)
{
break; //
}
else
{
// 其他异常,关闭本地连接
close(fd);
break;
}
}
}
}
}
}
ET模式只允许非阻塞式IO,解决的方式是反复读取缓冲区,直到返回EAGAIN错误。
问题总结:
相比select和poll,epoll有几个明显的改进:
1. epoll没有最大描述符(连接数)的限制(当然,poll也没有)。
2. epoll每一次调用都不需要把全部的描述符集合都传递给内核,当有海量连接时,这一点带来的性能差异会非常大。
3. epoll_wait函数调用从内核获取已经就绪的事件时,采用了mmap()映射,加速了内核空间到用户空间的数据复制。
4. select和poll函数在系统内核部分的处理是轮询机制,而epoll函数在系统内核部分是事件触发的回调函数机制。轮询机制会导致函数执行的时间随总连接数量线性增加,即函数的时间复杂度是O(n),而回调机制则不会。
所以,在面向海量连接的时候,epoll设计有巨大的优势,唯一需要注意的是,如果连接数少或者全部连接都十分活跃的情况下,select和poll的性能可能比epoll好,毕竟epoll内部机制更复杂。
这个模型的原理很简单,大致过程如下:
- 以非阻塞方式创建socket套接字,并开启套接字的信号驱动I/O功能
- 为SIGIO信号创建信号处理函数,并调用signal函数安装此信号处理函数
- 当内核数据包准备好时,会产生SIGIO信号,且相应的信号处理函数被调用,用户可以在信号处理函数中调用I/O操作函数处理数据。
总结:对于TCP协议,因为存在三步握手的过程,采用信号驱动IO的方式,会接收到大量的信号,导致实际的效率很低。对于UDP协议,信号驱动IO的方式是一种比较简单实用的技术。
用户进程调用专门的异步IO函数(例如:aio_read)通知内核,然后,函数立刻返回到用户进程。等到数据准备好了,内核直接复制数据到用户进程,内核向用户进程发送通知。整个过程,用户进程都是非阻塞的。