IO模型

网络通信中,最底层的就是内核中的网络 I/O 模型了。

随着技术的发展,操作系统内核的网络模型衍生出了五种 I/O 模型,《UNIX 网络编程》一书将这五种 I/O 模型分为 阻塞式 I/O非阻塞式 I/OI/O 复用信号驱动式 I/O异步 I/O。每一种 I/O 模型的出现,都是基于前一种 I/O 模型的优化升级。

术语解释

IO

《UNIX网络编程 卷1:套接字联网API》6.2节”I/O 模型”
IO (Input/Output,输入/输出)即数据的读取(接收)或写入(发送)操作,通常用户进程中的一个完整IO分为两阶段:用户进程空间<–>内核空间、内核空间<–>设备空间(磁盘、网络等)。

IO有内存IO、网络IO和磁盘IO三种,通常我们说的IO指的是后两者。

IO发生时涉及的对象和步骤

LINUX中进程无法直接操作I/O设备,其必须通过系统调用请求kernel来协助完成I/O动作;内核会为每个I/O设备维护一个缓冲区。

对于一个network IO (这里我们以read举例),它会涉及到两个系统对象,一个是调用这个IO的process (or thread),另一个就是系统内核(kernel)。当一个read操作发生时,它会经历两个阶段:
1 等待数据准备 (Waiting for the data to be ready)
2 将数据从内核拷贝到进程中 (Copying the data from the kernel to the process)

阻塞和非阻塞

阻塞:服务端返回结果之前,客户端线程会被挂起,此时线程不可被CPU调度,线程暂停运行。
非阻塞:在服务端返回前,函数不会阻塞调用端线程,而会立刻返回。
阻塞和非阻塞的概念描述的是用户线程调用内核IO操作的方式
阻塞是线程维度的,当前线程是否被阻塞。

同步IO和异步IO

同步:调用端会一直等待服务端响应,直到返回结果。
异步:调用端发起调用之后不会立刻返回,不会等待服务端响应。服务端通过通知机制或者回调函数来通知客户端。
同步IO:导致请求进程阻塞,直到I/O操作完成。
异步IO:不导致请求进程阻塞。
同步异步是调用方(进程)维度的,即使某个任务(线程)被阻塞,但是(进程)可以去做其他任务。

核心态(Kernel model)和用户态(User model)

核心态(Kernel model)和用户态(User model),CPU会在两个model之间切换。

核心态代码拥有完全的底层资源控制权限,可以执行任何CPU指令,访问任何内存地址,其占有的处理机是不允许被抢占的。内核态的指令包括:启动I/O,内存清零,修改程序状态字,设置时钟,允许/终止中断和停机。内核态的程序崩溃会导致PC停机。

用户态是用户程序能够使用的指令,不能直接访问底层硬件和内存地址。用户态运行的程序必须委托系统调用来访问硬件和内存。用户态的指令包括:控制转移,算数运算,取数指令,访管指令(使用户程序从用户态陷入内核态)。

进程切换

为了控制进程的执行,内核必须有能力挂起正在CPU上运行的进程,并恢复以前挂起的某个进程的执行。这种行为被称为进程切换。因此可以说,任何进程都是在操作系统内核的支持下运行的,是与内核紧密相关的。从一个进程的运行转到另一个进程上运行,这个过程中经过下面这些变化:

  1. 保存处理机上下文,包括程序计数器和其他寄存器。
  2. 更新PCB信息。
  3. 把进程的PCB移入相应的队列,如就绪、在某事件阻塞等队列。
  4. 选择另一个进程执行,并更新其PCB。
  5. 更新内存管理的数据结构。
  6. 恢复处理机上下文。

进程阻塞

正在执行的进程由于一些事情发生,如请求资源失败、等待某种操作完成、新数据尚未达到或者没有新工作做等,由系统自动执行阻塞原语,使进程状态变为阻塞状态。因此,进程阻塞是进程自身的一种主动行为,只有处于运行中的进程才可以将自身转化为阻塞状态。当进程被阻塞,它是不占用CPU资源的。

文件描述符(fd, File Descriptor)

FD用于描述指向文件的引用的抽象化概念。文件描述符在形式上是一个非负整数。实际上,它是一个索引值,指向内核为每一个进程所维护的该进程打开文件的记录表。当程序打开一个现有文件或者创建一个新文件时,内核向进程返回一个文件描述符。在程序设计中,一些涉及底层的程序编写往往会围绕着文件描述符展开。但是文件描述符这一概念往往只适用于UNIX、Linux这样的操作系统。

(2) IO模型的故事

故事情节为:张三去买火车票,三天后买到一张退票。
参演人员(张三,黄牛,售票员,快递员),往返车站耗费1小时。

(2.1) 阻塞I/O模型

张三去火车站买票,排队三天(同步)买到一张票(阻塞)。
耗费:在车站吃喝拉撒睡 3天,其他事一件没干。
优点: 简单,谁都能排队等
缺点: 耗费时间

(2.2) 非阻塞I/O模型

张三去火车站买票,隔12小时去火车站(异步)问有没有退票,三天后买到一张票(阻塞)。
耗费:往返车站6次,路上6小时,其他时间做了好多事。
优点: 不需要一直等(异步),可以省出时间做其它事
缺点: 需要往返多次

(2.3) I/O复用模型

(2.3.1) select/poll

张三去火车站买票,委托黄牛,然后每隔6小时电话黄牛询问(异步),黄牛三天内买到票,然后张三去火车站交钱领票(阻塞)。
耗费:往返车站2次,路上2小时,黄牛手续费100元,打电话17次
优点: 不需要自己等,
缺点: 需要看黄牛手上所有票,而且可能从黄牛手上拿错票

(2.3.2) epoll

张三去火车站买票,委托黄牛,黄牛买到后即通知张三去领(异步),然后张三去火车站交钱领票(阻塞)。
耗费:往返车站2次,路上2小时,黄牛手续费100元,通知一次
优点: 不需要等,不会拿错票
缺点: 需要其它资源(黄牛手续费),还需要自己去取票

(2.4) 信号驱动I/O模型

张三去火车站买票,给售票员留下电话,有票后,售票员电话通知张三(异步),然后张三去火车站交钱领票(阻塞)。
耗费:往返车站2次,路上2小时,通知一次
优点: 不需要黄牛
缺点: 需要其它资源,需要自己去取票

(2.5) 异步I/O模型

张三去火车站买票,给售票员留下电话,有票后,售票员电话通知张三(异步)并快递送票上门(非阻塞)。
耗费:往返车站1次,路上1小时,通知一次,快递一次
优点: 不需要自己取票
缺点: 需要其它资源 (售票员协助) (快递费)

1同2的区别是:自己轮询 (同步)
2同3的区别是:委托黄牛
3同4的区别是:电话代替黄牛
4同5的区别是:通知后是自取还是送票上门 (异步)


(3) IO模型

阻塞式 I/O ( blocking IO )

在每一个连接创建时,都需要一个用户线程来处理,并且在 I/O 操作没有就绪或结束时,线程会被挂起,进入阻塞等待状态。
在整个 socket 通信工作流程中,socket 的默认状态是阻塞的。也就是说,当发出一个不能立即完成的套接字调用时,其进程将被阻塞,被系统挂起,进入睡眠状态,一直等待相应的操作响应。
阻塞式 I/O 就成为了导致性能瓶颈的根本原因。

以简单的TCP服务端的工作流程为例

简单的TCP服务端的工作流程

connect 阻塞

connect 阻塞: 当客户端发起 TCP 连接请求,通过系统调用 connect 函数,TCP 连接的建立需要完成三次握手过程,客户端需要等待服务端发送回来的 ACK 以及 SYN 信号,同样服务端也需要阻塞等待客户端确认连接的 ACK 信号,这就意味着 TCP 的每个 connect 都会阻塞等待,直到确认连接。

accept 阻塞

accept 阻塞 : 一个阻塞的 socket 通信的服务端接收外来连接,会调用 accept 函数,如果没有新的连接到达,调用进程将被挂起,进入阻塞状态。

read、write 阻塞

read、write 阻塞 : 当一个 socket 连接创建成功之后,服务端用 fork 函数创建一个子进程, 调用 read 函数等待客户端的数据写入,如果没有数据写入,调用子进程将被挂起,进入阻塞状态。

(2) 非阻塞式 I/O ( non-blocking IO )

非阻塞式I/O 解决了阻塞的问题。

内核在没有准备好数据的时候会返回错误码,而调用程序不会休眠,而是不断轮询询问内核数据是否准备好。数据准备好时,函数成功返回。

使用用户线程轮询查看一个 I/O 操作的状态,在大量请求的情况下,非阻塞式IO的轮询会耗费大量cpu。

(3) I/O 复用 ( IO multiplexing )

类似与非阻塞,只不过轮询不是由用户线程去执行,而是由内核去轮询,内核监听程序监听到数据准备好后,调用内核函数复制数据到用户态。

Linux 提供了 I/O 复用函数 select/poll/epoll,进程将一个或多个读操作通过系统调用函数,阻塞在函数操作上。这样,系统内核就可以帮我们侦测多个读操作是否处于就绪状态。

Linux 提供了 I/O 复用函数 select/poll/epoll,进程将一个或多个读操作通过系统调用函数,阻塞在函数操作上。这样,系统内核就可以帮我们侦测多个读操作是否处于就绪状态。

Linux中IO复用的实现方式主要有select、poll和epoll:
Select:注册IO、阻塞扫描,监听的IO最大连接数不能多于FD_SIZE;
Poll:原理和Select相似,没有数量限制,但IO数量大扫描线性性能下降;
Epoll :事件驱动不阻塞,mmap实现内核与用户空间的消息传递,数量很大,Linux2.6后内核支持;

select 会修改传入的参数数组,这个对于一个需要调用很多次的函数,是非常不友好的。
select 如果任何一个sock(I/O stream)出现了数据,select 仅仅会返回,但是并不会告诉你是那个sock上有数据,于是你只能自己一个一个的找,10几个sock可能还好,要是几万的sock每次都找一遍,这个无谓的开销就颇有海天盛筵的豪气了。
select 只能监视1024个链接,linux 定义在头文件中的,参见FD_SETSIZE。
select 不是线程安全的

poll 修复了select的很多问题,比如
poll 去掉了1024个链接的限制
poll 从设计上来说,不再修改传入数组

epoll 可以说是I/O 多路复用最新的一个实现,epoll 修复了poll 和select绝大部分问题, 比如:
epoll 现在是线程安全的。
epoll 现在不仅告诉你sock组里面数据,还会告诉你具体哪个sock有数据,你不用自己去找了。

epoll只有linux支持

(4) 信号驱动式I/O( signal-driven IO )

首先我们允许Socket进行信号驱动IO,并安装一个信号处理函数,进程继续运行并不阻塞。当数据准备好时,进程会收到一个SIGIO信号,可以在信号处理函数中调用I/O操作函数处理数据。

(5) 异步非阻塞 I/O( asynchronous IO )

相对于同步IO,异步IO不是顺序执行。用户进程进行aio_read系统调用之后,无论内核数据是否准备好,都会直接返回给用户进程,然后用户态进程可以去做别的事情。等到socket数据准备好了,内核直接复制数据给进程,然后从内核向进程发送通知。IO两个阶段,进程都是非阻塞的。

References

[1] 答疑课堂:深入了解NIO的优化实现原理
[2] 聊聊Linux 五种IO模型
[3] linux五种IO模型
[4] 《Unix网络编程》
[5] Linux的5种网络IO模型详解
[6] linux-patches/nio-improve
[7] 理解一下5种IO模型、阻塞IO和非阻塞IO、同步IO和异步IO
[8] IO 模型 - Unix IO 模型