Netty从使用到源码1_IO模型与多路复用详解

一、IO常见模型概述

1.IO工作过程

1.1图示讲解

图示

在这里插入图片描述

上图的工作模式:

  • 开始时应用程序会发送一个请求给CPU,CPU得到通知后,此时CPU就需要调用操作系统内核程序(磁盘控制器)。这就是用户态->核心态
  • 磁盘控制器接到通知,使用DMA(Direct Memory Access)拷贝技术将数据放到PageCache内核缓冲区中,再由CPU将内核缓冲区的数据传回用户缓冲区(buffer),这就是内核态->用户态

1.2常用的IO模型

在请求数据是,无论是从磁盘中读数据还是从网络中接收数据,IO操作通常包括两个部分

  • 等待内核缓冲区的数据准备好
  • 将内核缓冲区的数据拷贝到用户缓冲区中(需要使用零拷贝优化,参考零拷贝)

常见的IO模型

同步IO

操作系统收到程序请求后,如果内核缓冲区中没有准备好数据,进程不会响应,直到数据准备好才会响应往下执行。包括:

  • 阻塞IO:程序请求操作系统IO,如果内核缓冲区中没有准备好数据,进程则会进行等待(阻塞)
  • 非阻塞IO:程序请求操作系统IO,如果内核缓冲区中没有准备好数据,进程会继续执行,不断进行系统调用直到IO数据准备好。
  • IO多路复用:后面详解

异步IO

操作系统收到程序请求后,如果内核缓冲区中没有准备好数据,会返回一个标记,程序继续往下执行。当数据准备好之后,会以事件的方式通知。

2.BIO

BIO(Blocking IO)即阻塞IO,Linux中默认情况下所有的IO操作都是blocking,一个典型的读操作流程大概如下

在这里插入图片描述

执行过程

当用户进程调用了recvfrom这个系统调用,kernel就开始了IO的第一个阶段:准备数据对于网络IO来说,很多时候数据在一开始还没有到达。比如,还没有收到一个完整的UDP包。这个时候kernel就要等待足够的数据到来),而数据被拷贝到操作系统内核的缓冲区中是需要一个过程的,这个过程需要等待。而在用户进程这边,整个进程会被阻塞(当然,是进程自己选择的阻塞)。

当kernel一直等到数据准备好了,它就会将数据从kernel中拷贝到用户空间的缓冲区以后,然后kernel返回结果,用户进程才解除block的状态,重新运行起来。

所以:blocking IO的特点就是在IO执行的下两个阶段的时候都被block了。

  • 等待数据准备就绪 (Waiting for the data to be ready) 「阻塞」
  • 将数据从内核拷贝到进程中 (Copying the data from the kernel to the process) 「阻塞」

3.NIO

应用进程执行系统调用之后,内核返回一个错误码。应用进程可以继续执行,但是需要不断的执行系统调用来获知I/O是否完成,这种方式称为轮询。(polling)。

在这里插入图片描述

工作流程

当用户进程调用了recvfrom这个系统调用,如果kernel中的数据还没有准备好,那么它并不会block用户进程,而是立刻返回一个EWOULDBLOCK error

从用户进程角度讲 ,它发起一个read操作后,并不需要等待,而是马上就得到了一个结果。用户进程判断结果是一个EWOULDBLOCK error时,它就知道数据还没有准备好,于是它可以再次发送read操作一旦kernel中的数据准备好了,并且又再次收到了用户进程的system call,那么它马上就将数据拷贝到了用户空间缓冲区,然后返回

可以看到,I/O 操作函数将不断的测试数据是否已经准备好,如果没有准备好,继续轮询,直到数据准备好为止。整个 I/O 请求的过程中,虽然用户线程每次发起 I/O 请求后可以立即返回,但是为了等到数据,仍需要不断地轮询、重复请求,消耗了大量的 CPU 的资源(自旋)

所以,non blocking IO的特点是用户进程需要不断的主动询问kernel数据好了没有

  • 等待数据准备就绪 (Waiting for the data to be ready) 「非阻塞」
  • 将数据从内核拷贝到进程中 (Copying the data from the kernel to the process) 「阻塞」

一般很少直接使用这种模型,而是在其他 I/O 模型中使用非阻塞 I/O 这一特性。这种方式对单个 I/O 请求意义不大,但给 I/O 多路复用铺平了道路.

4.AIO

在这里插入图片描述

用户进程发起aio_read调用之后,立刻就可以开始去做其它的事。

而另一方面,从kernel的角度,当它发现一个asynchronous read之后,首先它会立刻返回,所以不会对用户进程产生任何block。然后,kernel会等待数据准备完成,然后将数据拷贝到用户内存,当这一切都完成之后,kernel会给用户进程发送一个signal,告诉它read操作完成了

异步 I/O 模型使用了 Proactor 设计模式实现了这一机制。

因此对异步IO模型来说:

  • 等待数据准备就绪 (Waiting for the data to be ready) 「非阻塞」
  • 将数据从内核拷贝到进程中 (Copying the data from the kernel to the process) 「非阻塞」

二、IO多路复用详解

更详细多路复用参考

2.1阻塞IO

服务端为了处理客户端的连接和请求的数据,写了如下代码。

listenfd = socket();   // 打开一个网络通信端口
bind(listenfd);        // 绑定
listen(listenfd);      // 监听
while(1) {
  connfd = accept(listenfd);  // 阻塞建立连接
  int n = read(connfd, buf);  // 阻塞读数据
  doSomeThing(buf);  // 利用读到的数据做些什么
  close(connfd);     // 关闭连接,循环等待下一个连接
}

这段代码会执行得磕磕绊绊,就像这样。

在这里插入图片描述

  • 可以看到,服务端的线程阻塞在了两个地方,一个是 accept 函数一个是 read 函数
  • 如果再把 read 函数的细节展开,我们会发现其阻塞在了两个阶段。

在这里插入图片描述

这就是传统的阻塞 IO。

整体流程如下图。

所以,如果这个连接的客户端一直不发数据,那么服务端线程将会一直阻塞在 read 函数上不返回,也无法接受其他客户端连接这肯定是不行的

在这里插入图片描述

2.2非阻塞IO

为了解决上面的问题,其关键在于改造这个 read 函数

有一种聪明的办法是,每次都创建一个新的进程或线程,去调用 read 函数,并做业务处理。

while(1) {
  connfd = accept(listenfd);  // 阻塞建立连接
  pthread_create (doWork);  // 创建一个新的线程
}
void doWork() {
  int n = read(connfd, buf);  // 阻塞读数据
  doSomeThing(buf);  // 利用读到的数据做些什么
  close(connfd);     // 关闭连接,循环等待下一个连接
}

这样,当给一个客户端建立好连接后,就可以立刻等待新的客户端连接,而不用阻塞在原客户端的 read 请求上。

在这里插入图片描述

不过,这不叫非阻塞 IO只不过用了多线程的手段使得主线程没有卡在 read 函数上不往下走罢了。操作系统为我们提供的 read 函数仍然是阻塞的。

所以真正的非阻塞 IO,不能是通过我们用户层的小把戏,而是要恳请操作系统为我们提供一个非阻塞的 read 函数

非阻塞read

这个 read 函数的效果是,如果没有数据到达时(到达网卡并拷贝到了内核缓冲区),立刻返回一个错误值(-1),而不是阻塞地等待

操作系统提供了这样的功能,只需要在调用 read 前,将文件描述符设置为非阻塞即可

fcntl(connfd, F_SETFL, O_NONBLOCK);
int n = read(connfd, buffer) != SUCCESS);

这样,就需要用户线程循环调用 read,直到返回值不为 -1,再开始处理业务。
在这里插入图片描述

这里我们注意到一个细节。

  • 非阻塞的 read,指的是在数据到达前,即数据还未到达网卡,或者到达网卡但还没有拷贝到内核缓冲区之前,这个阶段是非阻塞的。
  • 当数据已到达内核缓冲区,此时调用 read 函数仍然是阻塞的,需要等待数据从内核缓冲区拷贝到用户缓冲区,才能返回。

整体流程如下图

在这里插入图片描述

2.3多路复用

2.3.1前导

为每个客户端创建一个线程,服务器端的线程资源很容易被耗光。

在这里插入图片描述

当然还有个聪明的办法,我们可以每 accept 一个客户端连接后,将这个文件描述符(connfd)放到一个数组里

fdlist.add(connfd);

然后开一个新的线程去不断遍历这个数组,调用每一个元素的非阻塞 read 方法。

while(1) {
  for(fd <-- fdlist) {
    if(read(fd) != -1) {
      doSomeThing();
    }
  }
}

这样,我们就成功用一个线程处理了多个客户端连接。

在这里插入图片描述

是不是觉得这有些多路复用的意思?

但这和我们用多线程去将阻塞 IO 改造成看起来是非阻塞 IO 一样,这种遍历方式也只是我们用户自己想出的小把戏,每次遍历遇到 read 返回 -1 时仍然是一次浪费资源的系统调用

在 while 循环里做系统调用,就好比你做分布式项目时在 while 里做 rpc 请求一样,是不划算的。

所以,还是得恳请操作系统老大,提供给我们一个有这样效果的函数,我们将一批文件描述符通过一次系统调用传给内核,由内核层去遍历,才能真正解决这个问题

2.3.2select函数

select 是操作系统提供的系统调用函数,通过它,我们可以把一个文件描述符的数组发给操作系统, 让操作系统去遍历,确定哪个文件描述符可以读写, 然后告诉我们去处理

在这里插入图片描述

select系统调用的函数定义如下。

int select(
    int nfds,
    fd_set *readfds,
    fd_set *writefds,
    fd_set *exceptfds,
    struct timeval *timeout);
// nfds:监控的文件描述符集里最大文件描述符加1
// readfds:监控有读数据到达文件描述符集合,传入传出参数
// writefds:监控写数据到达文件描述符集合,传入传出参数
// exceptfds:监控异常发生达文件描述符集合, 传入传出参数
// timeout:定时阻塞监控时间,3种情况
//  1.NULL,永远等下去
//  2.设置timeval,等待固定时间
//  3.设置timeval里时间均为0,检查描述字后立即返回,轮询

服务端代码,这样来写。

首先一个线程不断接受客户端连接,并把 socket 文件描述符放到一个 list 里。

while(1) {
  connfd = accept(listenfd);
  fcntl(connfd, F_SETFL, O_NONBLOCK);
  fdlist.add(connfd); //将连接即文件描述符放到一个集合里
}

然后,另一个线程不再自己遍历,而是调用 select,将这批文件描述符 list 交给操作系统去遍历

while(1) {
  // 把一堆文件描述符 list 传给 select 函数
  // 有已就绪的文件描述符就返回,nready 表示有多少个就绪的文件描述符
  nready = select(list);
  ...
}

不过,当 select 函数返回后,用户依然需要遍历刚刚提交给操作系统的 list。

只不过,操作系统会将准备就绪的文件描述符做上标识,用户层将不会再有无意义的系统调用开销。

while(1) {
  nready = select(list);
  // 用户层依然要遍历,只不过少了很多无效的系统调用
  for(fd <-- fdlist) {
    if(fd != -1) {
      // 只读已就绪的文件描述符
      read(fd, buf);
      // 总共只有 nready 个已就绪描述符,不用过多遍历
      if(--nready == 0) break;
    }
  }
}

正如刚刚的动图中所描述的,其直观效果如下

在这里插入图片描述

可以看出几个细节:

  • select 调用需要传入 fd 数组,需要拷贝一份到内核,高并发场景下这样的拷贝消耗的资源是惊人的。(可优化为不复制)
  • select 在内核层仍然是通过遍历的方式检查文件描述符的就绪状态,是个同步过程,只不过无系统调用切换上下文的开销。(内核层可优化为异步事件通知)
  • select 仅仅返回可读文件描述符的个数,具体哪个可读还是要用户自己遍历。(可优化为只返回给用户就绪的文件描述符,无需用户做无效的遍历)

整个 select 的流程图如下。

可以看到,这种方式,既做到了一个线程处理多个客户端连接(文件描述符),又减少了系统调用的开销(多个文件描述符只有一次 select 的系统调用 + n 次就绪状态的文件描述符的 read 系统调用)。

在这里插入图片描述

2.3.3poll()

poll 也是操作系统提供的系统调用函数。它和 select 的主要区别就是,去掉了 select 只能监听 1024 个文件描述符的限制

int poll(struct pollfd *fds, nfds_tnfds, int timeout);

struct pollfd {
  intfd; /*文件描述符*/
  shortevents; /*监控的事件*/
  shortrevents; /*监控事件中满足条件返回的事件*/
};

2.3.4epoll()

epoll 是最终的大 boss,它解决了 select 和 poll 的一些问题。

还记得上面说的 select 的三个细节么?

  • select 调用需要传入 fd 数组,需要拷贝一份到内核,高并发场景下这样的拷贝消耗的资源是惊人的。(可优化为不复制)
  • select 在内核层仍然是通过遍历的方式检查文件描述符的就绪状态,是个同步过程,只不过无系统调用切换上下文的开销。(内核层可优化为异步事件通知)
  • select 仅仅返回可读文件描述符的个数,具体哪个可读还是要用户自己遍历。(可优化为只返回给用户就绪的文件描述符,无需用户做无效的遍历)

所以 epoll 主要就是针对这三点进行了改进。

  • 内核中保存一份文件描述符集合,无需用户每次都重新传入,只需告诉内核修改的部分即可。
  • 内核不再通过轮询的方式找到就绪的文件描述符,而是通过异步 IO 事件唤醒。
  • 内核仅会将有 IO 事件的文件描述符返回给用户,用户也无需遍历整个文件描述符集合。

具体,操作系统提供了这三个函数。

第一步,创建一个 epoll 句柄

int epoll_create(int size);

第二步,向内核添加、修改或删除要监控的文件描述符。

int epoll_ctl(
  int epfd, int op, int fd, struct epoll_event *event);

使用起来,其内部原理就像如下一般丝滑。

在这里插入图片描述

2.3.5select/poll/epoll总结

  • select==> 时间复杂度 O(n) 它仅仅知道了,有 I/O 事件发生了,却并不知道是哪那几个流(可能有一个,多个,甚至全部),我们只能无差别轮询所有流,找出能读出数据,或者写入数据的流,对他们进行操作。所以 select 具有 O(n)的无差别轮询复杂度,同时处理的流越多,无差别轮询时间就越长。
  • poll==> 时间复杂度 O(n) poll 本质上和 select 没有区别,它将用户传入的数组拷贝到内核空间,然后查询每个 fd 对应的设备状态, 但是它没有最大连接数的限制,原因是它是基于链表来存储的.
  • epoll==> 时间复杂度 O(1) epoll 可以理解为 event poll,不同于忙轮询和无差别轮询,epoll 会把哪个流发生了怎样的 I/O 事件通知我们。 所以我们说 epoll 实际上是事件驱动(每个事件关联上 fd)的,此时我们对这些流的操作都是有意义的。(复杂度降低到了 O(1))select,poll,epoll 都是 IO 多路复用的机制。I/O 多路复用就通过一种机制,可以监视多个描述符,一旦某个描述符就绪 一般是读就绪或者写就绪),能够通知程序进行相应的读写操作。但 select,poll,epoll 本质上都是同步 I/O,因为他们都需要 在读写事件就绪后自己负责进行读写,也就是说这个读写过程是阻塞的,而异步 I/O 则无需自己负责进行读写,异步 I/O 的实现会负责把数据从内核拷贝到用户空间。

2.4总结

一切的开始,都起源于这个 read 函数是操作系统提供的,而且是阻塞的,我们叫它 阻塞 IO

为了破这个局,程序员在用户态通过多线程来防止主线程卡死

后来操作系统发现这个需求比较大,于是在操作系统层面提供了非阻塞的 read 函数,这样程序员就可以在一个线程内完成多个文件描述符的读取,这就是 非阻塞 IO

但多个文件描述符的读取就需要遍历,当高并发场景越来越多时,用户态遍历的文件描述符也越来越多,相当于在 while 循环里进行了越来越多的系统调用。

后来操作系统又发现这个场景需求量较大,于是又在操作系统层面提供了这样的遍历文件描述符的机制,这就是 IO 多路复用


多路复用有三个函数,最开始是 select,然后又发明了 poll 解决了 select 文件描述符的限制,然后又发明了 epoll 解决 select 的三个不足。

所以,IO 模型的演进,其实就是时代的变化,倒逼着操作系统将更多的功能加到自己的内核而已。

如果你建立了这样的思维,很容易发现网上的一些错误。

比如好多文章说,多路复用之所以效率高,是因为用一个线程就可以监控多个文件描述符。

这显然是知其然而不知其所以然,多路复用产生的效果,完全可以由用户态去遍历文件描述符并调用其非阻塞的 read 函数实现。而多路复用快的原因在于,操作系统提供了这样的系统调用,使得原来的 while 循环里多次系统调用,变成了一次系统调用 + 内核层遍历这些文件描述符

就好比我们平时写业务代码,把原来 while 循环里调 http 接口进行批量,改成了让对方提供一个批量添加的 http 接口,然后我们一次 rpc 请求就完成了批量添加。

三、参考

1. 常见IO模型

2.操作系统必会知识点

3. IO多路复用详解-敖丙

4.深度解密epoll如何工作-源码级别

5.select/poll/epoll讲解视频

6.select/poll/epoll讲解

7.小刘多路复用

7.更多内容参考

评论 8
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

shstart7

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值