这次 moon 要把网络 I/O 一网打尽

2022-05-27 15:02:21 浏览数 (1)

  • 前言
  • 什么是 I/O ?
  • 网络 I/O 又是什么?
  • 为什么会有网络 I/O 模型?
  • I/O 模型
    • 阻塞 I/O
    • 非阻塞 I/O
    • 异步 I/O
    • 信号驱动模型
    • I/O 多路复用
  • 结语

前言

大家好,我是 moon,上一次和大家聊了一下 socket(这次 moon 要把 socket 玩的明明白白),相信大家对 socket 有了一定的认识,对于 socket 还不熟悉的同学,可以先看看 socket 这篇文章,今天这篇文章是基于 socket,再和大家讲一讲「网络I/O」相关的知识,也刚好为后续 netty 的文章做下铺垫

什么是 I/O ?

I:其实就是 「Input」,输入 O:其实就是 「Output」,输出 所以 I/O 很好理解,就是输入和输出

生活中最简单的例子,你用微信和别人聊天,你「发送信息」给对方,就是「输入」「对方回给你信息」,你接受到了,就是「输出」

一般情况下,在软件中我们常说的 I/O 是指「网络 I/O 和磁盘 I/O」,今天我们就来聊下网络 I/O

网络 I/O 又是什么?

其实网络 I/O 就是网络中的输入与输出,我们再说详细点,正常的网络通信中,一条消息发送的过程中有一个很重要的媒介,叫做「网卡」,它的作用有两个

  • 一是将电脑的数据封装为帧,并通过网线(对无线网络来说就是电磁波)将数据发送到网络上去
  • 二是接收网络上其它设备传过来的帧,并将帧重新组合成数据,发送到所在的电脑中。

网卡能「接收所有在网络上传输的信号」,但正常情况下只接受发送到该电脑的帧和广播帧,将其余的帧丢弃。

所以网络 I/O 其实网络与服务端(电脑内存)之间的输入与输出

为什么会有网络 I/O 模型?

一般情况下,一次网络数据的传输会从客户端发送给服务端,由服务端网卡接受,转交给内存,最后由 cpu 执行相应的业务操作

只要有一点电脑知识的读者大多数都知道,cpu、显卡、内存等电脑中你数得上名字的模块,运行效率最高的就是 cpu 了,所以「为了整个网络传输的提效,就诞生出了五种网络 I/O 模型」

当然还有一点原因

不管是客户端还是服务端,在发送和接受数据的时候,都是要与 socket 缓冲区去进行交互的,那么「什么时候交互?是否需要等待响应?」基于这些「不同的业务场景」,也就「有了五种网络 I/O 模型」

其实这个结论是通用的,「任何模型的调优改造健全一般都是围绕着性能、安全、业务场景这三个方向来前进的」

I/O 模型

好了,我们说完了 I/O 模型的诞生,就来具体的研究下具体是哪五种 I/O 模型

阻塞 I/O

阻塞 I/O,顾名思义,就是在各个状态完全阻塞住整个 I/O 过程是个串行化的,在数据传输开始时,都要等到后续的每一个操作完成后才可以继续,这也是 linux 系统默认的 I/O 模型,当然,这种模型的问题就是「效率非常的低」

第二个问题就是,如果是在生产环境多线程的情况下,会有频繁线程的「上下文切换」,而这个切换又是「非常消耗资源」的。

非阻塞 I/O

非阻塞 I/O 针对于阻塞 I/O 有了一些改进

在系统调用内核后,内核如果「没有准备好」数据,则直接会「返回一个错误码」,直到数据「准备好」后,「再走后续的流程」

中间会有一个「一直定时去询问」的动作,所以它的「缺点」也就是在这里,需要「一直去处理数据没有准备好的情况」,也「无法判断该数据多久会准备好」。但是在并发的情况下相比阻塞 I/O 来说它的性能会好很多

异步 I/O

其实通过阻塞或者非阻塞 I/O,你能发现一个问题,其实「拷贝数据都是由内核完成」的,那又「何必让应用进程去触发拷贝这个操作呢」,由内核直接完成不就好了?

所以就出现了异步 I/O 模型

比如有一个读取数据的请求,应用「只需要向内核发送一个 read」,告诉内核它要读取数据后即刻返回。

之后内核收到请求并且建立一个信号连接,当数据准备就绪,内核会主动把数据从内核复制到用户空间,「等所有操作都完成之后,内核再发起一个通知告诉应用」,完成了所有操作,应用程序可以读取数据了,这就是异步 I/O 模型。

异步 IO 的好处是「将发送询问请求、发送接收数据请求两个请求合并为一次请求」就可以完成状态询问和数拷贝的所有操作,并且「无阻塞」,内核准备好数据后直接通知。

信号驱动模型

信号驱动就是进程发起一个 IO 操作,会「向内核注册一个信号处理程序」,然后立即返回

「内核将数据报准备好」后会「发送一个信号」给进程,这时候进程便可以在信号处理程序中调用 IO 处理数据报。

I/O 多路复用

I/O 多路复用解决的问题就是「单线程怎么管理多个 socket 连接」,内核负责轮询所有 socket,一个进程可以监视多个描述符,一旦某个描述符就绪(一般是读就绪或者写就绪),能够通知程序进行相应的读写操作。

「其主要有 select、poll、epoll 三种多路复用的网络I/O模型」

select

select 的设计思路是唤醒模式,「通过一个 socket 列表维护所有的 socket」,socket 对应文件列表中的 fd,select 会默认限制最大文件句柄数为 1024,间接控制 fd[] 最大为 1024。

  • 如果列表中的 Socket 都「没有数据」,就挂起进程。
  • 如果有一个 Socket 收到数据,就唤醒进程,将该线程从等待队列中移除,加入到工作队列,然后准备执行 socket 任务。

那么有个问题来了,如果有多个 socket 任务同时唤醒怎么办,也就是说说有多个 socket 任务同时进来,那到底执行哪个 socket 任务?

所以,当进程被唤醒后,「至少有一个 socket 是有数据的」,这时候只需要「遍历一遍 socket 列表」就知道了此次需要执行哪些 socket 了。

缺点:

  • 每次 select 都需要将进程加入到监视 socket 的等待队列,每次唤醒都要将进程从 socket 待队列移除。这里涉及两次遍历操作,而且每次都要将 FDS 列表传递给内核,有一定的开销。
  • 进程被唤醒后,只能知道有 socket 接收到了数据,无法知道具体是哪一个 socket 接收到了数据,所以需要用户进程进行遍历,才能知道具体是哪个 socket 接收到了数据。
poll

poll 其实内部实现基本跟 select 一样,区别在于它们底层组织 fd[] 的数据结构不太一样,从而实现了 poll 的最大文件句柄数量限制去除了

epoll

我们前面说到 select 是需要遍历来解决查询就绪 socket 的,效率很低,epoll 就对此做了改进

  • 1.拆分:epoll 将添加等待队列和阻塞进程拆分成两个独立的操作,不用每次都去重新维护等待队列
    • 先用 epoll_ctl 维护等待队列 eventpoll,它通过红黑树存储 socket 对象,实现高效的查找,删除和添加。
    • 再调用 epoll_wait 阻塞进程,底层是一个双向链表。显而易见地,效率就能得到提升。

select 的添加等待队列和阻塞进程是合并在一起的,每次调用select()操作时都得执行一遍这两个操作,从而导致每次都要将fd[]传递到内核空间,并且遍历fd[]的每个fd的等待队列,将进程放入各个fd的等待队列中。

  • 2.直接返回有数据的 fd[]:select 进程被唤醒后,是需要遍历一遍 socket 列表,手动获取有数据的 socket,而 epoll 是在唤醒时直接把有数据的 socket 返回给进程,不需要自己去进行遍历查询。

「直接返回有数据的 socket 是怎么实现的?」

其实就是 epoll 会先注册一个文件描述符,一旦基于某个文件描述符就绪时,内核会采用类似 callback 的回调机制,迅速激活这个文件描述符,当进程调用 epoll_wait() 时便得到通知。

epoll对文件描述符的操作有两种模式:「LT」(level trigger)和 「ET」(edge trigger)默认为 LT :

  • LT模式:当epoll_wait检测到描述符事件发生并将此事件通知应用程序,应用程序可以不立即处理该事件。下次调用epoll_wait时,会再次响应应用程序并通知此事件。
  • ET模式:当epoll_wait检测到描述符事件发生并将此事件通知应用程序,应用程序必须立即处理该事件。如果不处理,下次调用epoll_wait时,不会再次响应应用程序并通知此事件。

结语

I/O 模型这块儿可是重中之重,作为网络通信的核心,只要你想去大厂一定会被问到,今天这篇文章应该帮你梳理清楚了各个网络 I/O 模型之间的关系以及优缺点,让你对网络 I/O 模型有了更进一步的了解,下次我们准备开始突击 netty ~

0 人点赞