乐趣区

文件系统与异步操作异步IO那些破事

为什么想起写这篇文章

前面这篇文章提到,旧的 Linux AIO 只支持直接(Direct)IO,还对读写区域大小有限制,但是 Windows 上的 IOCP 就有完整的 AIO 支持。之前真的觉得 Windows 真的很牛 B,但是对为什么这样一直懵懵懂懂。

直到昨天我看到了这篇讨论帖:https://news.ycombinator.com/…,他说微软的异步 IO 是用线程模拟的。

WTF?这个内核原生支持这么高大上的东西居然是模拟的?但是人家还拿了微软官方的文章佐证

… so if you issue an asynchronous cached read, and the pages are not in memory, the file system driver assumes that you do not want your thread blocked and the request will be handled by a limited pool of worker threads.

微软官方的说明总不会错。但为什么会这样?缓冲(Buffered)IO 实现异步为什么就这么难?

还得从硬件说起

回顾一下大学学的计算机硬件知识:https://www.cnblogs.com/jswan…

每个硬盘有多个盘面,每个盘面都是由一圈圈的同心圆组成的,叫做磁道(track)。每个磁道又被等比划分为若干个弧段,叫做扇区(sector)。扇区有固定的大小和个数,而且从硬盘诞生就被分配在固定位置。一般一个扇区具体的大小视总磁盘大小而定,传统上 512B 为一个扇区(但是也有不同)。

扇区是实际上的磁盘最小读写单位,操作系统与磁盘文件系统通信必须以扇区的整数个进行。这里的整数个不仅代表大小,而且指个体,例如你不能只读第一个扇区的后半个 + 第二个扇区的前半个,虽然加起来大小也是一个扇区。

直接(Direct)IO,最原始的文件 IO

这种扇区操作磁盘的方式就直接派生了一种文件 IO 方式——直接(Direct)IO,也叫裸(Raw)IO,也叫非缓存(Unbuffered)IO。在 *nix 系中对应 O_DIRECT,在 Windows 中对应 FILE_FLAG_NO_BUFFERING。

对于这种 IO 方式,读写操作都有硬性的要求,所有操作系统都一致:

  1. 数据传输的开始点,即文件和设备的偏移量,必须是扇区大小的整数倍
  2. 待传递数据的长度必须是扇区大小的整数倍
  3. 用于传递数据的缓冲区,其内存边界必须对齐为扇区大小的整数倍

前两个限制就是为了保证读写的都是一个个完整的扇区。第一个限制要求从一个扇区的开始读写,第二个限制要求正好到一个扇区完截止。第三个限制则是为了保证读写一个扇区时不发生页错误(page fault)

直接内存访问(DMA),现代文件 IO 机制,异步 IO 的基础

这些硬性要求都保证了,那么操作系统怎样实施文件 IO 操作呢?

这里以读为例。就是磁盘说:把从第 x 号扇区开始的 y 个扇区的数据写入到从 p 地址开始的内存中,写完了告诉我(触发中断)。

这种操作叫做直接内存访问(Direct memory access),其整个过程都不需要 CPU 参与。这个过程中 CPU 有两个选择:等待或者去做其他事。前者就是同步 IO,后者就是异步 IO。所以异步 IO 中实现直接 IO 是毫无问题的。

缓冲 IO,操作方便性的妥协

直接 IO 环节少速度快。但是限制太多太复杂了,人们不想用。

设想一下,如果用户想把文件中间的某个部分读取至内存,使用直接 IO 需要怎么做?假设用户需要读取 size 个字节,开始位置为 offset,需要读取至的内存指针为 p

// 首先获取扇区大小
int sectsize;
ioctl(fd, BLKSSZGET, &sectsize);
// 存储真实需要读取的数据量
size_t actual_size = size;
// 需要读取的开始部分
size_t start = offset;
// 需要读取的第一个扇区可能不完整
if (offset % sectsize != 0) {
    // 需要找到这段数据是从哪个扇区开始存储的(相对于文件头),从扇区的开始位置读取
    start -= offset % sectsize;
    // 把需要多读的数据量计算进去
    actual_size += start - offset;
}
// 需要读的最后一个扇区也可能不完整
if (actual_size % sectsize != 0) {
    // 需要读满整个尾扇区
    actual_size += sectsize - actual_size % sectsize;
}
// 终于算出了所需要的所有参数,开辟临时内存
void *buf = aligned_alloc(sectsize, actual_size); // aligned_alloc 保证申请的内存地址按指定字节数对齐
// 执行读操作,可以异步
pread(fd, buf, actual_size, start);
// 将读出的数据中所需要的部分复制到指定内存
memcpy(p, ((char *)buf + (start - offset)), size);
// 释放零时内存
free(buf);

这是读操作,写操作更复杂。为了保证填补区域空间不被写操作冲掉,你要先把填补空间的数据从文件里读出来。

为了简化用户端操作,所有的内核都提供基于缓冲机制(类似如上操作)的 IO 操作方式,这就是缓冲(Buffered)IO

缓冲 IO——异步 IO 的原罪

前面说到异步 IO 中实现直接 IO 毫无问题,因为直接 IO 一旦开始全程不需要 CPU 参与。但是缓冲 IO 不一样了,还是以上面的读操作举例,申请内存可以在打开文件时做,释放内存可以在关闭文件时做,数据对齐操作是纯计算而且本身计算量不大同步做了也无所谓。但是最后的内存复制和这次读操作强关联,无论如何省不掉。

那么怎么办呢?内核为了不阻断当前线程运行,必须开辟一个线程池等待所需的直接 IO 操作完成,然后做如上内存复制操作 。线程池中线程的数量是有限的, 如果程序同时发起了多个异步的缓存 IO 请求,导致内核中的线程不够用,那么本次异步操作会直接改为阻塞执行

Windows、Linux、*BSD,无一例外。旧的 Linux AIO 最直接,遇到缓存 IO 直接强制阻塞运行,新的 io_uring 使用了线程池,它牛 B 的地方是可以让线程强轮询 IO 操作是否完成,而不使用硬件中断唤醒线程的方式,以减少唤醒线程所需要的额外延时。

微软的文章还提到了几个让异步操作变同步运行的情形,比如数据压缩加密等等。总而言之,异步 IO 操作在现如今阶段还有很大的局限性,Linux 新的异步 IO 特性 io_uring 已经用了线程池,恐怕也不会有什么大的改观。在没有硬件支持的情况下,哪有什么黑魔法呢?

注:本文大体基于官方描述,也有部分是笔者的猜想,比如缓冲 IO 操作也许远非笔者想象的那么简单。如果有错欢迎提出。

退出移动版