“内容主体选自 Linux系统编程手册,摘录了文件IO相关章节,包含第五章、第十三章、第四十九章。”
文件I/O: 通用的I/O模型
所有执行IO操作的系统调用都以文件描述符,一个非负整数来指代打开的文件。文件描述符用以表示所有类型的已打开文件,包括管道、FIFO、 socket、中断、设备和普通文件。
open(),当使用open()创建文件时,位掩码参数mode(flags)指定了文件的访问权限,如果open()并未指定O_CREAT标志,则可以省略mode参数,除了文件访问标志外,还有文件创建标志(O_DIRECT, O_NOTIME....)、文件状态标志(O_APPEND, OASYNC, O_SYNC)。当Linux陷入中断后,根据系统调用号找到内核函数入口(fs/open.c),获取未分配fd,根据pathname找到文件inode,填充struct file,install fd to file(inode是Linux内文件元信息,保存在磁盘上单独空间,每个磁盘在初始化时就确定了inode的数量,所以fsync的调用将会涉及到两次写磁盘,即一次写数据,一次写元数据)
read() 从文件描述符fd所指代的打开文件中读取数据。
write() 将数据写入一个已经打开的文件,并不能保证数据已经写入磁盘。
close() 关闭一个打开的文件描述符。
lseek() 对于每一个打来的文件,系统内核会记录其文件偏移量,即执行下一个read()或write()的文件起始位置,lseek依据offset和whence参数调整该文件的偏移量。whence代替偏移量基准位置(文件头部、当前位置、文件尾部),它不会引起对任何物理位置的访问。
深入探究文件I/O
原子操作和竞争条件
系统调用本身都是原子性的,但有一些场景(进程调度)导致出现竞争状态,例如多个进程同时追加一个文件,这将使用到lseek和write,如果某个进程执行到lseek和write中间后耗尽了它的时间片,第二个进程将偏移量设为相同位置并写入数据,当时间片回到第一个进程后,该段数据将被覆盖。
“很多场景需要考虑CPU轮转的影响,因为在设置了中断亲和的情况下CPU0处理的中断都仍然比其余CPU多,如果应用在CPU0上跑就很难不受到中断的影响了。下图是网卡设备的中断处理频数,它的中断亲和为00000000,00000000,00000000,00000080,而可能有的没有适应SMP,所有的中断都给了CPU0,所以很多厂家都会修改代码来设置CPU亲和。”
pread和pwrite则可以指定偏移量进行读写而不影响原有的偏移量,其等同于将lseek和read/write纳入同一原子操作。
非阻塞IO
open未能立即打开文件,将会陷入阻塞,O_NONBLOCK可以让这种情况返回错误,由于内核缓冲区保证了普通文件不会陷入阻塞,所以该标志一般会忽略。
缓冲区高速缓存
出于速度和效率考虑,系统I/O调用在操作磁盘文件将会对数据进行缓冲,read()和write()在操作磁盘文件时不会直接发起磁盘访问,而是仅仅在用户缓冲区和内核缓冲区之间复制数据。采用这一设计,意在使read和write的操作更为快速,因为它们不需要等待缓慢的磁盘操作,同时也减少了内核必须执行的磁盘传输次数。
Linux内核对缓冲区高速缓存的大小没有固定上限,内核会分配尽可能多的内存页:可用的物理内存。
从2.4开始,Linux不再维护一个单独的缓冲区高速缓存,而是将文件IO缓冲区置于页面高速缓存中,其中还含有诸如内存映射文件的页面。
fsync()系统调用将使缓冲数据和打开fd相关的所有元数据都刷新到磁盘上。调用fsync()会强制使文件处于Synchronized I/O file integrity completion状态。
fdatasync()系统调用的运作类似于 fsync(),只是强制文件处于 synchronized I/O data integrity completion 的状态。
sync()系统调用会使包含更新文件信息的所有内核缓冲区(即数据块、指针块、元数据等) 刷新到磁盘上。
绕过缓冲区:直接I/O
Linux允许应用程序在执行磁盘IO时绕过缓冲区高速缓存,从用户空间直接将数据传递到文件或磁盘设备。对于大多数应用,使用直接IO将会大大降低性能,因为PageCache做了不少优化,其中包括按顺序预读、在成簇磁盘块上执行IO、允许多个进程共享缓冲。但是直接IO适用于数据库系统,数据库的缓存和IO优化自成一体,无需重复。
内核中的IO调度
我们通过系统调用将数据写入页缓存后,内核定时会flush脏页到磁盘,它将提交一个bio request到块设备自身的reques_queue,由该块设备执行的IO Scheduler调度,IO Scheduler有三种算法:noop(先进先出,没有调度),cfq(默认,绝对公平算法),deadline(保证会被处理),对我们来说无脑改成noop就可以了,SSD不需要内核任何画蛇添足的调度。
内存映射
文件映射将一个文件的一部分直接映射到调用进程的虚拟内存中。一旦一 个文件被映射之后就可以通过在相应的内存区域中操作字节来访问文件内容了。映射 的分页会在需要的时候从文件中(自动)加载。这种映射也被称为基于文件的映射或内存映射文件
内存映射 I/O 之所以能够带来性能优势的原因如下。
正常的 read()或 write()需要两次传输:一次是在文件和内核高速缓冲区之间,另一次是在高速缓冲区和用户空间缓冲区之间。使用 mmap()就无需第二次传输了。对于输入来讲,一旦内核将相应的文件块映射进内存之后用户进程就能够使用这些数了。 对于输出来讲,用户进程仅仅需要修改内存中的内容,然后可以依靠内核内存管理器 来自动更新底层的文件。
除了节省了内核空间和用户空间之间的一次传输之外,mmap()还能够通过减少所需使 用的内存来提升性能。当使用 read()或 write()时,数据将被保存在两个缓冲区中:一 个位于用户空间,另一个位于内核空间。当使用 mmap()时,内核空间和用户空间会 共享同一个缓冲区。此外,如果多个进程正在在同一个文件上执行 I/O,那么它们通 过使用 mmap()就能够共享同一个内核缓冲区,从而又能够节省内存的消耗。
内存映射 I/O 所带来的性能优势在在大型文件中执行重复随机访问时最有可能体现出来。如果顺序地访问一个文件,并假设执行 I/O 时使用的缓冲区大小足够大以至于能够避免执行大 量的 I/O 系统调用,那么与 read()和 write()相比,mmap()带来的性能上的提升就非常有限或者 说根本就没有带来性能上的提升。性能提升的幅度之所以非常有限的原因是不管使用何种技 术,整个文件的内容在磁盘和内存之间只传输一次,效率的提高主要得益于减少了用户空间 和内核空间之间的一次数据传输,并且与磁盘 I/O 所需的时间相比,内存使用量的降低通常是可以忽略的。
内存映射 I/O 也有一些缺点。对于小数据量 I/O 来讲,内存映射 I/O 的开销(即映射、 分页故障、解除映射以及更新硬件内存管理单元的超前转换缓冲器)实际上要比简单的read()或 write()大。此外,有些时候内核难以高效地处理可写入映射的回写(在这种情况下, 使用 msync()或 sync_file_range()有助于提高效率)。
“现在我们几乎掌握了所有文件IO和其优化途径,即根据不同情况使用page cache、mmap、应用层缓冲结合Direct I/O,以及各种系统调用参数的配置”
常见应用场景分析
Kafka
Kafka的文件存储要从LogManager开始,其中每个Log代表不同的Topic的各个Partition,而每个Log又由许多固定长度的LogSegment组成,每个LogSegment封装了一个FileRecords来保存当前这个段持有的文件以及对这个文件的所有操作。
Kafka在open这个文件的时候并没有使用mmap,而是openat0的一个本地方法,该方法很明显对应Linux open()系统调用。向这个文件追加日志的时候使用的是pwrite,并没有维护应用的缓冲,而是直接写page cache,形同于直接写内存,然后由后台线程定时flush到磁盘,所以我们可以加大这个flush的间隔来提高kafka的性能。消费者fetch数据的时候则是获取需要消费的偏移量,然后直接sendfile到对应的socket中,直接从文件的page cache拷贝到scoket的内核缓冲中,也就是零拷贝。
当日志写成功后,需要更新offsetindex和timeindex,Kafka使用mmap将索引文件映射到page cache中,所有的读写操作都是由page cache完成的,内核的页置换算法是LRU,这对Kafka这种频繁在文件尾部操作(消费和生产)的场景非常有效。
但是这种页面置换算法对于索引的查找采用标准的二进制就非常不友好了,比如总数1到12页,目标11页上一条数据,我们使用标准二分法,第一次找到6页,但是6页很可能因为最近最少使用已经被淘汰了,就只能落盘在取出来,耗时就会非常的大,不过代码中也做了优化。
这就是kafka对这几种IO的综合使用。
BoltDB
BoltDB是一个go实现的kv数据库,相比Kafka简单很多,它采用了一个文件,这个文件划分为很多page,它也是mmap将这个文件映射到内存,采用内核默认的页面置换LRU做管理,一般应该应用层实现页面置换,get数据的时候也是sendfile到socket的内核缓冲实现零拷贝。
当然有其他一些细节的地方,但不在本次论述范围内,我们可以看到这些实现有很多相同的思路,当然对于MySQL、MongoDB这种数据库应该是使用的application buffer + Direct I/O,本文的这种mmap + page cache 实现简单也能打到很好的性能,但是要更高的要求的话就只能直接IO加上针对专门设计的数据库的内存池以及页置换算法了。
尾言
这算是Kafka的存储篇,下一篇有时间的话应该是Kafka的高可用设计,没时间就算了,