写点什么

万字图解| 深入揭秘 IO 多路复用

作者:云舒编程
  • 2024-01-25
    广东
  • 本文字数:5901 字

    阅读完需:约 19 分钟

万字图解| 深入揭秘IO多路复用

大家好,我是「云舒编程」,今天我们来聊聊 IO 多路复用。


文章首发于微信公众号:云舒编程

关注公众号获取:1、大厂项目分享 2、各种技术原理分享 3、部门内推

前言

     通过前面的文章我们已经了解了「数据包从 HTTP 层->TCP 层->IP 层->网卡->互联网->目的地服务器」以及「数据包怎么从网线到进程,在被应用程序使用」涉及的知识。

     本文将继续介绍网络编程中的各种细节和 IO 多路复用的原理。

系列文章

图解 | 深入揭秘数据链路层、物理层工作原理

图解 | 深入揭秘IP层工作原理

图解 | 深入揭秘TCP工作原理

图解 | 深入揭秘HTTP工作原理

图解 | 深入揭秘Linux 接收网络数据包

图解 | 深入揭秘IO多路复用原理


通过本文你可学到:

  1. 阻塞 IO、非阻塞 IO 的区别、优缺点;

  2. IO 多路复用的原理,为什么高性能;

  3. select 原理、优缺点;

  4. poll 原理、优缺点;

  5. epoll 原理、优缺点

  6. select、poll、epoll 到底是同步 IO 还是异步 IO?

  7. epoll 边缘触发、水平触发的区别、适合场景

  8. 为什么在 Linux 网络编程中最好用非阻塞式 IO?

  9. Linux 常见网络 IO 事件定义

一、从一次网络调用说起


假设应用 A 想要请求应用 B 获取数据,那么他会经历以下步骤:


  1. 应用 A 将请求报文写入自己的 TCP 写缓冲区

  2. 应用 A 的请求报文经过网线到达应用 B 的 TCP 读缓冲区

  3. 应用 B 获得请求报文后,进行业务逻辑处理

  4. 应用 B 业务逻辑处理完成后,将响应报文写入自己的 TCP 写缓冲区,然后经过网线达到应用 A 的 TCP 读缓冲区


现在我们将注意力放到应用 A 上,应用 A 将请求发送出去后,就会开始调用系统调用从 TCP 读缓冲区读取数据,由于无法知道应用 B 什么时候会把响应数据返回,那么就会两种情况:


  1. 应用 B 响应速度很快,在应用 A 读取 TCP 缓冲区时,就已经把响应数据返回了。那么应用 A 就可以顺利获取到数据,皆大欢喜。

  2. 应用 B 响应比较慢,在应用 A 读取 TCP 缓冲区时,还没有将响应数据返回了。这个时候操作系统就面临两种选择:

  3.            选择一:把应用 A 的本次调用阻塞在这,等到应用 B 的数据达到了缓冲区再重新唤醒应用 A。


      选择二:告诉应用 A 你要的数据还没来,你等会再来问。

二、阻塞 IO

对于选择一,就是我们常说的阻塞式 IO。


学术点的说法:当用户进程发起 read 调用时,如果内核的数据没有准备好,那么操作系统就会把该进程挂起来,进入等待状态(不消耗 CPU)。直到数据准备好了或者发生了错误,该进程才会被唤醒。


整体流程如图:


三、非阻塞 IO

对于选择二,就是我们常说的非阻塞式 IO。


学术点的说法:当用户进程发起 read 调用时,如果内核的数据没有准备好,那么操作系统会返回一个 EAGAIN error,用户进程可以根据该 error 判断出是数据未准备好,可以等会再来问。如果在轮询期间,内核准备好数据了,用户进程就可以把数据拷贝到用户态空间了。


整体流程如图:


四、IO 多路复用

阻塞 IO 和非阻塞 IO 都是早期最常见的网络编程模型,但是他们有着致命的缺点。考虑如下场景:



  1. 每个用户请求都需要使用一个单独的线程进行服务,同时还要请求应用 B 才能完成业务逻辑。

  2. 由于不知道应用 B 的响应数据何时会返回,那么只能选择阻塞 IO 或者非阻塞 IO 进行轮询。


        然而阻塞 IO 会导致线程被挂起,非阻塞 IO 会导致线程一直处于轮询状态。这两种情况都会导致线程无法被释放或者复用。随着用户请求数的增多,应用 A 不得不创建更多的线程。


        然而对于操作系统来说,可以创建的线程是有上限的,并且过多的线程会导致线程切换的时间变多,严重时可能导致系统卡死,无法对外提供服务。这也是著名的 C10K 问题。


        为了解决这个问题,于是人们就提出了方案:由一个或者几个线程去监控多个网络请求,由他们去完成数据准备阶段的操作。当有数据准备就绪之后再分配对应的线程去读取数据,这样就可以使用少量的线程维护大量的网络请求,这就是 IO 多路复用。


IO 多路复用的复用指的是复用线程,而不是 IO 连接;目的是让少量线程能够处理多个 IO 连接。


IO 多路复用又主要由以下函数分别实现,分别是 select、poll、epoll。

select

select 是 Linux 最早支持 IO 多路复用的函数。

select 原理

通过 select 函数可以完成多个 IO 事件的监听。


#define __FD_SETSIZE 1024
typedef struct {unsigned long fds_bits[__FD_SETSIZE / (8 * sizeof(long))];} __kernel_fd_set;
struct timeval {time_t tv_sec; /* seconds */suseconds_t tv_usec; /* microseconds */};
//函数声明int select(int nfds, fd_set *readfds, fd_set *writefds, fd_set *exceptfds, struct timeval *timeout);
复制代码


函数参数:


  • readfds:内核检测该集合中的 IO 是否可读。如果想让内核帮忙检测某个 IO 是否可读,需要手动把文件描述符加入该集合。

  • writefds:内核检测该集合中的 IO 是否可写。同 readfds,需要手动加入

  • exceptfds:内核检测该集合中的 IO 是否异常。同 readfds,需要手动加入

  • nfds:以上三个集合中最大的文件描述符数值 + 1,例如集合是{0,1,4},那么 maxfd 就是 5

  • timeout:用户线程调用 select 的超时时长。

  • 设置成 NULL,表示如果没有 I/O 事件发生,则 select 一直等待下去。

  • 设置为非 0 的值,这个表示等待固定的一段时间后从 select 阻塞调用中返回。

  • 设置成 0,表示根本不等待,检测完毕立即返回。


函数返回值:


  • 大于 0:成功,返回集合中已就绪的 IO 总个数

  • 等于-1:调用失败

  • 等于 0:没有就绪的 IO


从上述的 select 函数声明可以看出,fd_set 本质是一个数组,为了方便我们操作该数组,操作系统提供了以下函数:


// 将文件描述符fd从set集合中删除 void FD_CLR(int fd, fd_set *set); 
// 判断文件描述符fd是否在set集合中 int FD_ISSET(int fd, fd_set *set);
// 将文件描述符fd添加到set集合中 void FD_SET(int fd, fd_set *set);
// 将set集合中, 所有文件描述符对应的标志位设置为0void FD_ZERO(fd_set *set);
复制代码

select 缺点

  1. fd_set 长度限制:由于 fd_set 本质是一个数组,同时操作系统限制了其长度,导致其只能接受文件描述符数值在 1024 以内的。

  2. select 函数的返回值是 int,导致每次返回后,用户得手动检测集合中哪些值被改为 1 了(被改为 1 的表示产生了 IO 就绪事件)

  3. 每次调用 select,都需要把 fd 集合从用户态拷贝到内核态,当 fd 很多时,开销很大。

  4. 由于 fd_set 本质是数组,所以每次内核都是线性扫描整个 fd_set,判断是否有 IO 就绪事件,导致随着监控的描述符 fd 数量增长,其性能会线性下降

poll

poll 是在 select 之后出现的另一种 I/O 多路复用技术。和 select 相比,它使用了不同的方式存储文件描述符,也解决文件描述符的个数限制。

poll 原理

struct pollfd {    int fd; /* file descriptor */    short events; /* events to look for */    short revents; /* events returned */};
int poll(struct pollfd *fds, unsigned long nfds, int timeout);
复制代码


函数参数:


  • fds:struct pollfd 类型的数组, 存储了待检测的文件描述符,struct pollfd 有三个成员:

  • fd:委托内核检测的文件描述符

  • events:委托内核检测的 fd 事件(输入、输出、错误),每一个事件有多个取值

  • revents:这是一个传出参数,数据由内核写入,存储内核检测之后的结果

  • nfds:描述的是数组 fds 的大小

  • timeout: 指定 poll 函数的阻塞时长

  • -1:一直阻塞,直到检测的集合中有就绪的 IO 事件,然后解除阻塞函数返回

  • 0:不阻塞,不管检测集合中有没有已就绪的 IO 事件,函数马上返回

  • 大于 0:表示 poll 调用方等待指定的毫秒数后返回


函数返回值:


  • -1:失败

  • 大于 0:表示检测的集合中已就绪的文件描述符的总个数


在 select 里面,文件描述符的个数已经随着 fd_set 的实现而固定,没有办法对此进行配置;而在 poll 函数里,我们可以自由控制 pollfd 结构的数组大小,从而突破 select 中面临的文件描述符个数的限制。

poll 缺点

poll 的实现和 select 非常相似,只是 poll 使用 pollfd 结构,而 select 使用 fd_set 结构,poll 解决了文件描述符数量限制的问题,但是同样需要从用户态拷贝所有的 fd 到内核态,也需要线性遍历所有的 fd 集合,所以它和 select 并没有本质上的区别。

epoll

epoll 是 Linux kernel 2.6 之后引入的新 I/O 事件驱动技术,它解决了 select、poll 在性能上的缺点,是目前 IO 多路复用的主流解决方案。


《The Linux Programming Interface》中用了一张图直观的展示了 select、poll、epoll 在不同数量的文件描述符下的性能。



从上图可以明显地看到,随着文件描述符数量的上涨,epoll 的性能还是很优异。而 select、poll 则随着描述符数量的上涨性能逐渐变差。

epoll 原理

epoll 的 API 非常简洁,由 3 个系统函数组成:


int epoll_create(int size);  int epoll_create1(int flags);
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);
struct epoll_event { __uint32_t events; epoll_data_t data;};
union epoll_data { void *ptr; int fd; uint32_t u32; uint64_t u64;};typedef union epoll_data epoll_data_t;
复制代码


  1. epoll_creare、epoll_create1:这两个函数的作用是一样的,都是创建一个 epoll 实例。

  2. epoll_ctl:在创建完 epoll 实例之后,可以通过调用 epoll_ctl 往 epoll 实例增加或删除需要监控的 IO 事件。

  3. epfd:调用 epoll_create 创建的 epoll 获得的返回值,可以简单理解成是 epoll 实例的唯一标识。

  4. op:表示是增加还是删除一个监控事件,它有三个选项可供选择:

  5. EPOLL_CTL_ADD: 向 epoll 实例注册文件描述符对应的事件;

  6. EPOLL_CTL_DEL:向 epoll 实例删除文件描述符对应的事件;

  7. EPOLL_CTL_MOD: 修改文件描述符对应的事件。

  8. fd:需要注册的事件的文件描述符。

  9. epoll_event:表示需要注册的事件类型,并且可以在这个结构体里设置用户需要的数据。

  10. events:表示需要注册的事件类型,可选值在下文的 Linux 常见网络 IO 事件定义中列出

  11. data:可以存放用户自定义的数据。

  12. epoll_wait:调用者进程调用该函数等待 I/O 事件就绪。

  13. epfd: epoll 实例的唯一标识。

  14. epoll_event:同 2.4

  15. maxevents:一个大于 0 的整数,表示 epoll_wait 可以返回的最大事件值。

  16. timeout:

  17. -1:一直阻塞,直到检测的集合中有就绪的 IO 事件,然后解除阻塞函数返回

  18. 0:不阻塞,不管检测集合中有没有已就绪的 IO 事件,函数马上返回

  19. 大于 0:表示 epoll 调用方等待指定的毫秒数后返回

epoll 工作流程

epoll 主要的结构对象为 eventpoll,其主要包含几个重要属性成员:


struct eventpoll {   /* Wait queue used by sys_epoll_wait() */ wait_queue_head_t wq;
/* List of ready file descriptors */ struct list_head rdllist;
/* RB tree root used to store monitored fd structs */ struct rb_root_cached rbr;}
复制代码


  • wq:当用户进程执行了 epoll_wait 导致了阻塞后,用户进程就会存储到这,等待后续数据准备完成后唤醒。

  • rdllist:当某个文件描述符就绪了后,就会从 rbr 移动到这。其本质是一个链表。

  • rbr:用户调用 epoll_ctl 增加的文件描述都存储在这,其本质是一颗红黑树。


其工作流程主要如图所示:


epoll 相比 select、poll 为什么高效?

  1. epoll 采用红黑树管理文件描述符

  2. 从上图可以看出,epoll 使用红黑树管理文件描述符,红黑树插入和删除的都是时间复杂度 O(logN),不会随着文件描述符数量增加而改变。

  3. select、poll 采用数组或者链表的形式管理文件描述符,那么在遍历文件描述符时,时间复杂度会随着文件描述的增加而增加。

  4. epoll 将文件描述符添加和检测分离,减少了文件描述符拷贝的消耗

  5. select&poll 调用时会将全部监听的 fd 从用户态空间拷贝至内核态空间并线性扫描一遍找出就绪的 fd 再返回到用户态。下次需要监听时,又需要把之前已经传递过的文件描述符再读传递进去,增加了拷贝文件的无效消耗,当文件描述很多时,性能瓶颈更加明显。

  6.      而 epoll 只需要使用 epoll_ctl 添加一次,后续的检查使用 epoll_wait,减少了文件拷贝的消耗。

select、poll、epoll 到底是同步 IO 还是异步 IO?


在神书《UNIX 网络编程》里,提供了 5 种 IO 模型的比较,可以看出无论是同步和异步 I/O,获取数据都分为了两步:


  1. 等待数据

  2. 将数据从内核拷贝到用户空间


判定一个 I/O 模型是同步还是异步,一般主要看第二步数据拷贝时是否会阻塞用户进程。基于这个原则,从上图可以看出只有异步 IO 才是异步的。其余的,包括基于 IO 多路复用的思路的 selec、poll、epoll 都是同步 IO。

epoll 的边缘触发与水平触发

水平触发(LT)

        关注点是数据,只要读缓冲区不为空,写缓冲区不满,那么 epoll_wait 就会一直返回就绪,水平触发是 epoll 的默认工作方式。

边缘触发(ET)

    关注点是变化,只要缓冲区的数据有变化,epoll_wait 就会返回就绪。


    这里的数据变化并不单纯指,缓冲区从有数据变为没有数据,或者从没有数据变为有数据,还包括了数据变多或者变少。换句话说,当 buffer 长度有变化时,就会触发。


    假设 epoll 被设置为了边缘触发,当客户端写入了 10 个字符,由于缓冲区从 0 变为了 10,于是服务端 epoll_wait 触发一次就绪,服务端读取了 2 个字节后不再读取。这个时候再去调用 epoll_wait 会发现不会就绪,只有当客户端再次写入数据后,才会触发就绪。


    这就导致如果使用 ET 模式,那就必须保证要「一次性把数据读取/写入完」,否则会导致数据长期无法读取/写入。


    LT 模式则没有这个问题。

为什么在 Linux 网络编程中最好用非阻塞式 IO?

    前面提到过,如果使用阻塞 IO,假如某个文件描述符长期不可读,那么对应的线程就会长期阻塞,导致资源被浪费。


    但是当使用了 select、poll、epoll 后,函数调用返回的 fd 都是就绪的,这种情况下还需要使用非阻塞 IO 吗?答案是肯定的,依旧需要使用非阻塞 IO,原因如下:

            1、fd 从返回就绪,到用户线程去 read。存在时间间隔,在这段时间,有可能该 fd 已经被其他线程读取了(惊群问题)。这个时候如果再去读取,如果是阻塞 IO,那么用户线程就会被阻塞了。

           2、fd 还有可能被内核抛弃了,这个时候如果再去读取,如果是阻塞 IO,那么用户线程就会被阻塞了。

          3、select、poll、epoll 只是返回了可读事件,并没有返回可读的数据量。因此,使用非阻塞 IO 的一般做法是读多次,直到不能读为止。而阻塞 IO 却不一样,每次读了数据后都需要重新调用 epoll_wait 再次判断是否可读,不能连续的读多次。因为如果上一次就把数据读完了,不判断就直接 read 就会导致用户线程阻塞。

Linux 常见网络 IO 事件定义


推荐阅读

1、原来阿里字节员工简历长这样

2、一条SQL差点引发离职

3、MySQL并发插入导致死锁


如果你也觉得我的分享有价值,记得点赞或者收藏哦!你的鼓励与支持,会让我更有动力写出更好的文章哦!

更多精彩内容,请关注公众号「云舒编程」


发布于: 2 小时前阅读数: 3
用户头像

云舒编程

关注

公众号 云舒编程,大白话分享技术原理 2020-09-23 加入

字节、阿里资深工程师。 做过营销、支付、百万级Feed流优化、权限系统、网关。 专注于技术原理分享,用最简单的话分享最复杂的技术原理

评论

发布
暂无评论
万字图解| 深入揭秘IO多路复用_异步_云舒编程_InfoQ写作社区