IO模型专题


1. 什么是IO多路复用

IO多路复用是一种同步IO模型,实现一个线程可以监视多个文件句柄。一旦某个文件句柄就绪,就能通知应用程序进行相应的IO操作。没有文件句柄时就会发生阻塞,阻塞该线程,交出cpu。多路指多个文件句柄(网络连接),复用指同一个线程。

一句话解释:单线程或单进程同时监测若干个文件描述符是否可以执行IO操作的能力。


2. 常见IO模型

https://zhuanlan.zhihu.com/p/115220699

BIO 同步阻塞:

这是最常用的简单的IO模型。单线程下,阻塞IO意味着当我们发起一次IO操作后一直等待成功或失败之后才返回,在这期间程序不能做其它的事情,无法处理并发。阻塞IO操作只能对单个文件描述符进行操作,详见readwrite

在多线程下,当accept一个请求后,开启线程完成并发处理,但随着请求数增加需要增加系统线程,大量的线程占用很大的内存空间,并且线程切换时CPU上下文切换会带来很大的开销。

NIO 同步非阻塞:

我们在发起IO时,通过对文件描述符设置O_NONBLOCK flag来指定该文件描述符的IO操作为非阻塞。非阻塞IO通常发生在一个for循环当中,因为每次进行IO操作时要么IO操作成功,要么当IO操作会阻塞时返回错误EWOULDBLOCK/EAGAIN,然后再根据需要进行下一次的for循环操作,这种类似轮询的方式会浪费很多不必要的CPU资源,是一种糟糕的设计。和阻塞IO一样,非阻塞IO也是通过调用readwrite来进行操作的,也只能对单个描述符进行操作。

服务器端当accept一个请求后,加入fds集合,每次轮询一遍fds集合recv(非阻塞)数据,没有数据则立即返回错误,每次轮询所有fd(包括没有发生读写事件的fd)会很浪费cpu。

与上面同步阻塞BIO不同的是,BIO当accept 一个请求之后会一直等着这个连接就绪、读、写等;而同步非阻塞NIO是接收一个请求后,把这个请求放到一个队列或集合中,然后遍历这个集合,查看有没有就绪的连接,有就绪的就会进行同步的IO操作,没有就绪的连接,继续accept其他请求并加入队列。

  1. setNonblocking(listen_fd)
  2. // 伪代码描述
  3. while(1) {
  4. // accept非阻塞(cpu一直忙轮询)
  5. client_fd = accept(listen_fd)
  6. if (client_fd != null) {
  7. // 有人连接
  8. fds.append(client_fd)
  9. } else {
  10. // 无人连接
  11. }
  12. for (fd in fds) {
  13. // recv非阻塞
  14. setNonblocking(client_fd)
  15. // recv 为非阻塞命令
  16. if (len = recv(fd) && len > 0) {
  17. // 有读写数据
  18. // logic
  19. } else {
  20. //无读写数据
  21. }
  22. }
  23. }

IO多路复用

IO多路复用在Linux下包括了三种,selectpollepoll,抽象来看,他们功能是类似的,但具体细节各有不同:首先都会对一组文件描述符进行相关事件的注册,然后阻塞等待某些事件的发生或等待超时。更多细节详见下面的。IO多路复用都可以关注多个文件描述符,但对于这三种机制而言,不同数量级文件描述符对性能的影响是不同的,下面会详细介绍。

多路复用与上面同步非阻塞NIO类似,也是会把一个IO事件的描述符(可以理解为ID)放到一个队列或集合中。但这个轮询判断有没有可用就绪连接的过程交给了内核去做,而不再像NIO那样由程序去做。程序逻辑要做的只是调用select poll epoll获取就绪连接,没有就绪连接则会一直阻塞,下面详细说。

采用单线程通过select/epoll/poll等系统调用获取fds列表,遍历有事件的fd 进行accept/recv/send,使其能支持更多的并发连接请求。

信号驱动IO

信号驱动IO是利用信号机制,让内核告知应用程序文件描述符的相关事件。这里有一个信号驱动IO相关的例子

但信号驱动IO在网络编程的时候通常很少用到,因为在网络环境中,和socket相关的读写事件太多了,比如下面的事件都会导致SIGIO信号的产生:

  1. TCP连接建立
  2. 一方断开TCP连接请求
  3. 断开TCP连接请求完成
  4. TCP连接半关闭
  5. 数据到达TCP socket
  6. 数据已经发送出去(如:写buffer有空余空间)

上面所有的这些都会产生SIGIO信号,但我们没办法在SIGIO对应的信号处理函数中区分上述不同的事件,SIGIO只应该在IO事件单一情况下使用,比如说用来监听端口的socket,因为只有客户端发起新连接的时候才会产生SIGIO信号。

异步IO

异步IO和信号驱动IO差不多,但它比信号驱动IO可以多做一步:相比信号驱动IO需要在程序中完成数据从用户态到内核态(或反方向)的拷贝,异步IO可以把拷贝这一步也帮我们完成之后才通知应用程序。我们使用 aio_read 来读,aio_write 写。

同步IO vs 异步IO 1.同步IO指的是程序会一直阻塞的IO操作,如等待read、write完成。2.异步IO指的是IO操作不会阻塞当前程序的继续执行 所以根据这个定义,上面阻塞IO当然算是同步的IO,非阻塞IO也是同步IO,因为当文件操作符可用时我们还是需要阻塞的读或写,同理IO多路复用和信号驱动IO也是同步IO,只有异步IO是完全完成了数据的拷贝之后才通知程序进行处理,程序中没有阻塞的数据读写过程。


2. 为什么要有多路复用机制

凡事都是有成本的,线程/进程也一样,有这么几个方面:

  1. 线程/进程创建成本
  2. CPU切换不同线程/进程成本 Context Switch
  3. 多线程的资源竞争

有没有一种可以在单线程/进程中处理多个事件流的方法呢?一种答案就是IO多路复用。

因此IO多路复用解决的本质问题是在用更少的资源完成更多的事。同时单线程DMA的处理网络连接也能避免造成数据的丢失,为数据的不丢失性提供保障。

单线程通过select/epoll/poll等系统调用获取fds列表,遍历有事件的fd 进行accept/recv/send,使其能支持更多的并发连接请求。

  1. fds = [listen_fd]
  2. // 伪代码描述
  3. while(1) {
  4. // 通过内核获取有读写事件发生的fd,只要有一个则返回,无则阻塞
  5. // 整个过程只在调用select、poll、epoll这些调用的时候才会阻塞,accept/recv是不会阻塞
  6. for (fd in select(fds)) {
  7. if (fd == listen_fd) {
  8. client_fd = accept(listen_fd)
  9. fds.append(client_fd)
  10. } elseif (len = recv(fd) && len != -1) {
  11. // logic
  12. }
  13. }
  14. }

3. IO多路复用的实现方式

Linux:

  • select
  • poll
  • epoll

Mac:kqueue

Windows:IOCP


4. select函数接口、使用示例、缺点

接口

  1. #include <sys/select.h>
  2. #include <sys/time.h>
  3. #define FD_SETSIZE 1024
  4. #define NFDBITS (8 * sizeof(unsigned long))
  5. #define __FDSET_LONGS (FD_SETSIZE/NFDBITS)
  6. // 数据结构 (bitmap)
  7. typedef struct {
  8. unsigned long fds_bits[__FDSET_LONGS];
  9. } fd_set;
  10. // API
  11. int select(
  12. int max_fd,
  13. fd_set *readset, // 读 写 异常三张bitmap
  14. fd_set *writeset,
  15. fd_set *exceptset,
  16. struct timeval *timeout
  17. ) // 返回值就绪描述符的数目
  18. FD_ZERO(int fd, fd_set* fds) // 清空集合
  19. FD_SET(int fd, fd_set* fds) // 将给定的描述符加入集合
  20. FD_ISSET(int fd, fd_set* fds) // 判断指定描述符是否在集合中
  21. FD_CLR(int fd, fd_set* fds) // 将给定的描述符从文件中删除

示例

  1. int main() {
  2. /*
  3. * 这里进行一些初始化的设置,
  4. * 包括socket建立,地址的设置等,
  5. */
  6. fd_set read_fs, write_fs;
  7. struct timeval timeout;
  8. int max = 0; // 用于记录最大的fd,在轮询中时刻更新即可
  9. // 初始化比特位
  10. FD_ZERO(&read_fs);
  11. FD_ZERO(&write_fs);
  12. int nfds = 0; // 记录就绪的事件,可以减少遍历的次数
  13. while (1) {
  14. // 1.阻塞获取 将所有的 fd_set 传入内核 让它帮我们监听是否有事件准备就绪
  15. // 2.每次需要把多有的fd_set从用户态拷贝到内核态 read_fd write_fd...
  16. // 3.nfds 表示内核返回给你有多少个 fd 准备就绪了,并将有数据的fd在bitmap中置位
  17. nfds = select(max + 1, &read_fd, &write_fd, NULL, &timeout); // 这里的max+1主要是为了告诉内核最大fd的,以便内核监听
  18. // 1.用户态的进程是不知道到底哪个 fd 准备就绪的,那么它就需要再遍历一遍之前需要遍历所有的 fd
  19. // 2.判断有无读写事件发生 看下哪几个数据准备就绪了,然后进行处理
  20. for (int i = 0; i <= max && nfds; ++i) {
  21. if (i == listenfd) {
  22. --nfds;
  23. // 这里处理accept事件
  24. FD_SET(i, &read_fd);//将客户端socket加入到集合中
  25. }
  26. if (FD_ISSET(i, &read_fd)) {
  27. --nfds;
  28. // 这里处理read事件
  29. }
  30. if (FD_ISSET(i, &write_fd)) {
  31. --nfds;
  32. // 这里处理write事件
  33. }
  34. }
  35. }
  36. }

缺点

  • 单个进程所打开的FD是有限制的,通过FD_SETSIZE设置,默认1024
  • 每次调用select,都需要把fd_set(文件描述符标记的bitmap)从用户态拷贝到内核态,这个开销在fd很多时会很大。这个fd_set由于会改变,所以不可重用。
  • 唤醒的时候由于进程不知道是哪个 fd 已经就绪,需要进行一次遍历,采用轮询的方法,效率较低(高并发时)

5. poll函数接口、使用示例、缺点

poll 跟 select 相似,但对其进行了部分优化,比如单进程能打开的文件描述符数量不再受限制,并可以自定义。相比select,只是从bitmap标记改到了结构体数组的实现方式,没有fd的限制,其它基本一样。仍然有把数组从用户态改到内核态的过程。

接口

  1. #include <poll.h>
  2. // 数据结构
  3. // 没有使用bitmap带来了一定好处
  4. struct pollfd {
  5. int fd; // 需要监视的文件描述符
  6. short events; // 需要内核监视的事件
  7. short revents; // 实际发生的事件
  8. };
  9. // API
  10. int poll(struct pollfd fds[], nfds_t nfds, int timeout);

代码示例

  1. // 先宏定义长度
  2. #define MAX_POLLFD_LEN 4096
  3. int main() {
  4. /*
  5. * 在这里进行一些初始化的操作,
  6. * 比如初始化数据和socket等。
  7. */
  8. int nfds = 0;
  9. pollfd fds[MAX_POLLFD_LEN];
  10. memset(fds, 0, sizeof(fds));
  11. fds[0].fd = listenfd;
  12. fds[0].events = POLLRDNORM;
  13. int max = 0; // 队列的实际长度,是一个随时更新的,也可以自定义其他的
  14. int timeout = 0;
  15. int current_size = max;
  16. while (1) {
  17. // 1. 阻塞获取 每次需要把pollfd数组从用户态拷贝到内核态
  18. // 2. 当内核监听到有事件就绪的时候,会把pollfd结构体中的revents置成内核监听事件,并返回就绪数目
  19. nfds = poll(fds, max+1, timeout);
  20. if (fds[0].revents & POLLRDNORM) {
  21. // 这里处理accept事件
  22. connfd = accept(listenfd);
  23. //将新的描述符添加到读描述符集合中
  24. }
  25. // 每次需要遍历所有fd,判断有无读写事件发生
  26. for (int i = 1; i < max; ++i) {
  27. // 可以修改fds[i].fd 和 fds[i].revents = 0 实现复用
  28. if (fds[i].revents & POLLRDNORM) {
  29. sockfd = fds[i].fd
  30. if ((n = read(sockfd, buf, MAXLINE)) <= 0) {
  31. // 这里处理read事件
  32. if (n == 0) {
  33. close(sockfd);
  34. fds[i].fd = -1;
  35. }
  36. } else {
  37. // 这里处理write事件
  38. }
  39. if (--nfds <= 0) {
  40. break;
  41. }
  42. }
  43. }
  44. }
  45. }

优点

  • 相比select 文件描述符的数量已不再受限制
  • 可以修改fds[i].fd文件描述符 和 fds[i].revents = 0 实现数组的复用

缺点

  • 每次调用poll,都需要把pollfd集合从用户态拷贝到内核态,这个开销在fd很多时会很大
  • 对fd集合扫描时仍然是线性扫描,采用轮询的方法,效率较低(高并发时)

6. epoll函数接口、使用示例、缺点

epoll 的出现相较于 select 晚了几年,它对 select,poll 进行了大幅度的优化。

  1. #include <sys/epoll.h>
  2. // 数据结构
  3. // 每一个epoll对象都有一个独立的eventpoll结构体
  4. // 用于存放通过epoll_ctl方法向epoll对象中添加进来的事件
  5. // epoll_wait检查是否有事件发生时,只需要检查eventpoll对象中的rdlist双链表中是否有epitem元素即可
  6. struct eventpoll {
  7. /*红黑树的根节点,这颗树中存储着所有添加到epoll中的需要监控的事件*/
  8. struct rb_root rbr;
  9. /*双链表中则存放着将要通过epoll_wait返回给用户的满足条件的事件*/
  10. // 就绪队列
  11. struct list_head rdlist;
  12. };
  13. // API
  14. // 内核中加一个 eventpoll 对象,把所有需要监听的 fd 都放到这个对象中
  15. int epoll_create(int size);
  16. // epoll_ctl 负责把 fd 增加、删除到内核红黑树
  17. int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);
  18. // epoll_wait 负责检测红黑树,就绪则放到可读队列,没有可读 fd 则阻塞进程
  19. int epoll_wait(int epfd, struct epoll_event * events, int maxevents, int timeout);
  20. struct epoll_event {
  21. __uint32_t events; // 回调事件
  22. epoll_data_t data; // 存储fd等
  23. };

示例

  1. int main(int argc, char* argv[])
  2. {
  3. /*
  4. * 在这里进行一些初始化的操作,
  5. * 比如初始化数据和socket等。
  6. */
  7. // 内核中创建eventpoll对象
  8. epfd=epoll_create(256);
  9. // 需要监听的epfd 放到 eventpoll中
  10. epoll_ctl(epfd,EPOLL_CTL_ADD,listenfd,&ev);
  11. while(1) {
  12. // 阻塞获取 准备好了的 epfd。
  13. // 内核监测到就绪的事件之后,把时间放到rdlist
  14. nfds = epoll_wait(epfd,events,20,0);
  15. // 这里就直接对收到就绪的rdlist 进行遍历 不需要再遍历所有的 fd
  16. // 是怎么做到的呢,下面继续分析
  17. for(i=0;i<nfds;++i) {
  18. if(events[i].data.fd==listenfd) {
  19. // 这里处理accept事件
  20. connfd = accept(listenfd);
  21. // 接收新连接写到内核对象中
  22. epoll_ctl(epfd,EPOLL_CTL_ADD,connfd,&ev);
  23. } else if (events[i].events&EPOLLIN) {
  24. // 这里处理read事件
  25. read(sockfd, BUF, MAXLINE);
  26. //读完后准备写
  27. epoll_ctl(epfd,EPOLL_CTL_MOD,sockfd,&ev);
  28. } else if(events[i].events&EPOLLOUT) {
  29. // 这里处理write事件
  30. write(sockfd, BUF, n);
  31. //写完后准备读
  32. epoll_ctl(epfd,EPOLL_CTL_MOD,sockfd,&ev);
  33. }
  34. }
  35. }
  36. return 0;
  37. }

就绪队列

epoll实际是注册一个eventpoll到内核, 进程A调用epoll_wait,没有事件eventpoll上面挂起的进程 A等待,此时进程 A 是处于被阻塞状态的。当rdlist有事件就绪的时候,被唤醒。后序补图

就绪队列就是上图的 rdlist 它是 eventpoll 的一个成员,指的是内核中有哪些数据已经准备就绪。这个是怎么做到的呢,当我们调用 epoll_ctl() 的时候会为每一个 socket 注册一个回调函数,当某个 socket 准备好了就会回调然后加入 rdlist 中的,rdlist 的数据结构是一个双向链表。

epoll 提升了系统的并发,有限的资源提供更多的服务较于 select、poll 优势总结如下

  • 内核监视 fd 的时候不再需要每次传入所有的 fd 文件描述符,它只需通过一次 epoll_ctl 即可
  • select、poll 模型下进程收到了 sockets 准备就绪的指令执行后,它不知道到底是哪个 fd 就绪了,需要去遍历所有的 fd,而 epoll 维护了一个 rdlist 通过回调的方式将就绪的 fd 插入到 rdlist 链表中,我们可以直接遍历 rdlist 即可,提升效率

最后我们考虑下 epoll 的适用场景,只要同一时间就绪列表不要太长都适合。比如 Nginx 它的处理都是及其快速的,如果它为每一个请求还创建一个线程,这个开销情况下它还如何支持高并发。

缺点

  • epoll只能工作在linux下

https://juejin.cn/post/6844903954917097486 https://juejin.cn/post/6844904200141438984


7. epoll 水平触发(LT)与 边缘触发(ET)

水平触发level-triggered,边缘触发edge-triggered。这两个概念来自电路,triggered代表电路激活,也就是有事件通知给程序。

水平触发LT表示只要有IO操作可以进行比如某个文件描述符有数据可读,每次调用epoll_wait都会返回以通知程序可以进行IO操作。

边缘触发ET表示只有在文件描述符状态发生变化时,调用epoll_wait才会返回,如果第一次没有全部读完该文件描述符的数据而且没有新数据写入,再次调用epoll_wait都不会有通知给到程序,因为文件描述符的状态没有变化。

select和poll都是状态持续通知的机制,且不可改变,只要文件描述符中有IO操作可以进行,那么select和poll都会返回以通知程序。而epoll两种通知机制可选。

一个例子

  1. 状态变化通知(edge-triggered)模式下的epoll
  2. epoll状态变化通知机制下,有一些的特殊的地方需要注意。考虑下面这个例子
  3. 1. 服务端文件描述符rfd代表要执行read操作的TCP socketrfd已被注册到一个epoll实例中
  4. 2. 客户端向rfd写了2kb数据
  5. 3. 服务端调用epoll_wait返回,rfd可执行read操作
  6. 4. 服务端从rfd中读取了1kb数据
  7. 5. 服务端又调用了一次epoll_wait

在第5步的epoll_wait调用不会返回,而对应的客户端会因为服务端没有返回对应的response而超时重试,原因就是我上面所说的,epoll_wait只会在状态变化时才会通知程序进行处理。第3步epoll_wait会返回,是因为客户端写了数据,导致rfd状态被改变了,第3步的epoll_wait已经消费了这个事件,所以第5步的epoll_wait不会返回。

我们需要配合非阻塞IO来解决上面的问题:

  1. 对需要监听的文件描述符加上非阻塞IO标识
  2. 只在read或者write返回EAGAIN或EWOULDBLOCK错误时,才调用epoll_wait等待下次状态改变发生

通过上述方式,我们可以确保每次epoll_wait返回之后,我们的文件描述符中没有读到一半或写到一半的数据。

区别

epoll有EPOLLLT和EPOLLET两种触发模式,LT是默认的模式,ET是“高速”模式。

  • LT模式下,只要这个fd还有数据可读,每次 epoll_wait都会返回它的事件,提醒用户程序去操作
  • ET模式下,它只会提示一次,直到下次再有数据流入之前都不会再提示了,无论fd中是否还有数据可读。所以在ET模式下,read一个fd的时候一定要把它的buffer读完,或者遇到EAGAIN错误

8. select/poll/epoll之间的区别

select poll epoll
数据结构 bitmap 数组 红黑树+双链表
最大连接数 1024 无上限 无上限
fd拷贝 每次调用select拷贝 每次调用poll拷贝 fd首次调用epoll_ctl拷贝,每次调用epoll_wait不拷贝
工作效率 轮询:O(n) 轮询:O(n) 回调:O(1)
超时时间 精确到微妙 精确到毫秒 -
兼容性 更广 相对较窄 -

epoll的文件描述符可以在多个线程/进程共享,所以epoll的使用场景要比select&poll要多。


9. epoll应用

  • redis: Linux下 epoll(level-triggered),没有epoll用select
  • nginx:Linux下 epoll(edge-triggered),没有epoll用select

10. nginx/redis 所使用的IO模型是什么?

epoll

11. 代码示例

真正的源码解读

select poll epoll代码示例:https://github.com/caijinlin/learning-pratice/tree/master/linux/io

12. PIO和DMA

PIO

Programming Input/Output Model,在以前磁盘和内存之间的数据传输是通过CPU控制的。如果我们读取磁盘文件到内存中,数据要经过CPU存储转发,这种方式占用大量的CPU时间来读取文 件。

DMA

Direct Memory Access 直接内存访问,它可以不经过CPU直接进行磁盘和内存数据的传输。CPU只要下达指令,让DMA控制器来处理数据传输即可,DMA控制器通过系统总线传输数据,传输完毕再通知CPU,大大减少了CPU的占用时间

拿 Node Js 来说,它是单线程的,如果它从磁盘读取一个文件,那么其它的无法工作了,但其实读取文件是个很简单的工作,所以可以把 CPU 让出来,直接下单指令,让 DMA 去读取,等 DMA 读好了,会把数据放到内存中,然后中断来通知 CPU 说,你要的数据已经放到某个地址上了。