图片 6

聊聊BIO,NIO和AIO (2)

本文从操作系统的角度来解释BIO,NIO,AIO的概念,含义和背后的那些事。本文主要分为3篇。

本文从操作系统的角度来解释BIO,NIO,AIO的概念,含义和背后的那些事。本文主要分为3篇。

  • 第一篇 讲解BIO和NIO以及IO多路复用
  • 第二篇 讲解磁盘IO和AIO
  • 第三篇 讲解在这些机制上的一些应用的实现方式,比如nginx,nodejs,Java
    NIO等
  • 第一篇 讲解BIO和NIO以及IO多路复用
  • 第二篇 讲解磁盘IO和AIO
  • 第三篇 讲解在这些机制上的一些应用的实现方式,比如nginx,nodejs,Java
    NIO等

磁盘IO,简单来说就是读取硬盘一类设备的IO。这类设备包括传统的磁盘、SSD、闪存、CD等。操作系统将其统一抽象为”块设备“。所以磁盘IO又可以叫做”块IO“。这些设备上的数据一般用文件系统来组织,所以又可以成为”文件IO“。本文统一用”磁盘IO“这个术语。

很多人说BIO不好,会“block”,但到底什么是IO的Block呢?考虑下面两种情况:

对于磁盘的驱动来说,存在一个最小的操作单位。这个单位被称为“簇”。对磁盘的操作不可以小于这个单位,比如整簇读取/整簇写入。比如硬盘的簇很多都是512Byte,而CD上的簇是2KB。

  • 用系统调用read从socket里读取一段数据
  • 用系统调用read从一个磁盘文件读取一段数据到内存

对于Linux来说,虚拟文件系统抽象了磁盘设备,统一称为“块设备”(block
device)。数据是按照一块块来组织的。操作系统可以随机的定位到某个“块”,读写某个“块”。

如果你的直觉告诉你,这两种都算“Block”,那么很遗憾,你的理解与Linux不同。Linux认为:

很不巧,“阻塞”和“块”的英文单词都是“block”,请读者留意区分。

  • 对于第一种情况,算作block,因为Linux无法知道网络上对方是否会发数据。如果没数据发过来,对于调用read的程序来说,就只能“等”。

  • 对于第二种情况,不算做block

块到簇的转换,是由设备驱动来完成的。一个“块”的大小必须大于等于一个“簇”。并且块的大小必须是簇的整倍数(否则转换起来就太麻烦了)。块的大小一般有512Byte,1KB,2KB等。

是的,对于磁盘文件IO,Linux总是不视作Block。

在VFS上层的应用是感受不到“簇”的,他们只能感受到“块”。同时,对于操作系统在驱动程序之上的层次来说,访问磁盘数据的最小单位是“块”。即,即使你只想读取1个Byte,磁盘也至少要读取1个块;要写入1个Byte,磁盘也至少要写入一个块。

你可能会说,这不科学啊,磁盘读写偶尔也会因为硬件而卡壳啊,怎么能不算Block呢?但实际就是不算。

这里简单介绍“簇”和“块”,是因为读写磁盘数据要求“块”对齐。下文中会提到。

一个解释是,所谓“Block”是指操作系统可以预见这个Block会发生才会主动Block。例如当读取TCP连接的数据时,如果发现Socket
buffer里没有数据就可以确定定对方还没有发过来,于是Block;而对于普通磁盘文件的读写,也许磁盘运作期间会抖动,会短暂暂停,但是操作系统无法预见这种情况,只能视作不会Block,照样执行。

在虚拟文件系统层之上,是内存。这一层被称为Page Cache。详见下图。

基于这个基本的设定,在讨论IO时,一定要严格区分网络IO和磁盘文件IO。NIO和后文讲到的IO多路复用只对网络IO有意义。

图片 1Page
Cache和块设备

严格的说,O_NONBLOCK和IO多路复用,对标准输入输出描述符、管道和FIFO也都是有效的。但本文侧重于讨论高性能网络服务器下各种IO的含义和关系,所以本文做了简化,只提及网络IO和磁盘文件IO两种情况。

这个层次是用页面来组织的。一般来讲一个页面是4KB,一个页面对应若干个“块”。

本文先着重讲一下网络IO。

Page Cache对于磁盘IO的性能表现极度重要。比如,当通过write
API写入数据到磁盘时,数据先会被写入到Page
Cache。此时,这个Page被称为“dirty page”。dirty
page会最终被写入到磁盘上,这个过程为称之为“写回”(writeback)。写回往往不会立刻发生。写回可能由于调用者直接使用类似于fsync这样的API,也有可能因为操作系统根据某种策略和算法决定自动写回。写回发生之前,如果机器挂了,就有可能丢失数据。这也是为什么有持久性要求的程序都需要用fsync来保证数据落地的原因。

有了Block的定义,就可以讨论BIO和NIO了。BIO是Blocking
IO的意思。在类似于网络中进行read, write,
connect一类的系统调用时会被卡住。

当读取数据时,操作系统会先尝试从Page
Cache里找,如果找到了就会直接返回给应用程序。如果找不到,就会触发“页错误”(Page
Fault),迫使操作系统去读取磁盘数据,在Page
Cache里进行缓存,然后将数据返回给上层应用程序。

举个例子,当用read去读取网络的数据时,是无法预知对方是否已经发送数据的。因此在收到数据之前,能做的只有等待,直到对方把数据发过来,或者等到网络超时。

Page Cache的基本维护算法是基于“时间局部性”(Temporal
Locality)。下面是wiki的解释:

对于单线程的网络服务,这样做就会有卡死的问题。因为当等待时,整个线程会被挂起,无法执行,也无法做其他的工作。

Temporal locality refers to the reuse of specific data, and/or
resources, within a relatively small time duration.

顺便说一句,这种Block是不会影响同时运行的其他程序的,因为现代操作系统都是多任务的,任务之间的切换是抢占式的。这里Block只是指Block当前的进程。

用人类语言解释就是假设“被访问的数据在短时间内再次被访问的几率会很大”。具体算法一般就是基于Least
Recent Use,LRU——即把最不经常访问的Cache删除掉。

于是,网络服务为了同时响应多个并发的网络请求,必须实现为多线程的。每个线程处理一个网络请求。线程数随着并发连接数线性增长。这的确能奏效。实际上2000年之前很多网络服务器就是这么实现的。但这带来两个问题:

“时间局部性“作为通用规则,可以应付大部分情况。但是凡事总有特殊。比如把一个巨大的文件从头读到尾。此时“时间局部性”肯定是不起作用的(已经读取过的数据反而不需要了)。这时就要用一些定制的手段来定制“如何做Cache”。比如可以预取——预先把即将访问的数据读取到Cache;可以强制一个Page常驻——手工管理一个Page的存活等。这些工作可以由fadvise等api来完成。

  • 线程越多,Context Switch就越多,而Context
    Switch是一个比较重的操作,会无谓浪费大量的CPU。
  • 每个线程会占用一定的内存作为线程的栈。比如有1000个线程同时运行,每个占用1MB内存,就占用了1个G的内存。

大家都知道内存的读写延迟要比磁盘高2~3个数量级。对于磁盘数据,就可以长期的保存在Cache中。这样可以极大的提升磁盘IO读取的效率。

也许现在看来1GB内存不算什么,现在服务器上百G内存的配置现在司空见惯了。但是倒退20年,1G内存是很金贵的。并且,尽管现在通过使用大内存,可以轻易实现并发1万甚至10万的连接。但是水涨船高,如果是要单机撑1千万的连接呢?

Page Cache的上层是应用程序,就是我们平时写的程序了。

问题的关键在于,当调用read接受网络请求时,有数据到了就用,没数据到时,实际上是可以干别的。使用大量线程,仅仅是因为Block发生,没有其他办法。

磁盘IO的应用程序大概长这样:

当然你可能会说,是不是可以弄个线程池呢?这样既能并发的处理请求,又不会产生大量线程。但这样会限制最大并发的连接数。比如你弄4个线程,那么最大4个线程都Block了就没法响应更多请求了。

char buffer[BUF_SIZE]; /* buffer */int fd1 = /* ... 打开一个文件并获得fd */int fd2 = /* ... 打开另一个文件并获得fd */read (fd1, &buffer, BUF_SIZE); /* 读文件数据到buffer *//* processing buffer ... */write (fd2, &buffer, ret_in); /* 将buffer数据写入文件 *//* 如果需要,可以调用fsync; 将数据刷到磁盘*//* close fd */

要是操作IO接口时,操作系统能够总是直接告诉有没有数据,而不是Block去等就好了。于是,NIO登场。

在处理IO数据时,应用程序总是需要在用户态分配一段内存空间作为buffer,然后将Page
Cache中的数据copy出来进行处理。处理完成后,将数据写回到Page Cache。

NIO是指将IO模式设为“Non-Blocking”模式。在Linux下,一般是这样:

图片 2应用和Page
Cache

void setnonblocking { int flags = fcntl(fd, F_GETFL, 0); fcntl(fd, F_SETFL, flags | O_NONBLOCK);}

如果你留意这个图,就会发现,这里会多额外两次数据的copy(并且是CPU
copy
)。但是有两种方法可以避免这两次copy,分别是mmapsendfile

再强调一下,以上操作只对socket对应的文件描述符有意义;对磁盘文件的文件描述符做此设置总会成功,但是会直接被忽略。

mmap可以将Page
Cache中的内核空间内存地址直接映射到用户空间中,于是应用程序可以直接对Page
Cache中的数据进行读写操作。

这时,BIO和NIO的区别是什么呢?

图片 3内存映射

在BIO模式下,调用read,如果发现没数据已经到达,就会Block住。

mmap的一个巨大好处是可以让开发人员像是访问常规变量那样随机访问文件中的数据。如果不用mmap,开发人员就得自己用lseek去频繁定位文件的位置。这样一来是非常麻烦,代码写的会相当臃肿啰嗦;二是lseek也是系统调用,频繁使用的话会造成大量上下文切换,带来性能上的无谓损耗。

在NIO模式下,调用read,如果发现没数据已经到达,就会立刻返回-1,
并且errno被设为EAGAIN

现实当中,mmap有相当花样的玩法,可以实现多进程数据共享和通讯,实现跨进程锁等。但这些功能不是本文的重点就不展开了。

在有些文档中写的是会返回EWOULDBLOCK。实际上,在Linux下EAGAINEWOULDBLOCK是一样的,即#define EWOULDBLOCK EAGAIN

sendfile可以直接将Page
Cache中某个fd的一部分数据传递给另外一个fd,而不用经过到应用层的两次copy。值得注意的是,sendfile的原始fd必须是一个磁盘文件对应的fd;而其目标fd可以是磁盘文件,也可以是socket。当为socket时,sendfile就非常高效的实现了一个功能——通过网络serving文件。一般称这种实现为“Zero
Copy”。

于是,一段NIO的代码,大概就可以写成这个样子。

其实这里还是会copy,只不过只有DMA copy,没有CPU copy,不浪费CPU

struct timespec sleep_interval{.tv_sec = 0, .tv_nsec = 1000};ssize_t nbytes;while  { /* 尝试读取 */ if ((nbytes = read(fd, buf, sizeof < 0) { if (errno == EAGAIN) { // 没数据到 perror("nothing can be read"); } else { perror("fatal error"); exit(EXIT_FAILURE); } } else { // 有数据 process_data(buf, nbytes); } // 处理其他事情,做完了就等一会,再尝试 nanosleep(sleep_interval, NULL);}

图片 4sendfile实现Zero
Copy

这段代码很容易理解,就是轮询,不断的尝试有没有数据到达,有了就处理,没有(得到EWOULDBLOCK或者EAGAIN)就等一小会再试。这比之前BIO好多了,起码程序不会被卡死了。

上图是一个使用sendfile将一个文件直接发到网络的示意图。调用sendfile使得文件数据进入到Page
Cache,然后让网卡直接从Page Cache中获取数据发送给网络。期间不出现任何CPU
Copy。如果调用sendfile时,数据已经在Page Cache了,就会被直接使用。

但这样会带来两个新问题:

但是sendfile也有一个严重的缺点。因为数据是两个fd在内核直接传输的,所以无法做任何修改。你只能原封不动的传输原始的数据文件。一旦你想在数据上做一些额外的加工,就无法使用sendfile。比如磁盘上存储的是原始的文件,而你想压缩文件后再传输给socket,就必须放弃sendfile,老老实实的把文件读到用户态buffer,然后做压缩处理,再写回到内核态的socket
buffer。

  • 如果有大量文件描述符都要等,那么就得一个一个的read。这会带来大量的Context
    Switch(read是系统调用,每调用一次就得在用户态和核心态切换一次)
  • 休息一会的时间不好把握。这里是要猜多久之后数据才能到。等待时间设的太长,程序响应延迟就过大;设的太短,就会造成过于频繁的重试,干耗CPU而已。

上面介绍的是用了Page Cache的IO一般被称为Buffered IO。之所以不叫Cached
IO,是因为早年Linux的磁盘iOS设计中在Page Cache
里还有一个内部的”内核buffer“。在Linux
2.6之后,这个设计被统一到了只使用Page。然而,Buffered
IO的名字被保留了下来。

要是操作系统能一口气告诉程序,哪些数据到了就好了。

与Buffered IO相对的,是Direct IO。即应用程序直接读写块设备,不再经过Page
Cache。

于是IO多路复用被搞出来解决这个问题。

图片 5Direct
IO

IO多路复用(IO Multiplexing)
是这么一种机制:程序注册一组socket文件描述符给操作系统,表示“我要监视这些fd是否有IO事件发生,有了就告诉程序处理”。

要使用这种IO,只要在打开文件时,增加一个O_DIRECT标记。

IO多路复用是要和NIO一起使用的。尽管在操作系统级别,NIO和IO多路复用是两个相对独立的事情。NIO仅仅是指IO
API总是能立刻返回,不会被Blocking;而IO多路复用仅仅是操作系统提供的一种便利的通知机制。操作系统并不会强制这俩必须得一起用——你可以用NIO,但不用IO多路复用,就像上一节中的代码;也可以只用IO多路复用
+
BIO,这时效果还是当前线程被卡住。但是,IO多路复用和NIO是要配合一起使用才有实际意义。因此,在使用IO多路复用之前,请总是先把fd设为O_NONBLOCK

int fd = open("path/to/the/file", O_DIRECT | O_RDWR);

对IO多路复用,还存在一些常见的误解,比如:

相比“Buffered IO”,Direct IO必然会带来性能上的降低。所以Direct
IO有特定的应用场景。比如,在数据库的实现中,为了保证数据持久,写入新数据到WAL(Write
Ahead Log)必须直接写入到磁盘,不能等待。这里用Direct
IO来实现WAL就非常理想。

  • ❌IO多路复用是指多个数据流共享同一个Socket。其实IO多路复用说的是多个Socket,只不过操作系统是一起监听他们的事件而已。

    多个数据流共享同一个TCP连接的场景的确是有,比如Http2
    Multiplexing就是指Http2通讯中中多个逻辑的数据流共享同一个TCP连接。但这与IO多路复用是完全不同的问题。

  • ❌IO多路复用是NIO,所以总是不Block的。其实IO多路复用的关键API调用(selectpollepoll_wait)总是Block的,正如下文的例子所讲。

  • IO多路复用和NIO一起减少了IO。实际上,IO本身无论用不用IO多路复用和NIO,都没有变化。请求的数据该是多少还是多少;网络上该传输多少数据还是多少数据。IO多路复用和NIO一起仅仅是解决了调度的问题,避免CPU在这个过程中的浪费,使系统的瓶颈更容易触达到网络带宽,而非CPU或者内存。要提高IO吞吐,还是提高硬件的容量(例如,用支持更大带宽的网线、网卡和交换机)和依靠并发传输(例如HDFS的数据多副本并发传输)。

使用Direct
IO的另外一种场景是,应用程序对磁盘数据缓存有特别定制的需要,而常规的Page
Cache的各种策略并不能满足这种需要。于是开发人员可以自己设计和实现一套“Cache”,配合Direct
IO。毕竟最熟悉数据访问场景的,是应用程序自己的需求。

操作系统级别提供了一些接口来支持IO多路复用,最老掉牙的是selectpoll

图片 6块对齐

select长这样:

然而,Direct
IO有一个很大的问题是要求如果是写入到磁盘,开发者必须自行保证“块对齐”。即write时给的buffer的offset和size要刚好与VFS中的“块”对应,不然就会得到EINVAL错误。如果用了“Buffered
IO”,Page Cache内部就可以自动搞定对齐这件事情了。没有Page
Cache,对齐要就得自己做。比如,需要手工调用posix_memalign分配块对齐的内存地址。

int select(int nfds, fd_set *readfds, fd_set *writefds, fd_set *exceptfds, struct timeval *timeout);

除非用Direct
IO,对于磁盘IO的优化主要在读取操作上。这是因为写入时总是写到Page
Cache,而写内存比写磁盘要高效的多。从业务上讲,一般来讲上传文件的请求量要远远小于获取文件(图片、html、js、css……),所以在Web场景下,对磁盘IO的优化的主要思路其实很简单——尽量保证要读取的文件在内存里,而不是取磁盘上读取。如果数据已经到了Page
Cache,你可以

它接受3个文件描述符的数组,分别监听读取(readfds),写入(writefds)和异常(expectfds)事件。那么一个
IO多路复用的代码大概是这样:

  • 选择用read将其从Page Cache读取到应用程序的buffer,然后做后续处理。
  • 选择用sendfile直接将数据复制到另外一个fd里(另外一个文件或者socket)
  • 选择用mmap直接读写操作
struct timeval tv = {.tv_sec = 1, .tv_usec = 0};ssize_t nbytes;while { FD_ZERO(&read_fds); setnonblocking; setnonblocking; FD_SET(fd1, &read_fds); FD_SET(fd2, &read_fds); // 把要监听的fd拼到一个数组里,而且每次循环都得重来一次... if (select(FD_SETSIZE, &read_fds, NULL, NULL, &tv) < 0) { // block住,直到有事件到达 perror("select出错了"); exit(EXIT_FAILURE); } for (int i = 0; i < FD_SETSIZE; i++) { if (FD_ISSET(i, &read_fds)) { /* 检测到第[i]个读取fd已经收到了,这里假设buf总是大于到达的数据,所以可以一次read完 */ if ((nbytes = read(i, buf, sizeof >= 0) { process_data(nbytes, buf); } else { perror; exit(EXIT_FAILURE); } } }}

但如果数据没有到Page
Cache,read可能就会“卡”一下(虽然操作系统并不认为这是阻塞)。对于高性能服务,这可能是无法接受的的。我们需要一种不会“卡”当前线程的磁盘数据读取方式。

首先,为了select需要构造一个fd数组(这里为了简化,没有构造要监听写入和异常事件的fd数组)。之后,用select监听了read_fds中的多个socket的读取时间。调用select后,程序会Block住,直到一个事件发生了,或者等到最大1秒钟(tv定义了这个时间长度)就返回。之后,需要遍历所有注册的fd,挨个检查哪个fd有事件到达(FD_ISSET返回true)。如果是,就说明数据已经到达了,可以读取fd了。读取后就可以进行数据的处理。

正如第一篇文章所说,在Linux中,磁盘IO不支持NON_BLOCKING模式。但是Linux提供了磁盘的异步IO接口(Asynchronous
IO,AIO)。

select有一些发指的缺点:

Linux中有两套“AIO”接口。这两套接口都只支持磁盘IO,不支持网络IO。

  • select能够支持的最大的fd数组的长度是1024。这对要处理高并发的web服务器是不可接受的。
  • fd数组按照监听的事件分为了3个数组,为了这3个数组要分配3段内存去构造,而且每次调用select前都要重设它们(因为select会改这3个数组);调用select后,这3数组要从用户态复制一份到内核态;事件到达后,要遍历这3数组。很不爽。
  • select返回后要挨个遍历fd,找到被“SET”的那些进行处理。这样比较低效。
  • select是无状态的,即每次调用select,内核都要重新检查所有被注册的fd的状态。select返回后,这些状态就被返回了,内核不会记住它们;到了下一次调用,内核依然要重新检查一遍。于是查询的效率很低。

第一套被称作POSIX
AIO
。顾名思义,这套接口是POSIX标准规定的。这套AIO的接口的定义可以参考这里。其大致的使用方式是:

pollselect类似于。它大概长这样:

  1. POSIX
    AIO用信号来通知进程IO完成了。所以要先注册一个IO完成时对应的信号的handler。
  2. aio_read或者aio_write来发起要读/写的操作。这个接口会立刻返回。
  3. IO完成后,信号被触发,相应的handler会执行。
  4. 你也可以选择不使用信号,而主动调用aio_suspend来主动等待IO的完成,就像第一篇文章中的select那样。
int poll(struct pollfd *fds, nfds_t nfds, int timeout);

POSIX AIO还支持用线程来做通知,但这需要额外处理线程协作的问题。

poll的代码例子和select差不多,因此也就不赘述了。有意思的是poll这个单词的意思是“轮询”,所以很多中文资料都会提到对IO进行“轮询”。

这套接口没有得到广泛的使用,原因是其有很大的局限性——这套接口并不能算是”真・AIO”。这套接口是完全在用户态实现的,完全没有深入到操作系统内核中。

上面说的select和下文说的epoll本质上都是轮询。

此外,用信号做AIO的触发在工程中有很多问题。信号是一个“数字”,而且是全局有效的。所以比如你用POSIX
AIO实现了一个lib,选用数字M做信号;但是你无法阻止其他人用POSIX
AIO实现另外一个lib,也选用数字M做信号。这样如果一个程序同时用了两套lib,就会彼此干扰。POSIX
AIO无法实现类似于epoll中可以创建多个epoll fd,彼此隔离的使用方式。

poll优化了select的一些问题。比如不再有3个数组,而是1个polldfd结构的数组了,并且也不需要每次重设了。数组的个数也没有了1024的限制。但其他的问题依旧:

如果用aio_suspend,就不满足使用AIO的最初目标。你还是得让程序主动“等”一下。并且aio_suspend并不支持eventfd(下文会讲到为什么eventfd很重要)。

  • 依然是无状态的,性能的问题与select差不多一样;
  • 应用程序仍然无法很方便的拿到那些“有事件发生的fd“,还是需要遍历所有注册的fd。

发表评论

电子邮件地址不会被公开。 必填项已用*标注