前言
本文介绍网络io模型,以及select,poll,epoll三种IO多路复用的机制。
提示:以下是本篇文章正文内容
一、什么是IO
IO是input与output的首字母缩写,所以在计算机系统中,一切具有输入输出类型的交互系统都可以认为是I/O系统。如:网络IO,磁盘IO,等等。下面我们重点介绍网络IO。
二、IO模型
网路IO,一般会涉及到两个系统对象,一个是用户空间调用IO的进程或者线程,另一个空间是内核系统,比如发生IO操作read的时候,它会经历两个阶段:
- 等待数据准备就绪
- 将数据从内核拷贝到进程或者线程中。
针对以上两个阶段的不同情况,大概可以将网络模型分为以下五种:
- 阻塞 IO(blocking IO)
- 非阻塞 IO( non-blocking IO)
- 多路复用 IO(IO multiplexing)
- 异步 IO(Asynchronous I/O)
- 信号驱动 IO(signal driven I/O,SIGIO)
1. 阻塞 IO
在linux中,默认情况下所有的socket都是阻塞的,一个典型的读操作流程
当用户进程调用read()的时候,内核kernel进入到第一阶段等待数据,在此时,用户进程会被阻塞在read()函数,当内核收到数据,等到数据准备好后,内核进入到第二阶段,就是把数据从内核空间拷贝到用户空间,然后内核返回数据,用户进程从read的阻塞中返回,并读取到相应的数据。
在网络编程的接口函数中(listen,accept,send,recv等)默认情况下都是阻塞型的。
下图是简单的一问一答的服务器/客户端模型
服务器代码如下:
//main函数
int listenfd, connfd, n;
struct sockaddr_in servaddr;
char buff[MAXLNE];
if ((listenfd = socket(AF_INET, SOCK_STREAM, 0)) == -1) {
printf("create socket error: %s(errno: %d)\n", strerror(errno), errno);
return 0;
}
memset(&servaddr, 0, sizeof(servaddr));
servaddr.sin_family = AF_INET;
servaddr.sin_addr.s_addr = htonl(INADDR_ANY);
servaddr.sin_port = htons(9999);
if (bind(listenfd, (struct sockaddr *)&servaddr, sizeof(servaddr)) == -1) {
printf("bind socket error: %s(errno: %d)\n", strerror(errno), errno);
return 0;
}
if (listen(listenfd, 10) == -1) {
printf("listen socket error: %s(errno: %d)\n", strerror(errno), errno);
return 0;
}
struct sockaddr_in client;
socklen_t len = sizeof(client);
if ((connfd = accept(listenfd, (struct sockaddr *)&client, &len)) == -1) {
printf("accept socket error: %s(errno: %d)\n", strerror(errno), errno);
return 0;
}
printf("========waiting for client's request========\n");
while (1) {
n = recv(connfd, buff, MAXLNE, 0);
if (n > 0) {
buff[n] = '\0';
printf("recv msg from client: %s\n", buff);
send(connfd, buff, n, 0);
} else if (n == 0) {
close(connfd);
}
}
close(listenfd);
运行实例:
上述代码有很多的缺陷,它只支持读取第一个连上去的客户端的数据,后面如果有更多的客户端连上来,并不能读取到其他客户端的发送数据,这里解释一下为什么可以接受多个连接,而不能读取多个连接的数据:
- 首先tcp连接三次握手是在协议栈完成的,跟应用无关,所以服务器可以接受多个客户端的连接。
- 三次握手之后,通过accept函数返回一个客户端的连接,上述代码中的connfd。然后可以通过这个connfd去操作recv函数和send函数来收发数据。由上述代码可以看出,代码只支持一个accept返回的客户端,当第二个连接进来的时候,应用程序并没有通过accept来读取新的客户端fd,while循环里面只对第一个connfd来进行收发数据。
如何可以接受多个客户端连接并且对多个连接进行收发数据呢?多线程!
代码修改如下:
//定义一个线程函数处理connfd的收发数据
void *client_routine(void *args)
{
int connfd = *(int *)args;
char buff[MAXLNE];
while (1)
{
int n = recv(connfd, buff, MAXLNE, 0);
if (n > 0) {
buff[n] = '\0';
printf("recv msg from client: %s\n", buff);
send(connfd, buff, n, 0);
} else if (n == 0) {
close(connfd);
break;
}
}
return NULL;
}
//main函数修改如下
printf("========waiting for client's request========\n");
while (1) {
struct sockaddr_in client;
socklen_t len = sizeof(client);
if ((connfd = accept(listenfd, (struct sockaddr *)&client, &len)) == -1) {
printf("accept socket error: %s(errno: %d)\n", strerror(errno), errno);
return 0;
}
pthread_t threadid;
pthread_create(&threadid, NULL, client_routine, (void*)&connfd);
}
上述代码可以支持多个客户端的连接以及对多个客户端进行收发数据。但是开辟线程是需要代价的。一个线程占用8M的空间,所以1G的内存最多支持128个线程,这里还不包括其他资源或者进程占用内存资源。有些人说可以用到“线程池”和“连接池”或者“内存池”,这些“池”技术也只能说缓解部分压力,并不能解决这些问题。面对大规模的服务请求,多线程模型也会遇到瓶颈,可以用接下来的非阻塞IO接口来尝试解决这个问题。
2.非阻塞 IO
对于网络IO的read(),非阻塞IO的模型图如下:
可以看出来,当应用程序调用read操作的时候,当内核没有数据就绪,或者说数据还没准备好的时候,会立即返回,从而不会阻塞用户进程,一旦内核的数据准备好,用户进程再次调用read的时候,它马上就将数据拷贝到用户内存,然后返回。也就是说,需要应用程序不断的主动轮询内核数据准备好了没有。
在这种非阻塞的IO状态下,recv()接口被调用后会立即返回,返回值的不同有着不一样的含义:
- 大于0 ,表示数据接受完毕。返回值即是接收到的数据的字节数。
- 等于0 ,表示连接已断开。
- 等于-1 且 errno == EAGAIN, 表示recv还没执行完成,也就是内核中还没有数据可读。
- 等于 -1且 errno不等于EAGAIN,表示recv操作遇到系统错误,这是error代表本次错误的错误码,可以通过查表的方式去查看具体错误码的含义或者使用perror/strerror函数来打印错误信息。
使用如下函数可以将一个句柄fd设置成非阻塞:
fcntl(fd, F_SETFL, O_NONBLOCK);
这种模型下虽然可以使用单个线程来实现对所有连接的接受数据工作,但是由于非阻塞模型需要不断的去执行系统调用来获知I/O操作是否完成,将大幅度的推高CPU的占用率,实际上还有更为高效的检查“操作是否完成”的接口,这就是杰尔下来要介绍的多路复用模型。
3.多路复用 IO
select/poll 和 epoll都是多路复用I/O的接口函数。首先来介绍一下select和poll。
select和poll在使用和处理上存在大同小异,所以接下来只介绍select函数,同时poll的用法也会贴出来。
当应用程序调用select函数的时候,整个进程也会被阻塞,内核Kernel会监视所有select传进来的socket,当任何一个或者多个socket中的数据准备好的时候,select就会返回,应用程序通过select返回的socket进行read操作,内核将数据拷贝到用户内存上。
有人会问:这个select也是阻塞的,那他与阻塞IO模型有什么区别?select可以在单个进程下完成对多个客户端(socket或者说connfd)进行IO操作,而在阻塞IO中需要使用多个线程才能完成。
那有的人又会问:非阻塞IO也可以在单个线程下完成多个socket的IO操作,这又有什么区别呢?上面讲到非阻塞IO需要消耗大量的CPU资源来轮询内核是否有数据准备好,而select不需要不断的去轮询,只需要等待select的返回,这里多说一句:如果连接数不多的话,完全可以使用阻塞IO来实现一个server,如果使用select/poll、epoll实现的话可能不但性能差,而且延迟可能会更大,多路复用IO的优势不在于处理的更快,而是处理更多的连接,比如select可以支持1024个连接,这取决于select最大的监视数量,poll可以解决select的最大监视数量的问题,可以支持到C10K,也就是1万个连接,而epoll可以支持C1000K,也就是100万的连接,这些大家都可以去验证)。
select使用代码如下:
fd_set rfds, rset, wfds, wset;
FD_ZERO(&rfds);
FD_SET(listenfd, &rfds);
FD_ZERO(&wfds);
int max_fd = listenfd;
while (1)
{
rset = rfds;
wset = wfds;
int nready = select(max_fd+1, &rset, &wset, NULL, NULL);
std::cout << "hahahah \n";
std::cout << "nready :" << nready << std::endl;
if(FD_ISSET(listenfd, &rset))
{
std::cout << "listenfd \n";
struct sockaddr_in client;
socklen_t len = sizeof(client);
if ((connfd = accept(listenfd, (struct sockaddr *)&client, &len)) == -1) {
printf("accept socket error: %s(errno: %d)\n", strerror(errno), errno);
return 0;
}
FD_SET(connfd, &rfds);
if(connfd > max_fd) max_fd = connfd;
if(--nready == 0) continue;
}
int i = 0;
for(i = listenfd + 1; i <= max_fd; i++)
{
std::cout << "connnfd \n";
std::cout << "listenfd :" << listenfd << " max_fd :" << max_fd << " i = " << i << std::endl;
if(FD_ISSET(i, &rset))
{
n = recv(i, buff, MAXLNE, 0);
std::cout << "n : " << n << std::endl;
if (n > 0) {
buff[n] = '\0';
printf("recv msg from client: %s\n", buff);
FD_SET(i, &wfds);
} else if (n == 0) { //客户端close的时候
//释放fd
FD_CLR(i, &rfds);
close(i);
}
if(--nready == 0) break;
}else if(FD_ISSET(i, &wset))
{
printf("send msg to client: %s\n", buff);
send(i, buff, n, 0);
FD_SET(i, &rfds);
FD_CLR(i, &wfds);
}
}
}
poll使用代码如下:
struct pollfd fds[POLL_SIZE] = {0};
fds[0].fd = listenfd;
fds[0].events = POLLIN;
int max_fd = listenfd + 1;
while (1)
{
int nready = poll(fds, max_fd + 1, -1);
if(fds[0].revents & POLLIN)
{
struct sockaddr_in client;
socklen_t len = sizeof(client);
if ((connfd = accept(listenfd, (struct sockaddr *)&client, &len)) == -1) {
printf("accept socket error: %s(errno: %d)\n", strerror(errno), errno);
return 0;
}
fds[connfd].fd = connfd;
fds[connfd].events = POLLIN;
if(connfd > max_fd) max_fd = connfd;
if(--nready == 0) continue;
}
int i = 0;
for(i = listenfd + 1; i <= max_fd; i++)
{
if(fds[i].revents & POLLIN)
{
n = recv(i, buff, MAXLNE, 0);
std::cout << "n : " << n << std::endl;
if (n > 0) {
buff[n] = '\0';
printf("recv msg from client: %s\n", buff);
send(i, buff, n, 0);
} else if (n == 0) { //客户端close的时候
fds[i].fd = -1;
close(i);
}
if(--nready == 0) break;
}
}
}
解释一下代码:
- select函数原型:
int select(int nfds, fd_set *readfds, fd_set *writefds,
fd_set *exceptfds, struct timeval * timeout);
参数:
nfds:是委托内核检测的最大的文件描述符+1。
readfds:是委托内核检查该文件描述符是否可读的集合。
writefds:是委托内核检查该文件描述符是否可写的集合。
exceptds:是委托内核检查该文件描述符是否有异常的集合。
timeout:是一个timeval结构体,它是select函数阻塞时超时返回的时间。
返回值:
大于0:成功,返回集合中已就绪的文件描述符的总个数。
等于-1:函数调用失败。
等于0:超时,没有检测到就绪的文件描述符。
select只要使用到的数据结构是fd_set。他通过宏定义FD_ZERO将其所有标志位置为0,FD_SET将集合中对应的fd标志位置为1,FD_ISSET用来检查对应fd的标志位是否为1,FD_CLR用来将集合中指定的fd标志位删除,也就是置为0。
应用程序维护了两个fd_set的集合rfds和wfds用来管理所有的fd,每次调用select时将其赋值与rset和wset两个fd_set的集合,委托给内核进行检测事件的发生。select函数返回时会重写rset和wset两个集合,将有事件发生的fd的标志位置为1。所以必须在应用程序中自己维护这两个表,每次调用select的时候将其赋值并传入函数。
sizeof(fd_set) = 128字节 * 8 = 1024bit。所以select函数最多只支持1024个fd,这不是故意为之,因为每个bit位对应着一个文件描述符。这是内核写死的,如果需要增大,可以通过修改内核代码并重新编译。但是因为文件描述符的限制只影响着一个进程,可以通过多开进程的方式来扩展支持更多的客户端,但是随着select时待检测的文件描述符越多,检测的效率会变得越来越低,因为它需要遍历整个fds文件描述符集合中的所有元素。而且还需要应用程序去维护着这个集合。
- poll的机制与select类似,二者都是通过线性的对文件描述符进行检测和轮询,也存在频繁的进行用户态和内核态的数据拷贝。但是poll相比于select,它打破了只支持1024的限制,但是poll只能在linux平台上使用。
poll函数原型:
struct pollfd {
int fd; /* 委托内核检测的文件描述符 */
short events; /* 委托内核检测文件描述符的什么事件 */
short revents; /* 文件描述符实际发生的事件 -> 传出 */
};
int poll(struct pollfd *fds, nfds_t nfds, int timeout);
fds:是struct pollfd的集合。这个结构体里面有三个成员:
fd->委托内核检测的文件描述符。
events->委托内核检测该fd的时间(输入 输出 错误)。
revents->内核将检测到的事件写入,然后返回给应用程序。
nfds:数组中最后一个有效元素的下标+1 也可以指定参数fds数组元素的总个数。
timeout:poll阻塞的超时时长。
返回值:
-1:失败
大于0:集合中已就绪的文件描述符总个数。
下面来介绍一下epoll函数。
先来说说epoll相对于select/poll的区别:
- 对于select/poll的线性处理方式来说,eopll使用红黑树来管理监测的集合显得更加高效。
- eopll使用回调机制,如果监测的fd有读写事件发生,内核会调用相应的回调函数来处理。所以epoll不会随着监测集合的变大而效率下降。
- epoll中内核和应用程序使用的是共享内存(基于mmap内存映射区实现),省去了不必要的内存拷贝 。
int epfd = epoll_create(1); //以前固定大小,后来变成链表,所以这个参数意义不大,只要大于0
struct epoll_event events[POLL_SIZE] = {0};
struct epoll_event ev;
ev.events = EPOLLIN;
ev.data.fd = listenfd;
epoll_ctl(epfd, EPOLL_CTL_ADD, listenfd, &ev);
while (1)
{
int nready = epoll_wait(epfd, events, POLL_SIZE, 5);
if(nready == -1)
{
continue;
}
int i = 0;
for (i = 0; i < nready; i++)
{
int clientfd = events[i].data.fd;
if(clientfd == listenfd) {
struct sockaddr_in client;
socklen_t len = sizeof(client);
if ((connfd = accept(listenfd, (struct sockaddr *)&client, &len)) == -1) {
printf("accept socket error: %s(errno: %d)\n", strerror(errno), errno);
return 0;
}
ev.events = EPOLLIN;
ev.data.fd = connfd;
epoll_ctl(epfd, EPOLL_CTL_ADD, connfd, &ev);
}else if(events[i].events & EPOLLIN) {
n = recv(clientfd, buff, MAXLNE, 0);
std::cout << "n : " << n << std::endl;
if (n > 0) {
buff[n] = '\0';
printf("recv msg from client: %s\n", buff);
send(clientfd, buff, n, 0);
} else if (n == 0) { //客户端close的时候
close(clientfd);
ev.events = EPOLLIN;
ev.data.fd = clientfd;
epoll_ctl(epfd, EPOLL_CTL_DEL, clientfd, &ev);
}
}
}
}
代码解读
- epoll_create初始化一个epoll句柄,在内核2.6.8版本之前,它的参数size代表着这个文件描述符集合的最大个数,但是内核2.6.8版本之后,这个参数是被忽略的,只要传递一个大于0的值即可。
- epoll_ctl用来管理红黑树上的节点,也就是内核监控的文件描述符集合,可以进行添加,删除和修改操作。
- 最主要的是epoll_wait();
函数原型:
int epoll_wait(int epfd, struct epoll_event * events, int maxevents, int timeout);
参数:
epfd:epoll_create () 函数的返回值,通过这个参数找到 epoll 实例。
events:传出参数,这是一个结构体数组的地址,里边存储了已就绪的文件描述符的信息。
maxevents:修饰第二个参数,结构体数组的容量(元素个数)。
timeout:epoll_wait阻塞超时时长。
返回值:
等于 0:函数是阻塞被强制解除了,没有检测到满足条件的文件描述符。
大于 0:检测到的已就绪的文件描述符的总个数。
-1:执行失败。
- 如果发生事件的fd个数大于maxevents会怎么办,没关系,下次epoll_wait会继续返回出来。
- epoll有两种工作模式:LT(水平触发)ET(边沿触发)
LT:当有读事件时,如果我们不作任何操作,或者缓冲区的数据未读完,那么内核还会继续通知使用者。支持阻塞和非阻塞的fd。
ET:当有读事件发生时,通知使用者,如果使用者不做任何操作或者缓冲区数据未读完,内核将不再会通知,直到有下次数据到达的时候才会再次通知。只支持 非阻塞IO。
所以ET比LT减少了epoll重复触发的次数。因此效率比LT高,但是LT减少了出错的概率。
为什么ET需要非阻塞的IO,因为当有时间到来时,我们需要循环的recv来读完缓冲区的数据,当缓冲区的数据读完时,对于阻塞IO的话 ,再次recv会阻塞起来。所以我们需要一个非阻塞的IO。
我们的listenfd不适合使用ET模式,因为当大量客户端上来时,可能会丢失某些连接。
4.异步IO
Linux 下的 asynchronous IO 用在磁盘 IO 读写操作,不用于网络 IO,从内核 2.6 版本才开始引入。
用户进程发起 read 操作之后,立刻就可以开始去做其它的事。而另一方面,从 kernel
的角度,当它受到一个 asynchronous read 之后,首先它会立刻返回,所以不会对用户进
程产生任何 block。然后,kernel 会等待数据准备完成,然后将数据拷贝到用户内存,当
这一切都完成之后,kernel 会给用户进程发送一个 signal,告诉它 read 操作完成了。
5.信号驱动 IO
首先我们允许套接口进行信号驱动 I/O,并安装一个信号处理函数,进程继续运行并不阻
塞。当数据准备好时,进程会收到一个 SIGIO 信号,可以在信号处理函数中调用 I/O 操作函
数处理数据。当数据报准备好读取时,内核就为该进程产生一个 SIGIO 信号。我们随后既可
以在信号处理函数中调用 read 读取数据报,并通知主循环数据已准备好待处理,也可以立
即通知主循环,让它来读取数据报。无论如何处理 SIGIO 信号,这种模型的优势在于等待数
据报到达(第一阶段)期间,进程可以继续执行,不被阻塞。免去了 select 的阻塞与轮询,当
有活跃套接字时,由注册的 handler 处理。
三、IO模型之间的区别
阻塞IO与非阻塞IO:阻塞IO会一直阻塞等待操作的完成。而非阻塞在内核在数据准备好之前都会立刻返回。
同步IO和异步IO:阻塞IO、非阻塞IO和多路复用IO都属于同步IO。都会阻塞进程。有人会问:非阻塞的IO为什么属于同步IO,这里说一下,当数据没准备好的时候,read返回立刻返回,而当内核把数据准备就绪的时候,这时候read就会被阻塞,知道数据从内核拷贝到用户进程内存后才返回。对于异步IO,当进程发起IO操作后,就可以直接不理睬了,直到内核发送信号告诉进程IO已经完成数据拷贝到内存了。所以这个过程中 。进程完全没有被阻塞。
非阻塞IO和异步IO:非阻塞IO在大部分时间都是不会被阻塞的,但是它人需要主动的去检查IO,并且当数据准备好后,将数据从内核拷贝到内存。而异步IO把IO操作交由内核完成。在此期间不需要主动的去检查IO状态和不需要主动的去进行数据拷贝。