一、IO读写基本原理

IO读写分为read和write,read调用,并不是直接从物理磁盘读取,同样,write调用也不是直接把数据写进物理磁盘。这中间还有一层,缓冲区。而缓冲区又分为:内核缓冲区用户进程缓冲区

IO原理图

一次完整的Socket请求和相应如下:

  • 客户端调用:系统通过网卡读取客户端的请求,将数据读取到内核缓冲区;
  • 程序请求数据:程序通过read命令,将数据从内核缓冲区送入程序的用户缓冲区;
  • 程序服务端数据处理:在程序的用户空间中处理客户请求数据;
  • 程序服务端返回数据:数据处理完成,通过write命令,将构建好的响应数据从用户缓冲区写入内核缓冲区;
  • 发送数据到客户端:内核通过网络IO,将内核缓冲区中的数据通过网卡,适时地将数据发送给目标客户端

二、四种IO模型

介绍IO模型前,先介绍几个概念:

同步与异步:同步是指用户空间是主动发起IO请求的一方,内核空间是被动接受的一方。异步反之。

阻塞与非阻塞:阻塞是阻塞指的是用户空间发起请求后,需要等待内核空间彻底完成后,才将数据返回到用户空间;非阻塞指的是用户空间不需要等待内核空间完成,可以立即返回用户空间的过程。

同步阻塞IO(Blocking IO)

传统的IO模型都是同步阻塞IO,从发起IO调用开始,直到系统调用返回,调用进程都是阻塞的,直到有数据返回成功后,应用程序才能开始处理用户缓冲区的数据,具体流程如下:

同步阻塞IO
  • 优点:开发简单;在阻塞等待期间,用户线程挂起,在挂起期间不占用CPU资源
  • 缺点:一个线程维护一个IO,不适合大并发,并发量大的时候需要创建大量的线程来维护网络连接,内存、线程开销大

同步非阻塞IO(Non-blocking IO)

当处于阻塞IO时,用户空间和内核空间可以更快的交互,只要拿到内核返回的状态值,就可以继续处理自己的事,这种模式会有两种情况:

  • 在内核缓冲区没有数据时,系统调用马上返回失败状态给调用方
  • 在内核缓冲区有数据时,此时会阻塞,直到数据从内核缓冲区复制到用户缓冲区,复制完成后,系统调用返回成功,继而用户的应用进程开始处理用户空间的数据,具体流程如下:
同步非阻塞IO

上图的read请求2中,内核有数据但是还没准备好数据时,用户线程发起IO请求,会立即返回;为了最终能拿到数据,用户线程需要不断的发起IO调用,直到有数据到达内核时,用户线程会进入阻塞状态,当数据成功复制到用户缓冲区,用户线程成功读取到数据后,才会解除阻塞状态,重新运行。

  • 优点:每次发起IO调用,在内核等待数据的过程中可以立即返回,用户线程不会阻塞,实时性较好
  • 缺点:需要多个线程不断轮询内核是否有数据,占用大量CPU时间,效率不高。一般Web服务器不会采用这种模式

IO多路复用(IO Multiplexing)

为了解决同步非阻塞IO轮询效率低的问题,IO多路复用就出现了。

IO多路复用模型是建立在内核提供的多路分离函数select基础之上的,使用select函数可以避免同步非阻塞IO模型中轮询等待的问题。

在Linux系统中,对应的系统调用为select/epoll系统调用,当内核缓冲区可读或可写时,内核将就绪的状态主动通知相应的用户程序,应用程序收到就绪状态,进行相应的IO调用,具体流程:

IO多路复用
  1. 用户首先将需要进行IO操作的socket添加到select中,然后阻塞等待select系统调用返回
  2. 当数据到达时,socket被激活,select函数返回
  3. 用户线程正式发起read请求,读取数据并继续执行

使用select以后最大的优势是用户可以在一个线程内同时处理多个socket的IO请求。用户可以注册多个socket,然后不断地调用select读取被激活的socket,即可达到在同一个线程内同时处理多个IO请求的目的

经典的Reactor模式,也可称异步阻塞IO,就是这种设计模式,是众多高性能并发服务器的基础。

为什么叫异步阻塞IO呢?因为异步的还不够彻底,它使用的是会阻塞线程的select系统调用,并非真正的异步IO。

知乎上看到有个解释的比较形象的例子,可以帮助理解。

Reactor模式

Nginx、Netty、Redis等都是基于这种模式。

多路复用的几种实现方式

  • select
  • poll
  • epoll

select

调用过程

  1. 从用户空间将fd_set拷贝到内核空间(fd_set:一个存放文件描述符的信息的结构体,可以通过宏进行设置。)
  2. 注册回调函数
  3. 调用其对应的poll方法
  4. poll方法会返回一个描述读写是否就绪的mask掩码,根据这个mask掩码给fd_set赋值。
  5. 如果遍历完所有的fd都没有返回一个可读写的mask掩码,就会让select的进程进入休眠模式,直到发现可读写的资源后,重新唤醒等待队列里休眠的进程。
  6. 如果在规定的时间内都没有唤醒休眠的进程,那么进程会被唤醒重新获得CPU,再去遍历一次fd
  7. 将fd_set从内核空间拷贝到用户空间

优缺点

  • 缺点:两次拷贝耗时、轮询所有fd耗时,支持的文件描述符太小
  • 优点:跨平台支持

poll

调用过程和select完全一致

缺点:大量拷贝,水平触发(当报告了fd没有被处理,会重复报告,很耗性能)

优点:连接数(文件描述符)没有限制(链表存储)

epoll

调用过程

  1. 当调用epoll_wait函数的时候,系统会创建一个epoll对象,每个对象有一个evenpoll类型的结构体与之对应
  2. 文件的fd状态发生改变,就会触发fd上的回调函数
  3. 回调函数将相应的fd加入到rdlist,导致rdlist不空,进程被唤醒,epoll_wait继续执行
  4. 有一个事件转移函数——ep_events_transfer,它会将rdlist的数据拷贝到txlist上,并将rdlist的数据清空。
  5. ep_send_events函数,它扫描txlist的每个数据,调用关联fd对应的poll方法去取fd中较新的事件,将取得的事件和对应的fd发送到用户空间。如果fd是LT模式的话,会被txlist的该数据重新放回rdlist,等待下一次继续触发调用。

优点:没有最大并发连接的限制,只有活跃可用的fd才会调用callback函数,内存拷贝是利用mmap()文件映射内存的方式加速与内核空间的消息传递,减少复制开销。

只有存在大量的空闲连接和不活跃的连接的时候,使用epoll的效率才会比select/poll高

异步IO(Asynchronous IO)

异步IO,指的是用户空间与内核空间的调用方式反过来,内核空间是主动调用方,用户空间成为被动接收者。用户空间的线程向内核空间注册各种IO事件的回调函数,有内核主动触发调用。

异步非阻塞IO
  1. 用户发起read请求,内核立即返回成功状态,用户线程不会阻塞
  2. 内核开始准备数据,待数据准备好了,内核会主动将数据从内核缓冲区复制到用户缓冲区
  3. 此时,内核会给用户线程发一个信号,或者回调用户线程注册的回调接口,通知用户read操作完成了
  4. 用户线程收到通知后,读取用户缓冲区的数据,完成后续业务

异步IO优点:真正实现了异步非阻塞,吞吐量是这几种模式中最高的

异步IO缺点:应用程序只需要进行事件的注册和接收,其余工作由内核完成,所以需要内核的支持。在Linux系统中,异步IO在2.6才引入,底层仍然使用的epoll,与IO复用相同,因此性能上没有明显占优

最后引用知乎上的一个比较形象的回答来解释这四种IO模型

1. 阻塞IO, 给女神发一条短信, 说我来找你了, 然后就默默的一直等着女神下楼, 这个期间除了等待你不会做其他事情, 属于备胎做法.

2. 非阻塞IO, 给女神发短信, 如果不回, 接着再发, 一直发到女神下楼, 这个期间你除了发短信等待不会做其他事情, 属于专一做法.

3. IO多路复用, 是找一个宿管大妈来帮你监视下楼的女生, 这个期间你可以些其他的事情. 例如可以顺便看看其他妹子,玩玩王者荣耀, 上个厕所等等. IO复用又包括 select, poll, epoll 模式. 那么它们的区别是什么?

3.1 select大妈 每一个女生下楼, select大妈都不知道这个是不是你的女神, 她需要一个一个询问, 并且select大妈能力还有限, 最多一次帮你监视1024个妹子

3.2 poll大妈不限制盯着女生的数量, 只要是经过宿舍楼门口的女生, 都会帮你去问是不是你女神

3.3 epoll大妈不限制盯着女生的数量, 并且也不需要一个一个去问. 那么如何做呢? epoll大妈会为每个进宿舍楼的女生脸上贴上一个大字条,上面写上女生自己的名字, 只要女生下楼了, epoll大妈就知道这个是不是你女神了, 然后大妈再通知你.

4. 异步IO:你告诉女神我来了, 然后你就去王者荣耀了, 一直到女神下楼了, 发现找不见你了, 女神再给你打电话通知你, 说我下楼了, 你在哪呢? 这时候你才来到宿舍门口. 此时属于逆袭做法