目录
二. 生活的角度理解select poll epoll三种IO多路复用技术的工作模式.
前言.
- 小杰之前分析过的几种常见IO模型,以及给出了小杰对于IO多路复用技术的浅显理解. (上篇撸了很多代码,有着select poll epoll分别的服务端代码实现,感兴趣的可以阅读哈)
一. 谈信号驱动IO (对比异步IO来看)
-
信号驱动IO 对比 异步 IO进行理解
- 信号驱动IO: 内核将数据准备好的时候, 使用SIGIO信号通知应用程序进行IO操作
- 通知应用程序处理IO, 是开始处理IO, 这个时候还是存在阻塞的,将数据从内核态拷贝进入到用户态的过程至少是阻塞住的 (应用程序将数据从内核态拷贝到用户态的过程是阻塞等待的, 和异步IO的区别) (此处是区分信号驱动IO和异步IO的关键所在)
- 信号驱动IO, 我们提前在信号集合中设置好IO信号等待, 注册好对应的IO处理函数 handler,IO数据准备就绪后,会递交SIGIO信号,通知应用程序中断然后开始进行对应的IO处理逻辑. 但是通知处理IO的时候存在将数据从 内核空间拷贝到用户空间的过程,(而异步IO是数据拷贝完成之后内核再通知应用程序直接开始处理, 应用程序直接处理,不需要拷贝数据阻塞等待)
- 异步IO: 由内核在数据拷贝完成时, 通知应用程序(而信号驱动是告诉应用程序何时可以开始拷贝数据)
- 真正的做到了完完全全的非阻塞,发起aio_read之后应用程序立即可以去做其他的事情了. 调用了aio_read之后会立即进行返回继续向下执行应用程序,由kernel内核进行等待数据准备,只有当数据准备好了且拷贝到来用户空间,一切完成后,kernel给应用程序发送一个signal,告知它read完成了, 没有任何的阻塞,你直接处理就是
- 异步IO由于它不会对用户进程,应用程序产生任何的阻塞,所以他对于高并发网络服务器的实现至关紧要.
小结:
- 任何IO操作都是存在 等待数据准备完成 和 将 数据从内核态拷贝到用户态两个过程的
- 两个过程中等待数据消耗的时间一般远超于拷贝数据所花费的时间,所以一般我们进行IO的优化,都是想办法尽量降低等待时间
- 所以信号驱动IO 因为是通知开始处理数据,应用程序需要将数据从内核拷贝进入到用户态 (数据拷贝阻塞等待) 和异步IO的区别
- 异步IO 是完全不存在应用程序的阻塞等待,平时应用程序干自己的事情,当数据完全准备好了 (数据 完成了拷贝 ),直接通知应用程序回调处理数据
- 所以我们之前介绍的 blocking io non-blocking io io multiplexing (IO多路复用) 本质上都是属于 synchronous IO (同步IO) 都是存在有阻塞的,有人说不对吧: 哪 non-blocking IO 呢? 非阻塞IO仅仅只是在数据准备阶段上来说是非阻塞的,数据没准备好立马返回,可是数据拷贝阶段还是阻塞住的,所以本质还是同步IO. (大大的狡猾,忘记了阻塞除了准备数据的时候存在,拷贝数据也是阻塞住的)
- 只有异步IO asynchronous 是完全做到了整个过程非阻塞的 , 当进程发起IO操作之后,就直接返回再也不必理睬,直kernel 发送一个信号,告诉进程说IO完成(涵盖数据拷贝完成), 在这个过程中,是完全避免了阻塞进程了的
-
UDP + SIGIO信号注册模拟实现一下信号驱动IO
流程:
- 注册SIGIO的处理函数 (回调函数)
- 设置该套接口的属主,通常使用fcntl的F_SETOWN命令设置
3. 开启该套接口的信号驱动I/O,通常使用fcntl的F_SETFL命令打开O_ASYNC标志完成fcntl(fd, F_SETOWN, getpid());
实现代码 (简单的信号驱动服务端)
#include <signal.h> #include <sys/types.h> #include <unistd.h> #include <stdio.h> #include <stdlib.h> #include <fcntl.h> #include <stdlib.h> #include <sys/socket.h> #include <string.h> #include <arpa/inet.h> typedef struct sockaddr SA; #define BUFFSIZE 512 int sockfd = 0; //定义全局的sockfd //信号处理 void do_sigio(int signo) { char buff[512] = {0}; struct sockaddr_in cli_addr; socklen_t clilen = sizeof(cli_addr); int rlen = recvfrom(sockfd, buff, 512, 0, (SA*)&cli_addr, &clilen); //获取cli_addr, 为后面send做准备 printf("Recvfrom message: %s\n", buff); int slen = sendto(sockfd, buff, rlen, 0, (SA*)&cli_addr, clilen); } int main() { sockfd = socket(AF_INET, SOCK_DGRAM, 0); if (-1 == sockfd) { perror("socket"); return 2; } signal(SIGIO, do_sigio);//注册信号处理函数 //确定协议地址簇 struct sockaddr_in serv_addr; memset(&serv_addr, 0, sizeof(serv_addr)); serv_addr.sin_family = AF_INET; serv_addr.sin_port = htons(8080); serv_addr.sin_addr.s_addr = INADDR_ANY; //设置套接口属主 fcntl(sockfd, F_SETOWN, getpid()); //然后设置O_ASYNC 开启信号驱动IO int flags = fcntl(sockfd, F_GETFL); if (-1 == fcntl(sockfd, F_SETFL, flags | O_NONBLOCK | O_ASYNC)) { return 4; } if (-1 == bind(sockfd, (SA*)&serv_addr, sizeof(serv_addr))) { return 3; } while (1) sleep(1); close(sockfd); return 0; }
- 客户端代码:
#include <signal.h> #include <sys/types.h> #include <unistd.h> #include <stdio.h> #include <stdlib.h> #include <fcntl.h> #include <stdlib.h> #include <sys/socket.h> #include <string.h> typedef struct sockaddr SA; #defien BUFFSIZE 512 int sockfd = 0; //定义全局的listenfd int main(int argc, char* argv[]) { if (argc != 3) { fprintf(stderr, "usage: argv[0]<ip port>\n", argv[0]); return 1; } sockfd = socket(AF_INET, SOCK_DGRAM, 0); if (-1 == sockfd) { perror("socket"); return 2; } short port = atoi(argv[2]); const char* ip = argv[1]; //获取服务器协议地址簇 struct sockaddr_in serv_addr; memset(&serv_addr, 0, sizeof(serv_addr)); serv_addr.sin_family = AF_INET; serv_addr.sin_port = htons(port); inet_pton(AF_INET, ip, &serv_addr.sin_addr); //然后就是循环发送数据 char buffer[BUFFSIZE]; while (1) { printf("请说>>: "); scanf("%s", buffer); sendto(sockfd, buffer, strlen(buffer), 0 (SA*)&serv_addr, sizeof(serv_addr)); } return 0; }
二. 生活的角度理解select poll epoll三种IO多路复用技术的工作模式.
生活实例理解select 和 poll 工作原理
- 先抽象一个具体的场景出来:
- 假如说有这样一家餐厅。 一桌餐对应着一个服务员 (生活化IO事件),服务员只是负责服务,这个时候老板需要安排一个 (跑堂伙计 管理收集服务员获取的服务信息)
- select 便是这个跑堂伙计了
- 由于服务事件的类型可能不尽相同:所以跑堂伙计 开始的时候带着三个本子,分别记录不同的事件类型
- select(ionum, rfds, wfds, efds, timeout);
- rfds: 读事件集合 wfds写事件集合 efds异常事件集合
- ionum = maxfd + 1; fds {0, 1, 2, 3, 4 .....} fdsnum = maxfd + 1;
- 以上是一个生活中的一个小小栗子便于理解 select 工作模式,实际实现存在部分偏差
- 对应真实情景: select 之后是内核检测IO事件的发生,内核轮询所有的fd,内核重新设置底层的 fd_set :传入内核的时候 (内核如果知晓fd是否需要监视???) FD_SET: 然后内核会对于传入进去的fd_set 进行重新覆盖,没有IO事件发生的就像FD_CLR一样 将对应集合位图 位置上标记为0 有IO事件发生的就将对应位图位置标记为1 这样回到用户态之后从新进行轮询所有的 fd 就可以根据内核从新标记的发生IO事件的 位 来处理IO (select 内核 用户态两次轮询) 定时轮询,效率低下
int poll(struct pollfd *fds, nfds_t nfds, int timeout);
- poll的本质还是轮询。只不过破除了位图的限制,采取结构体存储IO事件,将三个本子合成一个本子了,而且破除了位图限制之后可以使用链式结构连接所有事件的结构体,没有了最大监视IO事件的限制了 (位图的fd_set的话大小是由内核开始确定的,如果修改大小比较麻烦,所以是存在fdnum上的限制的)
- poll 虽然理论上是没有了fdnum的限制了,但是随着fd的数量上升到一定程度,性能会急剧下降
生活理解epoll工作原理
- 还是先抽象场景出来:
- 存在这样一个小区的管理,小区里面很多的用户都存在寄快递的需求,每一次需要寄出快递的时候大家都统一的放入门卫室里面
- 快递员每一次来收取快递的时候不再需要挨家挨户的询问,收取,而是直接去门卫室将所有的快递放进自己的车子中带走处理即可 (门卫室相当于是readylist,不再需要轮询所有的IO事件是否发生,提高了效率)
- epoll_wait就是这个快递员:
- epoll_wait(管理的小区, 快递员存储快递包裹的容器, 容器可以容纳的快递数目,定时);
- epoll_wait(epfd, events, eventscap, timeout);
- epoll_create(size); //早期size标识最大居民数目,现在已经没有限制了,只有0和1的区别了, 因为可以进行链式存储,也就没有容量限制这一说了
- epoll_ctl(管理的小区, 小区居民搬入搬出修改的不同行为,新搬入居民的信息(标识), 描述需要寄出快递的类型信息 );
- epoll_ctl(epfd, op, fd, event); //epfd, epoll句柄,底层是红黑树 op:作何操作, fd : IO事件句柄, event:IO事件类型 (功能,向IO事件监视的红黑树上挂载新的监视IO事件,或者是删除监视,或者是修改监视事件类型)
epoll对比poll select优势出现小结: 将监视IO事件进行提前注册,挂载在内核的监视IO事件红黑树上,每一次调用epoll_wait 获取IO触发事件的时候不再需要传入待检测IO的事件,接口分离,功能分离,而且内核中采取了使用就绪队列存储红黑树上发生的IO事件结点的方式,这样每一次仅仅需要将就绪队列从内核中拷贝至用户空间拿取事件即可。。。
readylist放置触发IO事件, 使其不需要轮询获知IO触发的事件了, 提前注册挂载监视IO事件结点到红黑树上,也使得不需要每一次都从新拷贝监视事件进入内核空间,降低了拷贝消耗, 正是由于epoll的这两点优势好处使其成为稳定高效的多路复用技术,在高并发服务器的设计中随处可见epoll的身影
三. 细谈一下epoll的ET和LT
- ET : edge tigger边沿触发 LT : level tigger水平触发
- 简单理解一下两种触发模式:
- LT : 指的是 内核recv_buffer缓冲区中存在数据就一直会进行触发,处理数据, 读事件一直触发. 或者是 内核send_buffer缓冲区没有满,就一直触发写事件. 直到写满send_buffer
- ET : 指的是缓冲区状态发生变化之后引发触发,而且核心关键,仅仅只会触发一次 (边沿触发)
- 接收缓冲区recv_buffer 发生变化,数据从无到有,会触发一次读事件,核心,不论一次是否可以将数据完全处理,都只会触发一次
- 或者发送缓冲区send_buffer状态发生变化,也会触发写事件 (核心关键还在于触发一次)
- 上述的触发都指的是对于 epoll_wait 的触发.
总结:针对便于理解的读事件的触发, recv_buffer来理解, 如果说recv_buffer中有数据,如果是LT 就会不断地不停地触发, 如果是ET, 不管数据能不能处理完,都仅仅只会触发一次
- 光说不练是假把式,我们还是来一个实际地案例来解释一下:
#include <stdio.h> #include <stdlib.h> #include <unistd.h> #include <sys/types.h> #include <sys/socket.h> #include <string.h> #include <arpa/inet.h> #include <sys/epoll.h> #include <errno.h> typedef struct sockaddr SA; int main(int argc, char* argv[]) { if (argc != 2) { fprintf(stderr, "usage: %s <port>", argv[0]); return 1; } int sockfd = socket(AF_INET, SOCK_STREAM, 0); if (sockfd < 0) { perror("socket"); return 2; } struct sockaddr_in serv_addr; //确定协议地址簇 int port = atoi(argv[1]); memset(&serv_addr, 0, sizeof(serv_addr)); serv_addr.sin_family = AF_INET; serv_addr.sin_addr.s_addr = INADDR_ANY; serv_addr.sin_port = htons(port); if (-1 == bind(sockfd, (SA*)&serv_addr, sizeof(serv_addr))) { perror("bind"); return 3; } if (-1 == listen(sockfd, 5)) { perror("listen"); return 4; } //至此可以开始IO多路复用监视IO了 //创建出来内核红黑树地根结点(epoll句柄) int epfd = epoll_create(1); struct epoll_event ev, evs[512]; //监视新的连接到来IO事件 ev.events = EPOLLIN; ev.data.fd = sockfd; //将其挂载到红黑树上 epoll_ctl(epfd, EPOLL_CTL_ADD, sockfd, &ev); while (1) { //epoll_wait(epfd, 存储触发事件地容器传出参数, 容器大小size, timeout) int nready = epoll_wait(epfd, evs, 512, -1); if (nready < -1) { break; //出错 } int i = 0; for (i = 0; i < nready; ++i) { //处理各种IO事件, 存在各种封装形式 if (evs[i].events & EPOLLIN) { if (evs[i].data.fd == sockfd) { //新的连接到来 struct sockaddr_in cli_addr; socklen_t clilen; int clifd = accept(sockfd, (SA*)&cli_addr, &clilen); if (clifd < 0) return 5;//出错了嘛 char str[INET_ADDRSTRLEN] = {0}; //获取一下信息 printf("recv from %s at %d connection\n", inet_ntop(AF_INET, &cli_addr.sin_addr, str, sizeof(str)) , ntohs(cli_addr.sin_port)); //从新设置一下ev, 将新的监视IO事件挂载到红黑树上 ev.events = EPOLLIN | EPOLLET;//关键哈, EPOLLET使用地是边沿触发 ev.data.fd = clifd; epoll_ctl(epfd, EPOLL_CTL_ADD, clifd , &ev); continue; } //处理真正地读事件, 将缓冲区给小一点,等下才好看见效果 char buff[5] = {0}; int ret = recv(evs[i].data.fd, buff, 5, 0); if (ret < 0) { if (errno == EAGAIN || errno == EWOULDBLOCK) continue; else { //出错了 } //出错了将其从内核红黑树上移除,避免僵尸结点 ev.events = EPOLLIN; ev.data.fd = evs[i].data.fd; epoll_ctl(epfd, EPOLL_CTL_DEL, evs[i].data.fd, &ev); close(evs[i].data.fd); } else if (ret == 0) { printf("%d disconnection\n", evs[i].data.fd); //断开连接,从内核红黑树中移除监视 ev.events = EPOLLIN; ev.data.fd = evs[i].data.fd; epoll_ctl(epfd, EPOLL_CTL_DEL, evs[i].data.fd, &ev); close(evs[i].data.fd); //对端断开连接 } else { printf("recv %s, %d Bytes\n", buff, ret); //修改事件类型为写事件 } } if (evs[i].events & EPOLLOUT) { //此处暂时不写,仅仅只是测试一下读即可 } } } return 0; }
- 如上是使用ET地时候点一下地结果,没有设置非阻塞哈,结果是啥,我发送了这么一段话,它仅仅只是触发了一次,打印了一个Hello, why ? 我故意将缓冲区设置如此小,缓冲区状态改变,但是最多缓冲区仅仅存储5个数据,全发送了,后面再次循环过来,不触发了我去
- 如果需要一直触发直到recv_buffer内核缓冲区中没有数据,咋办。使用LT水平触发,如何设置,easy默认就是呀
我仅仅只是做了如此一个小小改动,默认LT触发,让我们康康效果
- 点了一次发送,他就一直触发,直到recv_buffer中没了数据
四. 总结本文
- 本文主要还是进行了IO地理解实战, 信号驱动IO 异步IO究竟区别在哪里?
- 异步IO 是完全不存在任何地应用程序挂起等待地, 其他哪些IO多多少少要么数据准备阶段要么数据拷贝阶段存在挂起等待阻塞
- 然后就是IO多路复用地生活化理解精进
- 最终介绍分析了epoll地ET 和 LT问题,这个超级重要好吧。大块数据使用 LT一次读,小块数据使用ET + 循环读(设置非阻塞) 出自大佬地结论
- LT: 水平触发,recv_buffer内核缓冲区中存在数据,则读事件一直不停地触发
- ET : 边沿触发,recv_buffer中数据从无到有,状态发生改变地时候进行触发,且关键是仅仅只会触发一次,不论你数据是不是一次可以读完,都只是触发一次