IO相关知识点

1,011 阅读11分钟

IO

IO是什么?

我们都知道unix(like)世界里,一切皆文件,而文件是什么呢?文件就是一串二进制流而已,不管socket,还是FIFO、管道、终端,对我们来说,一切都是文件,一切都是流。在信息 交换的过程中,我们都是对这些流进行数据的收发操作,简称为I/O操作(input and output),往流中读出数据,系统调用read,写入数据,系统调用write。

IO源有哪些

磁盘IO

发送一条磁盘IO的指令, 指令一般是通知磁盘开始扇区位置,然后给出需要从这个初始扇区往后读取的连续扇区个数,同时给出动作是读,还是写。磁盘收到这条指令,就会按照指令的要求,读或者写数据。控制器发出的这种指令+数据,就是一次IO,读或者写。

磁盘IO的并发

一个磁盘同一时刻只能执行一条指令, 因此单磁盘并发度为0

内存IO

就是从内存中读写数据, 速度非常快, 通常不会成为性能瓶颈, 一般不考虑

设备IO

从一个外接设备写入或者读取数据, 设备IO需要考虑设备是否是个互斥资源. 互斥资源的IO某一时刻只能被一个线程占用.

网络IO

网络IO其实也属于设备IO的一种, 但是通常单独讨论. 网络IO也就是对网卡的读写, 也就是发送请求和接受请求在网卡上数据读写的IO. 主要就是利用socket套接字发生和接受数据.

数据拷贝

不同的IO源, 所遵循的数据拷贝都是一致的.

DMA控制器

DMA(Direct Memory Access,直接存储器访问) : 它是所有现代电脑的重要特色,它允许不同速度的硬件装置来沟通,而不需要依赖于 CPU 的大量中断负载。

传统IO操作

读操作

拷贝两次, 上下文切换两次

  • (1) 用户进程通过read()函数向内核发起读取的调用 上下文从用户切换到内核
  • (2) cpu利用 DMA 控制器从内存或磁盘拷贝到读缓存区 拷贝一次
  • (3) cpu将缓存区的数据拷贝到用户进程的缓存区 拷贝一次
  • (4) 上下文从用户切换到内核 read()函数返回

写操作

拷贝两次 上下文切换两次

  • (1) 用户进程调用write()函数, 向内核发起系统调用 上下文用户切换到内核
  • (2) cpu将数据从用户缓存区拷贝到内核缓存区(这说明传统模式下用户进程没有权利去直接拷贝数据, 必须交给内核来完成) 拷贝一次
  • (3) CPU 利用 DMA 控制器将数据从网络缓冲区(socket buffer)拷贝到内存或者磁盘 拷贝一次
  • (4) 上下文从内核切换到用户 write()函数返回

零拷贝

于这种数据传输方式来说,应用程序可以直接访问硬件存储,操作系统内核只是辅助数据传输.也就是用户进程可以直接对磁盘或者内存进行读写. 这样数据就不需要拷贝了, 但是当然传统方式的一些好处必然也就需要舍弃

Linux 中提供类似零拷贝的系统调用主要有 mmap(),sendfile() 以及 splice()。

mmap() (读一次拷贝, 写不变)

一次拷贝发生在DMA会从磁盘或者内存将数据读入共享缓存区. 用户进程就可以直接使用共享缓存区中的数据.

和传统的区别就是read操作变成了mmap,之后用户空间会和内核态共享同一块内核缓存区,读入的数据都在这个内核缓存区里面。写入的话还是和原来一样。

sendfile() (读一次拷贝, 写两次次拷贝)

该方法适用于读取的数据可以直接写入到别的IO源里, 拷贝操纵直接在内核空间中完成, 用户进程不需要参与, 减少了上下文切换

读的一次拷贝发生在DMA会从磁盘或者内存将数据读入内核缓存区

写的两次拷贝

  • (1) 内存缓存区拷贝到socket缓存区
  • (2) socket缓存区内容复制到网卡

splice() (读一次拷贝, 写一次拷贝)

该方法适用于读取的数据可以直接写入到别的IO源里, 内存缓存和socket缓存间直接建立通道, 无需复制操作, 直接就可以互相访问.

读写就绪状态

(1) 读就绪状态

内核缓冲区中数据字节数大于等于用户进程请求读的字节数,此时系统可以将内核缓冲区的数据搬到用户缓冲区.

(2) 写就绪状态

内核缓冲区中剩余字节空间数(空闲空间)大于等于用户进程请求写的字节数,此时系统可以将用户缓冲区的数据搬往内核缓冲区.

也就是说一个读或写的过程,首先要经历一个读/写的就绪状态,读/写就绪后,才进行"真正"的IO,读就绪后,系统才能将内核缓冲区的数据搬到用户缓冲区;

写就绪后,系统才能将用户缓冲区的数据搬往内核缓冲区.

网络IO

网络IO作为java服务重点关注的情况, 无论是请求/相应, 还是数据库操作都通过网络IO完成

网络IO的特点

其它IO源在读取的时候, 基本不会有数据不存在的情况. 但是网络IO对于操作系统来说, 读写的都是网卡的内容, 网卡的内容能否被读取, 取决于是否有新的数据通过网络写入. 因此用户进程并不知道何时才能从网络IO获取数据, 因此就需要使用IO模型.

网络IO收取网络包的过程

www.easemob.com/news/5544

www.yuque.com/henyoumo/ik…

  • (1) 网卡收到数据后, 网络驱动会通过DMA把网卡上收到的数据写到内存里, 并想cpu发送一个软中断, 通知cpu有数据到达
  • (2) 内核具有一个线程ksoftirqd专门用来处理软中断的请求, ksoftirqd不断循环, 判断是否有软中断请求需要处理. 不断用poll()的方式轮询.
  • (3) ksoftirqd发现由网卡的中断请求后, 将数据交到各级协议栈处理.
  • (4) 协议栈负责将不同协议的数据都处理完毕(比如收到了完整的多个TCP数据报文), 变成可用的数据结合socket放到socket队列中去. 代表socket的数据就绪了.
  • (5) 用户进程自己维护一个不停循环的线程(或者由用户进程的网络框架维护), 不停的去访问内核空间看有没有就绪的socket数据.

IO模型

IO模型适用于所有和IO源交互的情况, 但是对于网络IO来说, IO交互的等待时间可能无限长.

  • (1) IO模型主要用来讨论数据未就绪的情况, 如果数据已经就绪了, 啥模型都能直接读取数据
  • (2) IO模型只讨论应用程序触发获取数据的操作之后的情况, 至于应用程序何时触发, 和IO模型无关

阻塞IO(BIO)

  • (1) 应用程序的一个线程获取数据的操作, 此时内核数据未就绪
  • (2) 线程一直等待
  • (3) 内核数据就绪, 唤醒线程读取内核数据到用户进程空间中
  • (4) 线程的读取数据操作完成

阻塞IO一个线程只能用来获取一个socket套接字的数据.

非阻塞IO(BIO)

  • (1) 应用程序的一个线程获取数据的操作, 此时内核数据未就绪, 返回一个错误信息
  • (2) 线程得到返回后, 可以执行别的操作, 之后会再次来尝试获取数据
  • (3) 线程不断轮询, 直到内核数据就绪, 就将内核数据拷贝到用户空间
  • (4) 线程的读取数据操作完成

非阻塞IO, 如果你在线程中维护多个socket连接的信息, 是可以实现和select()差不多的效果.

IO多路复用

IO多路复用是一种同步IO模型,一个线程监听多个IO事件,当有IO事件就绪时,就会通知线程去执行相应的读写操作,没有就绪事件时,就会阻塞交出cpu。

多路是指网络链接,复用指的是复用同一线程。

因为IO多路复用不止适用于套接字, 适用于所有文件描述符fd, 因此介绍时以fd来介绍

select()

用户线程维护一个数组, 记录所有感兴趣的fd(在socket中就是已经建立连接的所有套接字). 数组的大小有限制, 在32位系统中,最大值为1024个,而在64位系统中,最大值为2048个

(1) 线程不断调用select()方法, 将数组从用户空间拷贝到内核空间, 内核空间会按照数组检查一遍fd是否发生了IO事件(就是socket读队列有没有数据), 如果有, 就使该fd为就绪状态(此时不拷贝数据)

(2) select()方法返回, 进程遍历一遍该数组, 看看哪些fd是就绪状态, 如果就绪了, 就调用fd的对应方法, 将数据从内核空间拷贝到进程空间中.

poll()

进程维护一个链表, 因为是链表, 所以没有长度的限制.

其它的操作过程和select()方法一样. 性能没啥提升

epoll()

epoll就是对select和poll的改进了。它的核心思想是基于事件驱动来实现的,相当于提前建立好相应的数据结构 + 回调函数的使用, 使得不需要轮询, 而是只返回就绪的fd.

epoll操作实际上对应着有三个函数:epoll_create,epoll_ctr,epoll_wait.

epoll_create

epoll_create相当于在内核中创建一个存放fd的数据结构。在select和poll方法中,内核都没有为fd准备存放其的数据结构,只是简单粗暴地把数组或者链表复制进来;而epoll则不一样,epoll_create会在内核建立一颗专门用来存放fd结点的红黑树,后续如果有新增的fd结点,都会注册到这个epoll红黑树上。

epoll_ctr

select和poll会一次性将监听的所有fd都复制到内核中,而epoll不一样,当需要添加一个新的fd时,会调用epoll_ctr,给这个fd注册一个回调函数,然后将该fd结点注册到内核中的红黑树中。当该fd对应的设备活跃时,会调用该fd上的回调函数,将该结点存放在一个就绪链表中。这也解决了在内核空间和用户空间之间进行来回复制的问题。

epoll_wait

epoll_wait方法就是进程获取就绪fd的时候调用, 其实直接就是从就绪链表中取结点

epoll的工作流程

就算是epoll模型, 也需要线程去主动去获取数据, 即调用epoll_wait()方法, 此时就绪链表如果有数据, 那就直接返回, 如果没有数据, 线程就会进入阻塞状态, 然后当有数据后, 就会唤醒该线程, 获得数据的线程就会从epoll_wait()方法继续向后执行

何时选择select(), poll() 或者epoll()

并不是所有的情况中epoll都是最好的,比如当fd数量比较小的时候,epoll不见得就一定比select和poll好

AIO 异步IO

异步IO肯定不是阻塞的了, 异步乍一看和epoll回调类似, 但是epoll其实是等数据就绪了之后, 唤醒之前尝试获取数据的线程, 之前的线程在被唤醒前是一直阻塞的

  • (1) 用户线程向内核空间发起一次读取数据的调用.
  • (2) 如果数据就绪, 直接读取, 将数据拷贝到用户空间
  • (3) 如果未就绪就直接返回, 然后该线程销毁
  • (4) 内核已经知道了用户进程想要哪些数据, 等到内核数据准备好之后, 内核主动将数据拷贝到用户空间, 内核就去主动调用用户提供的回调函数来处理数据.

jdk的IO演变历程

  • (1) 一个是jdk 1.4,这个版本之前java仅支持传统的bio,之后支持nio;
  • (2) jdk 1.7,这个版本之后,有了aio。
  • (3) 编程语言层面上的io操作,其实调用的是操作系统内核的read/write 接口(对底层硬件设备的读写),所以本质上还得依赖于操作系统内核,如果操作系统不支持aio,即使编程语言层面上有aio接口,也没用,这也是为什么有了aio,但是目前大多数应用实际使用的还是nio,由于应用大多部署在linux服务器,而linux操作系统内核尚未实现aio(windows 实现了aio).