并发 IO

并发 IO

IO 的概念,从字义来理解就是输入输出。操作系统从上层到底层,各个层次之间均存在 IO。比如,CPU 有 IO,内存有 IO, VMM 有 IO, 底层磁盘上也有 IO,这是广义上的 IO。通常来讲,一个上层的 IO 可能会产生针对磁盘的多个 IO,也就是说,上层的 IO 是稀疏的,下层的 IO 是密集的。磁盘的 IO,顾名思义就是磁盘的输入输出。输入指的是对磁盘写入数据,输出指的是从磁盘读出数据。

所谓的并发 IO,即在一个时间片内,如果一个进程进行一个 IO 操作,例如读个文件,这个时候该进程可以把自己标记为休眠状态并出让 CPU 的使用权,待文件读进内存,操作系统会把这个休眠的进程唤醒,唤醒后的进程就有机会重新获得 CPU 的使用权了。这里的进程在等待 IO 时之所以会释放 CPU 使用权,是为了让 CPU 在这段等待时间里可以做别的事情,这样一来 CPU 的使用率就上来了;此外,如果这时有另外一个进程也读文件,读文件的操作就会排队,磁盘驱动在完成一个进程的读操作后,发现有排队的任务,就会立即启动下一个读操作,这样 IO 的使用率也上来了。

IO 类型

连续与随机

Unix 中内置了 5 种 IO 模型,阻塞式 IO, 非阻塞式 IO,IO 复用模型,信号驱动式 IO 和异步 IO。而从应用的角度来看,IO 的类型可以分为:

  • 大/小块 IO:这个数值指的是控制器指令中给出的连续读出扇区数目的多少。如果数目较多,如 64,128 等,我们可以认为是大块 IO;反之,如果很小,比如 4,8,我们就会认为是小块 IO,实际上,在大块和小块 IO 之间,没有明确的界限。

  • 连续/随机 IO:连续 IO 指的是本次 IO 给出的初始扇区地址和上一次 IO 的结束扇区地址是完全连续或者相隔不多的。反之,如果相差很大,则算作一次随机 IO。连续 IO 比随机 IO 效率高的原因是:在做连续 IO 的时候,磁头几乎不用换道,或者换道的时间很短;而对于随机 IO,如果这个 IO 很多的话,会导致磁头不停地换道,造成效率的极大降低。

  • 顺序/并发 IO:从概念上讲,并发 IO 就是指向一块磁盘发出一条 IO 指令后,不必等待它回应,接着向另外一块磁盘发 IO 指令。对于具有条带性的 RAID(LUN),对其进行的 IO 操作是并发的,例如:raid 0+1(1+0),raid5 等。反之则为顺序 IO。

非阻塞与异步

在传统的网络服务器的构建中,IO 模式会按照 Blocking/Non-Blocking、Synchronous/Asynchronous 这两个标准进行分类,其中 Blocking 与 Synchronous 大同小异,而 NIO 与 Async 的区别在于 NIO 强调的是轮询(Polling),而 Async 强调的是通知(Notification)。

譬如在一个典型的单进程单线程 Socket 接口中,阻塞型的接口必须在上一个 Socket 连接关闭之后才能接入下一个 Socket 连接。而对于 NIO 的 Socket 而言,服务端应用会从内核获取到一个特殊的 "Would Block" 错误信息,但是并不会阻塞到等待发起请求的 Socket 客户端停止。

一般来说,在 Linux 系统中可以通过调用独立的 select 或者 epoll 方法来遍历所有读取好的数据,并且进行写操作。而对于异步 Socket 而言(譬如 Windows 中的 Sockets 或者 .Net 中实现的 Sockets 模型),服务端应用会告诉 IO Framework 去读取某个 Socket 数据,在数据读取完毕之后 IO Framework 会自动地调用你的回调(也就是通知应用程序本身数据已经准备好了)。以 IO 多路复用中的 Reactor 与 Proactor 模型为例,非阻塞的模型是需要应用程序本身处理 IO 的,而异步模型则是由 Kernel 或者 Framework 将数据准备好读入缓冲区中,应用程序直接从缓冲区读取数据。

  • 同步阻塞:在此种方式下,用户进程在发起一个 IO 操作以后,必须等待 IO 操作的完成,只有当真正完成了 IO 操作以后,用户进程才能运行。

  • 同步非阻塞:在此种方式下,用户进程发起一个 IO 操作以后边可返回做其它事情,但是用户进程需要时不时的询问 IO 操作是否就绪,这就要求用户进程不停的去询问,从而引入不必要的 CPU 资源浪费。

  • 异步非阻塞:在此种模式下,用户进程只需要发起一个 IO 操作然后立即返回,等 IO 操作真正的完成以后,应用程序会得到 IO 操作完成的通知,此时用户进程只需要对数据进行处理就好了,不需要进行实际的 IO 读写操作,因为真正的 IO 读取或者写入操作已经由内核完成了。

而在并发 IO 的问题中,较常见的就是所谓的 C10K 问题,即有 10000 个客户端需要连上一个服务器并保持 TCP 连接,客户端会不定时的发送请求给服务器,服务器收到请求后需及时处理并返回结果。

IO 多路复用

IO 多路复用就通过一种机制,可以监视多个描述符,一旦某个描述符就绪(一般是读就绪或者写就绪),能够通知程序进行相应的读写操作。select,poll,epoll 都是 IO 多路复用的机制。值得一提的是,epoll 仅对于 Pipe 或者 Socket 这样的读写阻塞型 IO 起作用,正常的文件描述符则是会立刻返回文件的内容,因此 epoll 等函数对普通的文件读写并无作用。

首先来看下可读事件与可写事件:当如下任一情况发生时,会产生套接字的可读事件:

  • 该套接字的接收缓冲区中的数据字节数大于等于套接字接收缓冲区低水位标记的大小;

  • 该套接字的读半部关闭(也就是收到了 FIN),对这样的套接字的读操作将返回 0(也就是返回 EOF);

  • 该套接字是一个监听套接字且已完成的连接数不为 0;

  • 该套接字有错误待处理,对这样的套接字的读操作将返回-1。

当如下任一情况发生时,会产生套接字的可写事件:

  • 该套接字的发送缓冲区中的可用空间字节数大于等于套接字发送缓冲区低水位标记的大小;

  • 该套接字的写半部关闭,继续写会产生 SIGPIPE 信号;

  • 非阻塞模式下,connect 返回之后,该套接字连接成功或失败;

  • 该套接字有错误待处理,对这样的套接字的写操作将返回-1。

select,poll,epoll 本质上都是同步 IO,因为他们都需要在读写事件就绪后自己负责进行读写,也就是说这个读写过程是阻塞的,而异步 IO 则无需自己负责进行读写,异步 IO 的实现会负责把数据从内核拷贝到用户空间。select 本身是轮询式、无状态的,每次调用都需要把 fd 集合从用户态拷贝到内核态,这个开销在 fd 很多时会很大。epoll 则是触发式处理连接,维护的描述符数目不受到限制,而且性能不会随着描述符数目的增加而下降。

方法

数量限制

连接处理

内存操作

select

描述符个数由内核中的 FD_SETSIZE 限制,仅为 1024;重新编译内核改变 FD_SETSIZE 的值,但是无法优化性能

每次调用 select 都会线性扫描所有描述符的状态,在 select 结束后,用户也要线性扫描 fd_set 数组才知道哪些描述符准备就绪(O(n))

每次调用 select 都要在用户空间和内核空间里进行内存复制 fd 描述符等信息

poll

使用 pollfd 结构来存储 fd,突破了 select 中描述符数目的限制

类似于 select 扫描方式

需要将 pollfd 数组拷贝到内核空间,之后依次扫描 fd 的状态,整体复杂度依然是 O(n)的,在并发量大的情况下服务器性能会快速下降

epoll

该模式下的 Socket 对应的 fd 列表由一个数组来保存,大小不限制(默认 4k)

基于内核提供的反射模式,有活跃 Socket 时,内核访问该 Socket 的 callback,不需要遍历轮询

epoll 在传递内核与用户空间的消息时使用了内存共享,而不是内存拷贝,这也使得 epoll 的效率比 poll 和 select 更高

链接