lk16_00 图 1:第 16 章章首插图

这一篇在干嘛?

前几章的积木都备齐了:VFS(第 12 章)、块设备处理(第 14 章)、页缓存(第 15 章)。本章把三者串成一条完整的链路,回答”一次 read()/write() 到底经历了什么”。同时覆盖文件访问的五种方式:常规模式、同步模式、内存映射、直接 I/O 和异步 I/O——每种方式对应不同的性能特征和适用场景。

访问文件的五种方式 | 读文件与写文件 | readpage 方法 | 文件预读 | 把脏页写回磁盘 | 内存映射 | 直接 I/O | 异步 I/O

访问文件的五种方式

访问磁盘文件是个复杂活动,牵涉 VFS 抽象层(第 12 章)、块设备处理(第 14 章)和页缓存(第 15 章)。本章展示内核如何在这些设施之上完成文件读写。本章的内容对磁盘文件系统中的常规文件和块设备文件同样适用,下文统称为”文件”。

我们工作的舞台从”相应文件对象的 read/write 方法已被调用”(第 12 章)开始:每次 read 如何以数据送进用户态进程收尾,每次 write 如何以数据标记为”待传输到磁盘”收尾。剩余的传输由第 14、15 章描述的设施完成。

访问文件的方式很多,本章考虑五种:

  1. 常规模式(Canonical mode):文件以 O_SYNC 和 O_DIRECT 清零的方式打开,内容通过 read()write() 系统调用访问。read() 会阻塞调用进程直到数据拷入用户态地址空间(不过内核总是允许返回比请求少的字节数);write() 则不同,数据一拷入页缓存就终止(延迟写)。见”读文件与写文件”一节。
  2. 同步模式(Synchronous mode):文件以 O_SYNC 标志打开(或事后用 fcntl() 设置)。该标志只影响写操作(读操作总是阻塞的):写会阻塞调用进程直到数据真正写到磁盘。同样见”读文件与写文件”。
  3. 内存映射模式(Memory mapping mode):打开文件后应用发 mmap() 把文件映射进内存。文件于是呈现为 RAM 中的一个字节数组,应用直接访问数组元素,不用 read()/write()/lseek()。见”内存映射”一节。
  4. 直接 I/O 模式(Direct I/O mode):文件以 O_DIRECT 标志打开。任何读写都直接在用户态地址空间与磁盘之间传输数据,绕过页缓存。见”直接 I/O”一节。(O_SYNC 和 O_DIRECT 标志有四种有意义的组合。)
  5. 异步模式(Asynchronous mode):通过一组 POSIX API 或 Linux 特有系统调用执行”异步 I/O”——数据传输请求从不阻塞调用进程,而是在”后台”进行,应用继续正常执行。见”异步 I/O”一节。

读文件与写文件

第 12 章讲过 read()/write() 系统调用的实现:服务例程最终调用文件对象的 read/write 方法,这些方法可能随文件系统而异。对磁盘文件系统,方法定位包含所访数据的物理块、激活块设备驱动启动传输。

读文件以页为单位:内核总是一次传输整页数据。进程发 read() 想要几个字节而这数据不在 RAM 中时,内核分配一个新页框、用文件适当部分填充该页、把页加进页缓存,最后把所求字节拷进进程地址空间。对多数文件系统,从文件读一页只是找出磁盘上哪些块包含所求数据;之后内核向通用块层提交合适的 I/O 操作来填充页。实践中,所有磁盘文件系统的 read 方法都由同一个公共函数 generic_file_read() 实现

写操作稍微复杂,因为文件大小可能增长、内核可能要在磁盘上分配物理块——具体怎么做取决于文件系统类型。许多磁盘文件系统(如 Ext2、System V/Coherent/Xenix、MINIX)用公共函数 generic_file_write() 实现 write 方法;而日志文件系统、网络文件系统等则用自定义函数。

读:generic_file_read()

generic_file_read() 实现块设备文件和几乎所有磁盘文件系统常规文件的 read 方法,参数:

  • filp:文件对象地址;
  • buf:用户态内存区线性地址,从文件读出的字符存这里;
  • count:要读的字符数;
  • ppos:指针,指向存放读取起始偏移的变量(通常是 filp 的 f_pos 字段)。

函数第一步初始化两个描述符:局部变量 local_iov(iovec 类型)——包含接收数据的用户态缓冲的地址 buf 和长度 count;局部变量 kiocb(kiocb 类型)——跟踪进行中的同步或异步 I/O 操作的完成状态。kiocb 主要字段:

类型字段说明
struct list_headki_run_list稍后重试的 I/O 操作链表指针
longki_flagskiocb 描述符的标志
intki_userskiocb 描述符使用计数
unsigned intki_key异步 I/O 操作标识符;同步操作为 KIOCB_SYNC_KEY (0xffffff)
struct file *ki_filp进行中 I/O 操作关联的文件对象指针
struct kioctx *ki_ctx本操作的异步 I/O 上下文描述符指针
int (*)(struct kiocb *, struct io_event *)ki_cancel取消异步 I/O 操作时调用的方法
ssize_t (*)(struct kiocb *)ki_retry重试异步 I/O 操作时调用的方法
void (*)(struct kiocb *)ki_dtor销毁 kiocb 描述符时调用的方法
struct list_headki_list异步 I/O 上下文的活动操作链表指针
unionki_obj同步操作:发起 I/O 的进程描述符指针;异步操作:用户态 iocb 结构指针
__u64ki_user_data返回给用户态进程的值
loff_tki_pos进行中 I/O 操作的当前文件位置
unsigned shortki_opcode操作类型(read、write 或 sync)
size_tki_nbytes要传输的字节数
char *ki_buf数据传输的内核缓冲当前位置

generic_file_read()init_sync_kiocb 宏初始化 kiocb(为同步操作设置字段:ki_key = KIOCB_SYNC_KEY、ki_filp = filp、ki_obj = current),然后调 __generic_file_aio_read()(传入刚填好的 iovec 和 kiocb 地址),返回其结果——通常即实际读到的字节数。

__generic_file_aio_read() 是所有文件系统实现同步与异步读操作的通用例程,参数四个:kiocb 描述符地址 iocb、iovec 描述符数组地址 iov、数组长度、存文件当前指针的变量地址 ppos。由 generic_file_read() 调用时 iovec 数组只有一个元素(描述接收数据的用户态缓冲)。下面只讲最常见情形:对页缓存文件由 read() 系统调用发起的同步操作

  1. access_ok() 验证 iovec 描述符描述的用户态缓冲有效——起始地址和长度来自 sys_read() 服务例程,用前必须检查(第 10 章)。无效返回 -EFAULT。
  2. 建立读操作描述符——read_descriptor_t 类型的结构,存放相对单个用户态缓冲的文件读操作当前状态:
类型字段说明
size_twritten已拷入用户态缓冲的字节数
size_tcount尚待传输的字节数
char *arg.buf用户态缓冲中的当前位置
interror读操作的错误码(0 为无错)
  1. do_generic_file_read(),传文件对象指针 filp、文件偏移指针 ppos、刚分配的读操作描述符地址、file_read_actor() 函数地址(见下)。
  2. 返回拷入用户态缓冲的字节数——即 read_descriptor_t 的 written 字段。

do_generic_file_read() 从磁盘读所求页并拷进用户态缓冲:

  1. 取正读文件的 address_space 对象,地址在 filp->f_mapping

  2. 取 address_space 的拥有者——即将拥有被填充页的 inode 对象,地址在 address_space 的 host 字段。若正读的是块设备文件,拥有者是 bdev 特殊文件系统里的 inode,而非 filp->f_dentry->d_inode 指向的 inode(第 15 章);

  3. 把文件看成按页细分的数据(每页 4096 字节),由文件指针 *ppos 推导包含第一个所求字节的页的逻辑号(即页在地址空间中的索引)存进 index 局部变量,页内位移存进 offset 局部变量;

  4. 开始循环读取包含所求字节的所有页(待读字节数在 read_descriptor_t 的 count 字段)。每次迭代传输一页:

    a. 若 index*4096+offset 超过 inode 对象 i_size 字段存的文件大小,退出循环到第 5 步;

    b. 调 cond_resched() 检查当前进程的 TIF_NEED_RESCHED 标志,置位则调 schedule()

    c. 若需预读额外的页,调 page_cache_readahead()(预读见后文);

    d. 调 find_get_page()(参数为 address_space 指针和 index)在页缓存中查找存放所求数据的页描述符;

    e. 返回 NULL 说明所求页不在页缓存中:调 handle_ra_miss() 调整预读参数;分配新页;调 add_to_page_cache() 把新页描述符插进页缓存(记住:该函数会置 PG_locked 标志);调 lru_cache_add() 把新页插进 LRU 链表(第 17 章);跳到 4j 开始读文件数据;

    f. 页在页缓存中:检查 PG_uptodate 标志;置位说明页中数据是最新的,无需读盘:跳到 4m;

    g. 页上数据无效,必须读盘。调 lock_page() 获得页的独占访问(第 15 章讲过:PG_locked 已置位时它会挂起当前进程直到该位清除);

    h. 现在页被当前进程锁定。但另一个进程可能恰在上一步之前把页从页缓存移除了,所以要检查页描述符的 mapping 字段是否为 NULL;是则调 unlock_page() 解锁、递减其使用计数(find_get_page() 递增过)、跳回 4a 从同一页重新开始;

    i. 页已锁定且仍在页缓存中。再次检查 PG_uptodate——因为另一个内核控制路径可能在 4f 与 4g 之间完成了读取;置位则调 unlock_page()、跳到 4m 跳过读操作;

    j. 现在可以启动真正的 I/O 操作了:调文件 address_space 对象的 readpage 方法,对应函数负责激活从磁盘到页的 I/O 数据传输(下节细讲常规文件和块设备文件的情况);

    k. PG_uptodate 仍为 0 则调 lock_page() 等待页被真正读完——4g 中锁定的页会在读操作结束时解锁,因此当前进程睡到 I/O 传输终止;

    l. 若 index 超过文件的页数(inode 的 i_size 除以 4096),递减页使用计数、退出循环到第 5 步——这种情况发生在正读的文件被另一进程并发截断时;

    m. 把应拷入用户态缓冲的字节数存进 nr 局部变量——通常等于页大小 4096,除非 offset 非 0(只会发生在所求数据的第一页或最后一页)或文件不含全部所求字节;

    n. 调 mark_page_accessed() 置 PG_referenced 或 PG_active 标志,表示页正在使用、不应被换出(第 17 章)。同一页在 do_generic_file_read() 的连续执行中被多次读时,这一步只在第一次读时执行;

    o. 把页上的数据拷进用户态缓冲——调以参数传入的 file_read_actor(),它依次:调 kmap()(页在高内存时建立永久内核映射,第 8 章);调 __copy_to_user() 把页上数据拷进用户态地址空间(第 10 章;注意访问用户态地址空间时的缺页可能阻塞进程);调 kunmap() 释放页的永久内核映射;更新 read_descriptor_t 的 count、written、buf 字段;

    p. 按实际传输的字节数更新 index 和 offset:通常页的最后一个字节已拷入用户态缓冲时 index 加一、offset 清零;否则 index 不变、offset 设为页内已拷贝字节数;

    q. 递减页描述符使用计数;

    r. read_descriptor_t 的 count 不为零(还有数据要读)则跳回 4a 继续文件的下一页。

  5. 所求(或可用)的字节全部读完。函数更新 filp->f_ra 预读数据结构,记录正在从文件顺序读数据的事实(见”文件预读”);

  6. index*4096+offset 赋给 *ppos——存下未来 read()/write() 顺序访问将发生的位置;

  7. update_atime() 把当前时间存进文件 inode 的 i_atime 字段、把 inode 标记为脏,返回。

写:generic_file_write()

回顾一下:write() 系统调用要把数据从调用进程的用户态地址空间搬进内核数据结构、再到磁盘。文件对象的 write 方法允许每种文件系统定义专门的写操作。Linux 2.6 中,磁盘文件系统的 write 方法基本是:找出写操作涉及的磁盘块、把数据从用户态地址空间拷进页缓存的一些页、把这些页中的缓冲标记为脏。

许多文件系统(包括 Ext2、JFS)用 generic_file_write() 实现 write 方法,参数:

  • file:文件对象指针;
  • buf:用户态地址空间中待写字符的提取地址;
  • count:要写的字符数;
  • ppos:存放写入起始文件偏移的变量地址。

执行步骤:

  1. 初始化一个 iovec 类型局部变量,含用户态缓冲的地址和长度;
  2. 确定对应待写文件的 inode 对象地址(file->f_mapping->host)并获取 inode->i_sem 信号量——同一时刻只允许一个进程对该文件发 write()
  3. 调 init_sync_kiocb 宏初始化 kiocb 类型局部变量(ki_key = KIOCB_SYNC_KEY、ki_filp = file、ki_obj = current);
  4. __generic_file_aio_write_nolock()(见下)把受影响页标记为脏,传 iovec 和 kiocb 局部变量地址、用户态缓冲的段数(此处只有 1 个)和参数 ppos;
  5. 释放 inode->i_sem
  6. 检查文件的 O_SYNC 标志、inode 的 S_SYNC 标志、超块的 MS_SYNCHRONOUS 标志;任一置位则调 sync_page_range() 强制内核刷新第 4 步触及的所有页缓存页,阻塞当前进程直到 I/O 传输终止。sync_page_range() 依次:执行 address_space 对象的 writepages 方法(有定义的话)或 mpage_writepages() 函数(见”把脏页写回磁盘”)启动脏页 I/O 传输;调 generic_osync_inode() 把 inode 及关联缓冲刷到磁盘;调 wait_on_page_bit() 挂起当前进程直到所有已刷页的 PG_writeback 位清除;
  7. 返回 __generic_file_aio_write_nolock() 的返回码——通常是实际写入的字节数。

__generic_file_aio_write_nolock() 参数四个:kiocb 地址 iocb、iovec 数组地址 iov、数组长度、存文件当前指针的变量地址 ppos。由 generic_file_write() 调用时 iovec 数组只有一个元素(描述含待写数据的用户态缓冲)。同样只讲最常见情形:对页缓存文件由 write() 发起的常规模式操作。执行:

  1. access_ok() 验证 iovec 描述的用户态缓冲有效(地址和长度来自 sys_write() 服务例程,用前必须检查,第 10 章)。无效返回 -EFAULT;

  2. 确定对应待写文件的 inode 对象地址(file->f_mapping->host)。文件是块设备文件的话,这是 bdev 特殊文件系统里的 inode(第 14 章);

  3. current->backing_dev_info 设为文件的 backing_dev_info 描述符地址(file->f_mapping->backing_dev_info)。这一设置允许当前进程即使相应请求队列拥塞也回写 file->f_mapping 拥有的脏页(第 17 章);

  4. file->flags 的 O_APPEND 置位且文件是常规文件(非块设备文件)时,把 *ppos 设为文件末尾——新数据全部追加到文件尾;

  5. 对文件大小做多项检查:写操作不得把常规文件扩大到超过每用户限额 current->signal->rlim[RLIMIT_FSIZE](第 3 章)和文件系统限额 inode->i_sb->s_maxbytes;文件不是”大文件”(file->f_flags 的 O_LARGEFILE 清除)时大小不能超过 2 GB。任一约束不满足就减少待写字节数;

  6. 有设置的话清文件的 suid 标志;文件可执行的话连 sgid 一起清(第 1 章”访问权限与文件模式”)——不能让用户修改 setuid 文件;

  7. 把当前时间存进 inode 的 mtime(最后一次文件写操作时间)和 ctime(最后一次 inode 变更时间),把 inode 对象标记为脏;

  8. 开始循环更新写操作涉及的所有文件页,每次迭代:

    a. 调 find_lock_page() 在页缓存中搜索该页(第 15 章)。找到则递增其使用计数、置 PG_locked 标志;

    b. 页不在页缓存则分配新页框、调 add_to_page_cache() 把页插进页缓存(该函数同样递增使用计数、置 PG_locked),并把新页插进所在内存区的非活动链表(第 17 章);

    c. 调 inode(file->f_mapping)address_space 对象的 prepare_write 方法。对应函数负责为页分配和初始化缓冲区首部(下节细讲);

    d. 缓冲在高内存的话先建立用户态缓冲的内核映射(第 8 章);然后调 __copy_from_user() 把字符从用户态缓冲拷进页,释放内核映射;

    e. 调 inode address_space 对象的 commit_write 方法。对应函数把底层缓冲标记为脏、供稍后写盘(下节细讲);

    f. 调 unlock_page() 清 PG_locked 并唤醒等待该页的所有进程;

    g. 调 mark_page_accessed() 为内存回收算法更新页状态(第 17 章);

    h. 递减页使用计数,抵消第 8a/8b 步的递增;

    i. 本轮又弄脏了一页:检查页缓存脏页比例是否升过固定阈值(通常为系统页数的 40%);是则调 writeback_inodes() 启动几十个页的刷盘(第 15 章);

    j. 调 cond_resched() 检查当前进程的 TIF_NEED_RESCHED 标志,置位则调 schedule()

  9. 写操作涉及的所有文件页处理完毕。更新 *ppos 指向最后一个写入字符之后;

  10. current->backing_dev_info 设回 NULL(见第 3 步);

  11. 返回实际写入的字节数。

readpage 方法:数据从磁盘到页

do_generic_file_read() 反复用 readpage 方法把单独的页从磁盘读进内存。address_space 对象的 readpage 方法存放真正激活”物理磁盘 → 页缓存”I/O 传输的函数地址。

常规文件的 readpage

对常规文件,这个字段通常指向一个包装器,它调用 mpage_readpage() 函数。比如 Ext3 文件系统的 readpage 方法:

int ext3_readpage(struct file *file, struct page *page)
{
    return mpage_readpage(page, ext3_get_block);
}

为什么需要包装器?因为 mpage_readpage() 接收两个参数:待填充页的描述符 page,和一个帮助它找到正确块的函数地址 get_block。包装器是文件系统特定的,因此能提供正确的 get_block 函数——它把相对文件开头的块号翻译成相对磁盘分区位置的逻辑块号(示例见第 18 章)。get_block 函数总是用缓冲区首部存放宝贵信息:块设备(b_dev 字段)、所求数据在设备上的位置(b_blocknr 字段)和块状态(b_state 字段)。

mpage_readpage() 读页时在两种策略中选择:包含所求数据的块在磁盘上连续——用单个 bio 描述符向通用块层提交读 I/O 操作;不连续——页中每块用不同的 bio 描述符读。文件系统的 get_block 函数起决定性作用:判断文件的下一块是否也是磁盘上的下一块。

具体步骤:

  1. 检查页描述符的 PG_private 字段:置位说明页是缓冲页——关联着一串描述组成该页的块的缓冲区首部(第 15 章)。这意味着该页过去已从磁盘读过、页中块在磁盘上不相邻:跳到第 11 步逐块读;
  2. 取块大小(存在 page->mapping->host->i_blkbits inode 字段),算出访问该页所有块所需的两个值:页中存放的块数、页中第一块的文件块号(相对文件开头的索引);
  3. 对页中每个块,调作为参数传入的文件系统相关 get_block 函数取得逻辑块号(相对磁盘或分区开头的索引),全部存进局部数组;
  4. 检查第 3 步中的异常情况:某些块在磁盘上不相邻、某些块落入”文件洞”(第 18 章)、某个块缓冲已被 get_block 函数填充——任一成立则跳到第 11 步逐块读;
  5. 走到这里说明页中所有块在磁盘上相邻。但该页可能是文件的最后一页数据,某些块可能没有磁盘映像——是则把页中对应块缓冲填零;否则置页描述符的 PG_mappedtodisk 标志;
  6. bio_alloc() 分配由单个段组成的新 bio 描述符,用块设备描述符地址和页中第一块的逻辑块号初始化 bi_bdevbi_sector 字段(两者都来自第 3 步);
  7. 用页的起始地址、待读首字节偏移(零)、待读总字节数设置 bio 段的 bio_vec 描述符;
  8. mpage_end_io_read() 函数地址存进 bio->bi_end_io 字段;
  9. submit_bio():设置 bi_rw 标志为传输方向、更新 page_state 每 CPU 变量统计读扇区数、对 bio 描述符调 generic_make_request()(第 14 章);
  10. 返回 0(成功)。
  11. (跳到此处的情形)页含不相邻的块。页是最新的(PG_uptodate 置位)则调 unlock_page() 解锁;否则调 block_read_full_page() 逐块读页(见下);
  12. 返回 0(成功)。

mpage_end_io_read() 是 bio 的完成方法,I/O 传输一终止就执行。没有 I/O 错误的话:置页描述符的 PG_uptodate 标志、调 unlock_page() 解锁并唤醒睡等此事件的所有进程、调 bio_put() 销毁 bio 描述符。

块设备文件的 readpage

第 13 章和第 14 章讲过内核如何处理打开块设备文件的请求:init_special_inode() 建立设备 inode、blkdev_open() 完成打开阶段。

块设备使用一个存放在 bdev 特殊文件系统中对应块设备 inode 的 i_data 字段里的 address_space 对象。与常规文件不同(其 readpage 方法取决于文件所属文件系统类型),块设备文件的 readpage 方法永远是同一个——由 blkdev_readpage() 实现,它调 block_read_full_page()

int blkdev_readpage(struct file * file, struct * page page)
{
    return block_read_full_page(page, blkdev_get_block);
}

又是一个包装器,这次包装 block_read_full_page()。第二个参数指向把”相对文件开头的块号”翻译成”相对块设备开头的逻辑块号”的函数。对块设备文件,两个数恰好相同,所以 blkdev_get_block() 只需:

  1. 检查页中第一块的块号是否超过块设备最后一块的索引(该索引 = bdev->bd_inode->i_size 除以 bdev->bd_block_size;bdev 指向块设备描述符)。超过则写操作返回 -EIO、读操作返回 0(读超过块设备末尾也不允许,但这里不该返回错误码——内核可能只是在派发块设备最后数据的读请求,相应缓冲页只是部分映射);
  2. 把缓冲区首部的 b_dev 字段设为 bdev;
  3. 把缓冲区首部的 b_blocknr 字段设为作为参数传入的文件块号;
  4. 置缓冲区首部的 BH_Mapped 标志,表示 b_devb_blocknr 字段有效。

block_read_full_page() 逐块读一页数据。如前所述,它既用于读块设备文件、也用于读块在磁盘上不相邻的常规文件页。步骤:

  1. 检查页描述符的 PG_private 标志;置位说明页已关联一串描述组成该页的块的缓冲区首部。否则调 create_empty_buffers() 为页中所有块缓冲分配缓冲区首部——页中第一个缓冲的缓冲区首部地址存进 page->private 字段,每个缓冲区首部的 b_this_page 字段指向页中下一个缓冲的缓冲区首部;
  2. 由相对页的文件偏移(page->index 字段)推导页中第一块的文件块号;
  3. 对页中每个缓冲的缓冲区首部:
    • BH_Uptodate 置位则跳过该缓冲、继续下一个;
    • BH_Mapped 未置位且块未越过文件末尾,则调作为参数传入的文件系统相关 get_block 函数。对常规文件,该函数在文件系统的磁盘数据结构里查找缓冲相对磁盘或分区开头的逻辑块号;对块设备文件,把文件块号当作逻辑块号。两种情况下都把逻辑块号存进相应缓冲区首部的 b_blocknr 字段、置 BH_Mapped 标志;
    • 再次测试 BH_Uptodate——因为 get_block 函数可能触发了一次更新该缓冲的块 I/O 操作;置位则继续下一缓冲;
    • 把缓冲区首部地址存进 arr 局部数组,继续下一缓冲;
  4. 上一步没有遇到文件洞则置页的 PG_mappedtodisk 标志;
  5. arr 数组存的是内容不是最新的那些缓冲的缓冲区首部地址。数组为空说明页中所有缓冲都有效:置页描述符的 PG_uptodate 标志、调 unlock_page() 解锁、返回;
  6. 数组非空。对其中每个缓冲区首部:置 BH_Lock 标志(已置位则等缓冲释放);把 b_end_io 字段设为 end_buffer_async_read() 函数地址(见下)、置 BH_Async_Read 标志;
  7. 对 arr 数组中每个缓冲区首部调 submit_bh()(指定 READ 操作)——如前所述触发相应块的 I/O 传输;
  8. 返回 0。

end_buffer_async_read() 是缓冲区首部的完成方法,块缓冲的 I/O 传输一终止就执行。没有 I/O 错误的话:置缓冲区首部的 BH_Uptodate、清 BH_Async_Read;然后取包含该块缓冲的缓冲页描述符(地址在缓冲区首部的 b_page 字段),检查页中所有块是否都是最新的——是则置页的 PG_uptodate 标志、调 unlock_page()

文件预读

许多磁盘访问是顺序的。第 18 章会看到,常规文件在磁盘上以大片相邻扇区存放,所以磁头少量移动就能快速取回。程序读或复制文件时常从头到尾顺序访问,于是处理同一文件的一系列读请求时,磁盘上大量相邻扇区很可能被顺次取回。

**预读(read-ahead)**就是在一页数据被真正请求之前,先读入常规文件或块设备文件的若干相邻页。多数情况下预读显著提升磁盘性能:磁盘控制器要处理的命令更少、每条命令对应更大一片相邻扇区;同时系统响应性更好——顺序读文件的进程通常不必等待所求数据,因为它已经在 RAM 里。

但应用随机访问文件时预读毫无用处,甚至有害——它在页缓存里浪费空间存无用信息。所以当内核判定最近一次 I/O 访问与上一次不连续时,会缩减乃至停止预读。

文件预读需要精致算法,原因有好几个:

  • 数据按页读,算法不必考虑页内偏移,只考虑被访问页在文件内的位置;
  • 进程持续顺序访问文件时,预读可以逐渐加大
  • 当前访问相对上一次不连续(随机访问)时,必须缩减甚至停用预读;
  • 进程反复访问同一批页(只用了文件的一小部分)、或文件几乎所有页都已在页缓存中时,应停止预读;
  • 底层 I/O 设备驱动应在恰当时机被激活,让”未来”的页在进程需要时已经传输完毕。

内核判定一次文件访问相对上一次是顺序的,条件是:本次请求的第一页恰好是上次访问最后请求页的下一页

预读算法使用两组页,各对应文件的一段连续区域:当前窗口(current window)预读窗口(ahead window)

  • 当前窗口:由进程请求的页或内核预读并已进页缓存的页组成(窗口内的页不一定是最新的——I/O 传输可能还在进行)。它既包含进程最后顺序访问的页,也可能包含内核已预读但进程尚未请求的页;
  • 预读窗口:紧跟当前窗口之后的页,正在被内核预读。其中没有任何页已被进程请求,但内核假定进程迟早会要。

内核识别出顺序访问且起始页属于当前窗口时,检查预读窗口是否已建立;没有就创建新预读窗口并触发相应页的读操作。理想情况下,预读窗口的页正在传输时进程仍在请求当前窗口的页;进程请求到预读窗口内的页时,预读窗口升级为新的当前窗口

预读算法的主数据结构是 file_ra_state 描述符,每个文件对象的 f_ra 字段里都有一个:

类型字段说明
unsigned longstart当前窗口第一页的索引
unsigned longsize当前窗口包含的页数(-1 表示预读暂时停用,0 表示当前窗口为空)
unsigned longflags控制预读的标志
unsigned longcache_hit连续缓存命中数(进程请求且在页缓存中找到的页数)
unsigned longprev_page进程最后请求的页索引
unsigned longahead_start预读窗口第一页的索引
unsigned longahead_size预读窗口的页数(0 表示空)
unsigned longra_pages预读窗口的最大页数(0 表示永久停用预读)
unsigned longmmap_hit预读命中计数(用于内存映射文件)
unsigned longmmap_miss预读未命中计数(用于内存映射文件)

文件打开时描述符所有字段清零,只有 prev_pagera_pages 除外。

prev_page 存上次读操作中进程最后请求的页索引,初值 -1。ra_pages 是当前窗口的最大页数——即该文件允许的最大预读;初值存在包含该文件的块设备的 backing_dev_info 描述符里(第 14 章)。应用可通过修改 ra_pages 为给定打开文件调优预读——用 posix_fadvise() 系统调用:POSIX_FADV_NORMAL(最大预读设回默认,通常 32 页)、POSIX_FADV_SEQUENTIAL(设为默认两倍)、POSIX_FADV_RANDOM(设为 0,永久停用预读)。

flags 字段里有两个重要标志:RA_FLAG_MISS——预读的页在页缓存中没找到时置位(多半被内核回收腾内存了,第 17 章),此时下一个预读窗口的尺寸会适当缩小;RA_FLAG_INCACHE——内核判定进程最近请求的 256 页全部在页缓存中找到时置位(连续命中数存 ra->cache_hit 字段),此时关闭预读——内核认定进程所需的页全都已在缓存。

预读算法何时执行?以下情形:

  • 内核处理读文件页的用户态请求——触发 page_cache_readahead()(见”读文件”一节 do_generic_file_read() 的第 4c 步);
  • 内核为文件内存映射分配页(见后文 filemap_nopage(),它同样调 page_cache_readahead());
  • 用户态应用执行 readahead() 系统调用,对文件描述符显式触发预读;
  • 用户态应用以 POSIX_FADV_NOREUSE 或 POSIX_FADV_WILLNEED 命令执行 posix_fadvise(),告知内核给定范围的文件页近期将被访问;
  • 用户态应用以 MADV_WILLNEED 命令执行 madvise(),告知内核文件内存映射区中给定页范围的页近期将被访问。

page_cache_readahead() 函数

page_cache_readahead() 负责所有非系统调用显式触发的预读操作:补充当前和预读窗口、按预读命中数(即过往访问中预读策略的成功程度)更新窗口尺寸。参数五个:mapping(描述页拥有者的 address_space 对象指针)、ra(含页的文件的 file_ra_state 描述符指针)、filp(文件对象地址)、offset(页在文件内的偏移)、req_size(完成当前读操作还需读取的页数)。

lk16_01 图 1:page_cache_readahead() 函数的流程图

函数本质上只操作 file_ra_state 描述符的字段,所以尽管流程图的描述相当非正式,也能轻松推断实际步骤。比如”检查所求页是否与上次读的页相同”就转化为比较 ra->prev_page 字段与 offset 参数。

  • 进程首次访问文件且首个所求页在文件偏移 0 处:函数假定进程会顺序访问,从第一页开始创建新当前窗口。初始当前窗口的长度——总是 2 的幂——与进程第一次读请求的页数相关:请求页数越多窗口越大,上限是 ra->ra_pages。反过来,首次访问但首个所求页不在偏移 0 处:假定进程不会顺序访问,暂时停用预读ra->size 设为 -1);不过暂时停用期间一旦识别出顺序访问,仍会创建新当前窗口;
  • 预读窗口不存在时,一旦识别出进程在当前窗口内做了顺序访问,立即创建。预读窗口总是从当前窗口最后一页的下一页开始;长度与当前窗口长度的关系:RA_FLAG_MISS 置位时为”当前窗口长度减 2”(不足 4 则取 4);否则为当前窗口长度的 4 倍或 2 倍。进程持续顺序访问文件的话,预读窗口最终升级为新当前窗口、再创建新预读窗口——顺序读文件的进程会让预读劲头十足地滚雪球
  • 一旦识别出相对上一次不连续的文件访问,当前窗口和预读窗口被清空、预读暂时停用;进程做出相对上次访问连续的读操作时,预读从头重新开始。

每次创建新窗口,函数都会启动窗口内各页的读操作——调 blockable_page_cache_readahead() 读一批页。为降低内核开销,它有几个聪明的设计:

  • 服务该块设备的请求队列读拥塞时不读(继续加塞只会加剧拥塞、堵死预读);
  • 对每个待读页先查页缓存,已在缓存的直接跳过;
  • 读请求所需的所有页框一次性分配再执行磁盘读;不能全拿到就对可用的页做预读——推迟到页框齐全毫无意义;
  • 尽可能用多段 bio 描述符向通用块层提交读操作(第 14 章”段”)——由 address_space 对象专门的 readpages 方法完成(有定义的话),否则反复调 readpage 方法。

handle_ra_miss() 函数

有时预读策略看起来不给力,内核必须纠正预读参数。回顾 do_generic_file_read()page_cache_readahead() 在第 4c 步被调,按图 1 的流程,所求页要么在当前/预读窗口里(本该已预读),要么不在、由 blockable_page_cache_readahead() 去读。两种情况下 do_generic_file_read() 在第 4d 步都应该能在页缓存里找到该页——找不到就说明页框回收算法把页从缓存里逐出去了。此时它调 handle_ra_miss():置 RA_FLAG_MISS 标志、清 RA_FLAG_INCACHE 标志,调校预读算法。

把脏页写回磁盘

write() 系统调用的净效果是修改页缓存中一些页的内容——必要时分配页并加入页缓存。某些情况下(比如文件以 O_SYNC 打开)I/O 传输立即启动(见”写文件”第 6 步);通常 I/O 传输是延迟的(第 15 章讲过)。

内核真正要启动 I/O 传输时,最终调文件 address_space 对象的 writepages 方法——在基数树中搜索脏页并刷盘。例如 Ext2 的 writepages 方法:

int ext2_writepages(struct address_space *mapping,
    struct writeback_control *wbc)
{
    return mpage_writepages(mapping, wbc, ext2_get_block);
}

又是对通用函数 mpage_writepages() 的简单包装;文件系统不定义 writepages 方法时,内核直接调 mpage_writepages()、第三参数传 NULL。ext2_get_block() 前文已介绍——文件系统相关的、把文件块号翻译成逻辑块号的函数。writeback_control 结构控制回写操作的执行方式(第 15 章”查找待刷新的脏页”讲过)。

mpage_writepages() 执行:

  1. 请求队列写拥塞且进程不想阻塞时,直接返回、一页不写;
  2. 确定文件的起始处理页。writeback_control 指定了文件内起始位置则换算成页索引;writeback_control 指定进程不想等 I/O 完成,则起始页索引取 mapping->writeback_index(即从上次回写考虑的最后一页继续扫描);进程必须等 I/O 完成时,从文件第一页开始扫;
  3. find_get_pages_tag() 在页缓存中查找脏页描述符(第 15 章”基数树的标签”);
  4. 对取回的每个页描述符:
    • lock_page() 锁定页;
    • 检查页仍有效、仍在页缓存(因为另一内核控制路径可能已在第 3、4a 步之间动过它);
    • 检查页的 PG_writeback 标志:置位说明页已在刷盘中。进程必须等 I/O 完成则调 wait_on_page_bit() 阻塞当前进程直到 PG_writeback 清除(函数返回时所有先前进行中的回写都已结束);进程不想等则检查 PG_dirty:现在已清除说明进行中的回写会照料这页,解锁并跳回 4a 处理下一页;
    • get_block 参数为 NULL(无 writepages 方法)则调文件 address_space 对象的 writepage 方法刷盘;否则调 mpage_writepage()(见第 8 步);
  5. cond_resched() 检查 TIF_NEED_RESCHED,置位则调 schedule()
  6. 未扫完给定范围的所有页、或实际写盘页数小于 writeback_control 原定值,跳回第 3 步;
  7. writeback_control 未指定文件内起始位置时,把最后扫描页的索引存进 mapping->writeback_index
  8. 第 4d 步调过 mpage_writepage() 且其返回了 bio 描述符地址的话,调 mpage_bio_submit()(见下)。

典型文件系统(如 Ext2)把 writepage 方法实现为通用函数 block_write_full_page() 的包装器(传文件系统相关的 get_block 函数地址)。block_write_full_page() 与前文 block_read_full_page() 类似:为页分配缓冲区首部(页还不是缓冲页的话),对每个缓冲区首部调 submit_bh()、指定 WRITE 操作。块设备文件则用 blkdev_writepage() 实现 writepage——同样是 block_write_full_page() 的包装器。

许多非日志文件系统不用定制 writepage 方法,而依赖 mpage_writepage()——这能提升性能:它尽量把尽可能多的页收进同一个 bio 描述符提交 I/O,让块设备驱动得以发挥现代硬盘控制器的 scatter-gather DMA 能力。长话短说:mpage_writepage() 检查待写页是否含磁盘上不相邻的块、是否含文件洞、是否有块不脏或不是最新——任一条件成立就退回文件系统相关的 writepage 方法(如上);否则把页作为一个段加进 bio 描述符。bio 地址作为参数传入:为 NULL 时 mpage_writepage() 初始化新 bio 并把地址返回给调用者,调用者后续调用时再传回来——多页得以进同一个 bio;待加页与 bio 中最后加入的页不相邻时,调 mpage_bio_submit() 启动该 bio 的 I/O 传输、为该页分配新 bio。

mpage_bio_submit() 把 bio 的 bi_end_io 方法设为 mpage_end_io_write() 地址,然后调 submit_bio() 启动传输(第 15 章)。传输成功结束后,完成函数 mpage_end_io_write() 唤醒所有等待该页传输完成的进程、销毁 bio 描述符。

常见坑:为什么读总是整页地读?

进程可能只要 10 个字节,内核却读回一整页 4096 字节——初学者常以为这是浪费。其实这是页缓存机制的核心设计:页是缓存的最小单位,多读的 4086 字节大概率马上被后续 read() 命中(顺序访问模式);再叠加预读把窗口滚雪球式扩大,磁盘寻道次数大幅下降。真正的浪费场景是纯随机访问——这正是预读算法要识别并关闭的情况。

内存映射

第 9 章讲过,内存区域可以关联到磁盘文件系统中某个常规文件或块设备文件的一部分:对内存区域某页内一个字节的访问,被内核翻译成对文件相应字节的操作——这就是内存映射(memory mapping)

映射分两种:

  • 共享(shared):对内存区域页的每次写操作都会改变磁盘上的文件;而且一个进程往共享映射的页里写,所有映射同一文件的其他进程都看得见变化;
  • 私有(private):适合进程建立映射只为读文件不写文件的情形——私有映射比共享映射高效。但对私有映射页的每次写操作都会让该页停止映射文件(写时复制,第 8 章):写不改变磁盘文件、其他访问同一文件的进程也看不见。不过私有映射中未被进程修改的页仍然会受其他进程文件更新的影响。

进程发 mmap() 系统调用创建映射,必须显式指定 MAP_SHARED 或 MAP_PRIVATE 标志。映射建好后,进程读新内存区域的内存单元就是读文件数据;共享映射下写这些单元就是改文件。munmap() 系统调用销毁或收缩映射。

一般规则:共享映射的内存区域置 VM_SHARED 标志,私有映射清除。(只读共享映射是个例外,见下文。)

内存映射的数据结构

一个内存映射由以下数据结构组合表示:

  • 与被映射文件关联的 inode 对象;
  • 被映射文件的 address_space 对象;
  • 不同进程对该文件执行的每个不同映射对应一个 file 对象;
  • 对文件的每个不同映射对应一个 vm_area_struct 描述符;
  • 分配给映射该文件的内存区域的每个页框对应一个页描述符。

lk16_02 图 2:文件内存映射的数据结构

图的左侧是标识文件的 inode:每个 inode 对象的 i_mapping 字段指向文件的 address_space 对象;address_space 的 page_tree 字段指向属于该地址空间的页的基数树(第 15 章),i_mmap 字段指向第二棵树——属于该地址空间的内存区域的基数优先搜索树(PST)。PST 的主要用途是”逆向映射”:快速识别共享给定页的所有进程。PST 在下一章详讲(页框回收要用)。同一文件的各 file 对象与 inode 之间靠 f_mapping 字段建立联系。

每个内存区域描述符有 vm_file 字段链接到被映射文件的 file 对象(该字段为 null 则此内存区域不用于映射)。第一个被映射单元的位置存在区域描述符的 vm_pgoff 字段——以页为单位的文件偏移。被映射文件部分的长度就是内存区域的长度,由 vm_startvm_end 字段算出。

共享映射的页总是在页缓存里;私有映射的页只要未被修改也在页缓存里。进程试图修改私有映射的页时,内核复制页框、用副本替换进程页表中的原页框——写时复制机制的应用之一(第 8 章)。原页框留在页缓存里(虽然不再属于该映射),副本不进页缓存(它不再包含代表磁盘文件的有效数据)。

图 2 还画了几个引用被映射文件的、页缓存中页的页描述符。注意图中第一个内存区域有 3 页长,却只分配了 2 个页框——多半进程从没访问过第三页。

内核提供了若干钩子让每种文件系统定制内存映射机制。实现的核心委托给 file 对象的 mmap 方法——对多数磁盘文件系统和块设备文件,它由通用函数 generic_file_mmap() 实现(见下)。

文件内存映射依赖第 9 章的请求分页(demand paging)机制:新建的映射是一个不含任何页的内存区域;进程引用区域内地址时发生缺页,缺页处理程序检查内存区域的 nopage 方法是否有定义——未定义说明该区域不映射磁盘文件;有定义则说明映射了文件,由该方法负责访问块设备读入页。几乎所有磁盘文件系统和块设备文件都用 filemap_nopage() 函数实现 nopage 方法。

创建内存映射

进程发 mmap() 系统调用创建新映射,参数:标识被映射文件的文件描述符;文件内偏移(指定被映射部分首字符);被映射部分长度;一组标志(必须显式置 MAP_SHARED 或 MAP_PRIVATE);一组权限(PROT_READ 读、PROT_WRITE 写、PROT_EXEC 执行);可选的线性地址(内核把它当作新内存区域起始位置的提示;指定 MAP_FIXED 而内核无法从指定地址分配时,系统调用失败)。

mmap() 返回新内存区域第一个单元的线性地址。为兼容起见,80×86 上系统调用表为 mmap() 保留两个表项:下标 90 和 192——前者对应 old_mmap() 服务例程(老 C 库用),后者对应 sys_mmap2()(新 C 库用);两者只在六个参数的传递方式上不同,最终都调第 9 章的 do_mmap_pgoff()。补充”文件映射内存区域”特有的步骤(file 参数非空的情形):

  • Step 1:检查被映射文件的 mmap 文件操作是否有定义;没有则返回错误码——文件操作表里 mmap 为 NULL 表示该文件不能映射(比如目录)。
  • Step 2get_unmapped_area() 函数若文件对象定义了 get_unmapped_area 方法则调它分配适合文件映射的线性地址区间。磁盘文件系统不定义该方法——此时(第 9 章)get_unmapped_area() 最终调内存描述符的 get_unmapped_area 方法。
  • Step 3:除常规一致性检查外,比较请求的映射类型(mmap() 的 flags 参数)与文件打开时指定的标志(file->f_mode):要求共享可写映射时,检查文件以写方式打开、且未以追加模式(open() 的 O_APPEND 标志)打开;要求共享映射时,检查文件上没有强制锁(第 12 章”文件加锁”);无论哪种映射,都检查文件以读方式打开。任一条件不满足返回错误码。此外初始化新内存区域描述符的 vm_flags 字段时,按文件访问权限和映射类型设置 VM_READ、VM_WRITE、VM_EXEC、VM_SHARED、VM_MAYREAD、VM_MAYWRITE、VM_MAYEXEC、VM_MAYSHARE 标志(第 9 章)。一个优化:非可写共享映射清除 VM_SHARED 和 VM_MAYWRITE——进程不允许写该区域的页,所以映射按私有映射对待;但内核实际上仍允许共享该文件的其他进程读此区域的页。
  • Step 10:把内存区域描述符的 vm_file 字段初始化为 file 对象地址、递增文件使用计数。对被映射文件调 mmap 方法(参数为 file 对象地址和内存区域描述符地址)。多数文件系统由 generic_file_mmap() 实现:把当前时间存进文件 inode 的 i_atime 并把 inode 标记为脏;把内存区域描述符的 vm_ops 字段初始化为 generic_file_vm_ops 表的地址——表中所有方法都是空的,只有 nopage 方法(filemap_nopage() 实现)和 populate 方法(filemap_populate() 实现,见”非线性内存映射”)除外。
  • Step 11:递增文件 inode 的 i_writecount 字段——写进程的使用计数。

销毁内存映射

进程要销毁映射时调 munmap();该调用也能缩小任何内存区域。参数:待移除线性地址区间的首地址、区间长度。sys_munmap() 服务例程本质上调第 9 章的 do_munmap()。注意:销毁可写共享映射时无需把其中的页刷盘——这些页仍留在页缓存里继续充当磁盘缓存。

内存映射的请求分页

出于效率,页框不会在映射创建后立刻分配,而是拖到最后一刻——进程试图访问其中一页引发缺页异常时。

第 9 章讲过:内核验证出错地址是否在进程某内存区域内,是则检查相应页表项,表项为 null 则调 do_no_page()do_no_page() 执行所有请求分页共通的操作(分配页框、更新页表),并检查涉及内存区域的 nopage 方法是否有定义——未定义的情形(匿名内存区域)第 9 章讲过;有定义时:

  1. 调 nopage 方法,返回包含所求页的页框地址;
  2. 进程试图写页且映射是私有的:为避免将来的写时复制缺页,把刚读的页复制一份、把副本插进非活动页链表(第 17 章)。私有映射区域若还没有包含新页的从属匿名内存区域,就新增一个或扩展现有的(第 9 章)。后续步骤改用新页——原页不会被用户态进程修改;
  3. 若其他进程已截断或使该页失效(用 address_space 描述符的 truncate_count 字段检查),跳回第 1 步重新取页;
  4. 递增进程内存描述符的 rss 字段——新页框已分配给进程;
  5. 用页框地址和内存区域 vm_page_prot 字段中的页访问权设置出错地址对应的页表项;
  6. 进程试图写页时,强制把页表项的 Read/Write 和 Dirty 位置 1——此时页框要么独占分配给该进程、要么页是共享的,两种情况都该允许写。

请求分页算法的核心就是内存区域的 nopage 方法:它必须返回包含进程所访问页的页框地址,实现取决于页所在内存区域的种类。对映射磁盘文件的内存区域,nopage 方法必须先在页缓存中找所求页,找不到再从磁盘读。多数文件系统用 filemap_nopage() 实现,它接收三个参数:内存区域描述符地址(含所求页的地址)、所求页的线性地址、type 指针(函数往里写检测到的缺页类型:VM_FAULT_MAJOR或 VM_FAULT_MINOR)。执行:

  1. area->vm_file 取 file 对象地址 file;由 file->f_mapping 得 address_space 地址;由 address_space 的 host 字段得 inode 地址;
  2. 用 area 的 vm_startvm_pgoff 字段确定从 address 开始的页对应的文件内数据偏移;
  3. 文件偏移超过文件大小则返回 NULL(分配新页失败),除非缺页是由调试器经 ptrace() 跟踪另一进程造成的(特例不讨论);
  4. 内存区域的 VM_RAND_READ 标志置位(见下):进程随机读映射页,跳过预读、跳到第 10 步;
  5. 内存区域的 VM_SEQ_READ 标志置位:进程严格顺序读映射页,调 page_cache_readahead() 从出错页开始预读(“文件预读”一节);
  6. find_get_page() 按 address_space 对象和文件偏移在页缓存中找页;找到跳第 11 步;
  7. 页没找到。检查 VM_SEQ_READ 标志:置位说明内核已在激进预读、预读算法失败了——调 handle_ra_miss() 调校预读参数、跳第 10 步;标志清除则递增文件 file_ra_state 描述符的 mmap_miss 计数器;未命中数远大于命中数(mmap_hit)时跳过预读、跳第 10 步;
  8. 预读未永久停用(file_ra_state 的 ra_pages 大于零)则调 do_page_cache_readahead() 预读所求页周围的一组页;
  9. find_get_page() 再查所求页是否进了页缓存;在则跳第 11 步;
  10. page_cache_read():检查所求页是否已在页缓存,不在则分配新页框、加进页缓存、执行 mapping->a_ops->readpage 方法调度 I/O 从磁盘读页内容;
  11. grab_swap_token() 可能地把交换令牌发给当前进程(第 17 章”交换令牌”);
  12. 所求页现在在页缓存中。递增文件 file_ra_state 描述符的 mmap_hit 计数器;
  13. 页不是最新的(PG_uptodate 清除)则调 lock_page() 锁页、执行 mapping->a_ops->readpage 方法触发 I/O 传输、调 wait_on_page_bit() 睡到页解锁(即传输完成);
  14. mark_page_accessed() 把所求页标记为已访问(下一章);
  15. 页的最新版本在页缓存中找到则 *type 设 VM_FAULT_MINOR,否则 VM_FAULT_MAJOR;
  16. 返回所求页的地址。

用户态进程可用 madvise() 系统调校 filemap_nopage() 的预读行为:MADV_RANDOM 置 VM_RAND_READ 标志(页将被随机访问);MADV_SEQUENTIAL 置 VM_SEQ_READ 标志(页将被严格顺序访问);MADV_NORMAL 复位两者(访问次序未定)。

把脏的内存映射页刷到磁盘:msync()

msync() 系统调用让进程把共享内存映射的脏页刷到磁盘。参数:线性地址区间起始地址、区间长度、一组标志:

  • MS_SYNC:让系统调用挂起进程直到 I/O 操作完成——调用返回时映射的所有页都已刷盘;
  • MS_ASYNC(MS_SYNC 的补集):立即返回、不挂起调用进程;
  • MS_INVALIDATE:使同一文件的其他内存映射失效(Linux 中并未真正实现,因为没用)。

sys_msync() 服务例程对地址区间内的每个内存区域调 msync_interval(),后者执行:

  1. 内存区域描述符的 vm_file 为 NULL、或 VM_SHARED 标志清除,返回 0(该区域不是文件的可写共享映射);
  2. filemap_sync():扫描内存区域所含线性地址区间对应的页表项;对找到的每页,复位页表项的 Dirty 位、调 flush_tlb_page() 刷新相应 TLB;然后置页描述符的 PG_dirty 标志标记页为脏;
  3. MS_ASYNC 置位则返回——MS_ASYNC 的实际效果只是置页的 PG_dirty 标志,系统调用并不真正启动 I/O 传输
  4. 走到这里说明 MS_SYNC 置位:必须把区域中的页刷盘、并让当前进程睡到所有 I/O 传输结束。为此先获取文件 inode 的 i_sem 信号量;
  5. filemap_fdatawrite()(参数为文件 address_space 对象地址):建立一个 WB_SYNC_ALL 同步模式的 writeback_control 描述符,检查地址空间有没有内建的 writepages 方法——有则调它并返回,没有则执行 mpage_writepages()(“把脏页写回磁盘”一节);
  6. 检查 file 对象的 fsync 方法是否定义,有则执行。常规文件上它通常只是把文件的 inode 对象刷盘;块设备文件上它调 sync_blockdev(),激活设备所有脏缓冲的 I/O 传输;
  7. 执行 filemap_fdatawait()。回忆第 15 章:页缓存的基数树用 PAGECACHE_TAG_WRITEBACK 标签标识所有正在写盘的页。该函数快速扫描覆盖给定线性地址区间的基数树部分,找 PG_writeback 置位的页;对每页调 wait_on_page_bit() 睡到 PG_writeback 清除(即该页进行中的 I/O 传输终止);
  8. 释放文件 inode 的 i_sem,返回。

非线性内存映射

Linux 2.6 还为常规文件提供了另一种访问方式:非线性内存映射。本质上它也是文件内存映射,但内存页不是映射到文件上的顺序页——每个内存页映射文件数据的一个任意页

用户态应用当然可以反复调 mmap()、每次映射文件的不同 4096 字节段来达到同样效果;但对大文件的非线性映射这样做效率很低——每个映射页都要自己的内存区域。

为支持非线性映射,内核用了几个附加数据结构。首先,内存区域描述符的 VM_NONLINEAR 标志表示该区域含非线性映射;给定文件的所有非线性映射内存区域描述符收集在一条双向循环链表里,根在 address_space 对象的 i_mmap_nonlinear 字段。

创建非线性映射:应用先用 mmap() 建普通共享映射,然后调 remap_file_pages() 重映射映射区域中的一些页。sys_remap_file_pages() 服务例程参数:start(调用进程的共享文件映射区域内的一个线性地址)、size(重映射部分的大小,字节)、prot(未用,必须为 0)、pgoff(待重映射的起始文件页的页索引)、flags(控制非线性映射的标志)。

服务例程把 pgoff 和 size 标识的文件数据部分重映射到 start 线性地址。内存区域非共享、或不够大装不下请求的所有页时,系统调用失败返回错误码。本质上服务例程把内存区域插进文件的 i_mmap_nonlinear 链表,然后调内存区域的 populate 方法

对所有常规文件,populate 方法由 filemap_populate() 实现:

  1. remap_file_pages() 的 flags 参数中 MAP_NONBLOCK 标志清除的话,调 do_page_cache_readahead() 预读待重映射的文件页;
  2. 对每个待重映射的页:
    • 检查页描述符是否已在页缓存;不在且 MAP_NONBLOCK 清除则从磁盘读页;
    • 页描述符在页缓存中:更新相应线性地址的页表项使其指向页框,并更新内存区域描述符的页计数;
    • 否则(页描述符在页缓存中没找到):把文件页的偏移存进相应线性地址页表项的高 32 位,清除页表项的 Present 位、置 Dirty 位。

第 9 章”请求分页”讲过:处理请求分页缺页时 handle_pte_fault() 检查页表项的 Present 和 Dirty 位;两者组合对应非线性映射的值时,handle_pte_fault()do_file_page()——它从页表项高位提取所求文件页的索引,然后调内存区域的 populate 方法从磁盘读页、更新页表项。

因为非线性映射的内存页按”相对文件开头的页索引”(而非相对内存区域开头的索引)进页缓存,非线性映射的刷盘方式与线性映射完全相同(“把脏的内存映射页刷到磁盘”一节)。

直接 I/O

前面看到,Linux 2.6 中经由文件系统访问常规文件、引用底层块设备文件上的块、建立文件内存映射,三者没有实质区别。但有些高度复杂的程序(自缓存应用,self-caching applications)想完全掌控整个 I/O 传输机制。典型如高性能数据库服务器:多数实现了自己的缓存机制,利用数据库查询的独特性质。对这类程序,内核页缓存帮不上忙,反而有害:

  • 大量页框被浪费在复制磁盘数据上——数据已经(在用户态磁盘缓存里)在 RAM 中了;
  • read()/write() 系统调用被处理页缓存和预读的冗余指令拖慢;文件内存映射相关的分页操作同理;
  • read()/write() 不是在磁盘与用户内存间直接传输,而是搞两次传输:磁盘↔内核缓冲、内核缓冲↔用户内存。

但块硬件设备必须经中断和 DMA 处理,而这只能在内核态进行——所以自缓存应用仍然需要某种内核支持。

Linux 给出了绕过页缓存的简单办法:直接 I/O 传输。每次直接 I/O 传输中,内核编程磁盘控制器,把数据直接传输到自缓存应用用户态地址空间的页里(或从中传出)。

众所周知,每次数据传输都是异步进行的:传输进行时内核可能切换当前进程、CPU 可能返回用户态、发起传输的进程的页可能被换出……普通 I/O 传输无所谓——它们涉及的是磁盘缓存页,磁盘缓存归内核所有、不能被换出、对所有内核态进程可见。

但直接 I/O 传输要在属于某进程用户态地址空间的页里搬数据。内核必须保证:这些页对所有内核态进程可访问、且传输进行期间不会被换出。做法如下:

自缓存应用想直接访问文件时,以 O_DIRECT 标志打开文件(第 12 章)。处理 open() 时,dentry_open() 检查被打开文件 address_space 对象的 direct_IO 方法是否实现,未实现则返回错误码。O_DIRECT 也可以对已打开文件用 fcntl() 的 F_SETFL 命令设置。

先看自缓存应用对 O_DIRECT 文件发 read() 的情形。如前所述 read 文件方法通常由 generic_file_read() 实现,它初始化 iovec 和 kiocb、调 __generic_file_aio_read();后者验证用户态缓冲有效后检查文件的 O_DIRECT 标志,置位则执行与下面等价的代码:

if (filp->f_flags & O_DIRECT) {
    if (count == 0 || *ppos > filp->f_mapping->host->i_size)
        return 0;
    retval = generic_file_direct_IO(READ, iocb, iov, *ppos, 1);
    if (retval > 0)
        *ppos += retval;
    file_accessed(filp);
    return retval;
}

对 O_DIRECT 文件发 write() 的情形类似:write 方法最终调 generic_file_aio_write_nolock(),它检查 O_DIRECT 置位则调 generic_file_direct_IO()——这次操作类型为 WRITE。

generic_file_direct_IO() 参数:

rw      操作类型:READ 或 WRITE
iocb    kiocb 描述符指针
iov     iovec 描述符数组指针
offset  文件偏移
nr_segs iov 数组中 iovec 描述符的个数

执行步骤:

  1. 从 kiocb 描述符的 ki_filp 字段取 file 对象地址,由 file->f_mapping 取 address_space 对象地址 mapping;
  2. 操作类型为 WRITE 且有一个或多个进程建立了与文件某部分关联的内存映射时,调 unmap_mapping_range() 解除文件所有页的映射。该函数还确保:待解映射页对应的页表项若置了 Dirty 位,页缓存中的相应页就被标记为脏;
  3. mapping 为根的基数树非空(mapping->nrpages 大于零)则调 filemap_fdatawrite()filemap_fdatawait() 把所有脏页刷盘并等 I/O 完成。(即便自缓存应用直接访问文件,系统里还可能有别的应用经页缓存访问该文件。为避免丢数据,启动直接 I/O 传输前先把磁盘映像与页缓存同步。)
  4. 调 mapping 地址空间的 direct_IO 方法(见下);
  5. 操作类型为 WRITE 则调 invalidate_inode_pages2() 扫描 mapping 基数树中的所有页并释放它们;函数还会清引用这些页的用户态页表项。

多数情况下 direct_IO 方法是 __blockdev_direct_IO() 的包装器。这个函数相当复杂、调用大量辅助结构和函数,但本质上执行的还是本章讲过的那类操作:把待读写数据切成合适的块、定位数据在磁盘上的位置、填充一个或多个描述待执行 I/O 操作的 bio 描述符——当然,数据会直接读写进 iov 数组中 iovec 描述符指定的用户态缓冲。bio 描述符经 submit_bio() 提交给通用块层(第 15 章)。通常 __blockdev_direct_IO() 直到所有直接 I/O 传输完成才返回——所以 read()/write() 一返回,自缓存应用就能安全访问包含文件数据的缓冲。

异步 I/O

POSIX 1003.1 标准定义了一组异步访问文件的库函数:

函数说明
aio_read()异步地从文件读一些数据
aio_write()异步地向文件写一些数据
aio_fsync()请求对所有未决异步 I/O 操作执行刷新(不阻塞)
aio_error()获取某未决异步 I/O 操作的错误码
aio_return()获取已完成的异步 I/O 操作的返回码
aio_cancel()取消某未决异步 I/O 操作
aio_suspend()挂起进程直到若干未决 I/O 操作中至少一个完成

“异步”意味着:用户态进程调库函数读或写文件时,函数在读写操作入队后立即终止——甚至可能发生在实际 I/O 传输开始之前;调用进程可以继续执行,数据在后台传输。

异步 I/O 用起来很简单:应用照常用 open() 打开文件;然后往 struct aiocb 类型的控制块里填描述所求操作的信息,常用字段:aio_fildes(文件描述符)、aio_buf(文件数据的用户态缓冲)、aio_nbytes(要传输的字节数)、aio_offset(读写起始位置——它独立于”同步的”文件指针)。最后把控制块地址传给 aio_read()aio_write(),两者在系统库或内核把 I/O 传输入队后立即终止。应用稍后可查状态:aio_error() 返回 EINPROGRESS(传输仍在进行)、0(成功完成)或错误码;aio_return() 返回已完成操作实际读写的字节数,失败返回 -1。

Linux 2.6 的异步 I/O

异步 I/O 可以完全由系统库实现、不需任何内核支持——aio_read()/aio_write() 克隆当前进程、让子进程调同步的 read()/write();父进程不等子进程的同步操作结束就继续执行。这种”穷人版” POSIX 函数比内核级实现慢得多

Linux 2.6 内核支持一组异步 I/O 系统调用。不过在 2.6.11 版本中该特性仍在完善,只有以 O_DIRECT 标志打开的文件异步 I/O 才能正确工作

系统调用说明
io_setup()为当前进程初始化一个异步上下文
io_submit()提交一个或多个异步 I/O 操作
io_getevents()获取一些未决异步 I/O 操作的完成状态
io_cancel()取消一个未决 I/O 操作
io_destroy()移除当前进程的一个异步上下文

异步 I/O 上下文

用户态进程想用 io_submit() 启动异步 I/O,必须先创建异步 I/O 上下文(AIO context)——一组跟踪进程所请求异步 I/O 操作进行情况的数据结构。每个上下文关联一个 kioctx 对象,存放上下文的全部相关信息。应用可以创建多个上下文;给定进程的所有 kioctx 描述符收集在一条单向链表里,根在内存描述符的 ioctx_list 字段(第 9 章表 9-2)。

kioctx 对象里值得点出的重要数据结构是 AIO 环(AIO ring):一块位于用户态进程地址空间、同时可被所有内核态进程访问的内存缓冲。其用户态起始地址和长度存在 kioctx 对象的 ring_info.mmap_basering_info.mmap_size 字段;组成 AIO 环的所有页框的描述符存在 ring_info.ring_pages 字段指向的数组里。

AIO 环本质是一个循环缓冲:内核往里写未决异步 I/O 操作的完成报告。开头几个字节是头部(struct aio_ring 结构),其余字节存放 io_event 结构——每个描述一个已完成的异步 I/O 操作。因为 AIO 环的页映射在进程的用户态地址空间,应用可以直接检查未决操作的进展,省掉相对缓慢的系统调用

io_setup() 为调用进程创建新 AIO 上下文,参数两个:未决异步 I/O 操作的最大个数(最终决定 AIO 环的大小)、指向将存放上下文句柄的变量的指针(句柄也是 AIO 环的基址)。sys_io_setup() 服务例程本质上调 do_mmap() 为进程分配一个将包含 AIO 环的新匿名内存区域(第 9 章),并创建、初始化描述该上下文的 kioctx 对象。反过来,io_destroy() 移除 AIO 上下文、销毁包含相应 AIO 环的匿名内存区域;系统调用会阻塞当前进程直到所有未决异步 I/O 操作完成。

提交异步 I/O 操作

应用调 io_submit() 启动异步 I/O 操作。三个参数:ctx_id(io_setup() 返回的句柄,标识 AIO 上下文);iocbpp(指向 iocb 类型描述符指针数组的地址,每个描述符描述一个异步 I/O 操作);nr(iocbpp 指向的数组长度)。

iocb 结构包含与 POSIX aiocb 描述符相同的字段(aio_fildes、aio_buf、aio_nbytes、aio_offset),外加 aio_lio_opcode 字段——存放所求操作的类型(典型为 read、write 或 sync)。

sys_io_submit() 服务例程执行:

  1. 验证 iocb 描述符数组有效;
  2. 在内存描述符 ioctx_list 字段为根的链表中搜索 ctx_id 句柄对应的 kioctx 对象;
  3. 对数组中每个 iocb 描述符:
    • aio_fildes 字段中文件描述符对应的 file 对象地址;
    • 为该 I/O 操作分配并初始化新的 kiocb 描述符;
    • 检查 AIO 环中有空闲槽位可存操作的完成结果;
    • 按操作类型设置 kiocb 描述符的 ki_retry 方法(见下);
    • 执行 aio_run_iocb() 函数——它本质上调 ki_retry 方法启动相应异步 I/O 操作的数据传输。ki_retry 返回 -EIOCBRETRY 说明操作已提交但尚未完全满足:稍后会再次对该 kiocb 调 aio_run_iocb()(见下);否则调 aio_complete() 往 AIO 上下文的环里加一个该操作的完成事件。

异步 I/O 操作是读请求时,kiocb 的 ki_retry 方法由 aio_pread() 实现:执行 file 对象的 aio_read 方法,然后按其返回值更新 kiocb 描述符的 ki_bufki_left 字段;最后返回实际读到的字节数,或 -EIOCBRETRY(若判定并非所有请求字节都已传输)。多数文件系统的 aio_read 方法最终调 __generic_file_aio_read();假定文件 O_DIRECT 置位,该函数最终调 generic_file_direct_IO()(上节)。不过此时 __blockdev_direct_IO() 不阻塞当前进程等 I/O 完成,而是立即返回。因为异步操作仍未完成,aio_run_iocb() 会被再次调用——这次由 aio_wq 工作队列的 aio 内核线程调用。kiocb 描述符跟踪 I/O 传输的进展;最终所有请求数据传完,完成结果被加进 AIO 环。

写请求同理:ki_retry 方法由 aio_pwrite() 实现——执行 file 对象的 aio_write 方法、更新 ki_buf/ki_left、返回实际写入字节数或 -EIOCBRETRY。多数文件系统的 aio_write 方法最终调 generic_file_aio_write_nolock();O_DIRECT 置位时最终调 generic_file_direct_IO()

通关标准

能沿着一次 read() 讲完整链路:generic_file_read → __generic_file_aio_read → do_generic_file_read(查页缓存→readpage→拷贝到用户态)→ mpage_readpage/block_read_full_page → submit_bh/bio → 通用块层;能说清五种访问方式的适用场景(顺序流用常规+预读、数据库用 O_DIRECT、随机改大文件用 mmap);能解释当前窗口/预读窗口如何滚动、AIO 环为什么能省系统调用。