直接 I/O 的动机
在介绍直接 I/O 之前,这一小节先介绍一下为什么会出现直接 I/O 这种机制,即传统的 I/O 操作存在哪些缺点。
什么是缓存 I/O (Buffered I/O)
缓存 I/O 又被称作标准 I/O,大多数文件系统的默认 I/O 操作都是缓存 I/O。在 Linux 的缓存 I/O 机制中,操作系统会将 I/O 的数据缓存在文件系统的页缓存( page cache )中,也就是说,数据会先被拷贝到操作系统内核的缓冲区中,然后才会从操作系统内核的缓冲区拷贝到应用程序的地址空间。缓存 I/O 有以下这些优点:
- 缓存 I/O 使用了操作系统内核缓冲区,在一定程度上分离了应用程序空间和实际的物理设备。
- 缓存 I/O 可以减少读盘的次数,从而提高性能。
当应用程序尝试读取某块数据的时候,如果这块数据已经存放在了页缓存中,那么这块数据就可以立即返回给应用程序,而不需要经过实际的物理读盘操作。当然,如果数据在应用程序读取之前并未被存放在页缓存中,那么就需要先将数据从磁盘读到页缓存中去。对于写操作来说,应用程序也会将数据先写到页缓存中去,数据是否被立即写到磁盘上去取决于应用程序所采用的写操作机制:如果用户采用的是同步写机制( synchronous writes ), 那么数据会立即被写回到磁盘上,应用程序会一直等到数据被写完为止;如果用户采用的是延迟写机制( deferred writes ),那么应用程序就完全不需要等到数据全部被写回到磁盘,数据只要被写到页缓存中去就可以了。在延迟写机制的情况下,操作系统会定期地将放在页缓存中的数据刷到磁盘上。与异步写机制( asynchronous writes )不同的是,延迟写机制在数据完全写到磁盘上的时候不会通知应用程序,而异步写机制在数据完全写到磁盘上的时候是会返回给应用程序的。所以延迟写机制本身是存在数据丢失的风险的,而异步写机制则不会有这方面的担心。
缓存 I/O 的缺点
在缓存 I/O 机制中,DMA 方式可以将数据直接从磁盘读到页缓存中,或者将数据从页缓存直接写回到磁盘上,而不能直接在应用程序地址空间和磁盘之间进行数据传输,这样的话,数据在传输过程中需要在应用程序地址空间和页缓存之间进行多次数据拷贝操作,这些数据拷贝操作所带来的 CPU 以及内存开销是非常大的。
对于某些特殊的应用程序来说,避开操作系统内核缓冲区而直接在应用程序地址空间和磁盘之间传输数据会比使用操作系统内核缓冲区获取更好的性能,下边这一小节中提到的自缓存应用程序就是其中的一种。
自缓存应用程序( self-caching applications)
对于某些应用程序来说,它会有它自己的数据缓存机制,比如,它会将数据缓存在应用程序地址空间,这类应用程序完全不需要使用操作系统内核中的高速缓冲存储器,这类应用程序就被称作是自缓存应用程序( self-caching applications )。数据库管理系统是这类应用程序的一个代表。自缓存应用程序倾向于使用数据的逻辑表达方式,而非物理表达方式;当系统内存较低的时候,自缓存应用程序会让这种数据的逻辑缓存被换出,而并非是磁盘上实际的数据被换出。自缓存应用程序对要操作的数据的语义了如指掌,所以它可以采用更加高效的缓存替换算法。自缓存应用程序有可能会在多台主机之间共享一块内存,那么自缓存应用程序就需要提供一种能够有效地将用户地址空间的缓存数据置为无效的机制,从而确保应用程序地址空间缓存数据的一致性。
对于自缓存应用程序来说,缓存 I/O 明显不是一个好的选择。由此引出我们这篇文章着重要介绍的 Linux 中的直接 I/O 技术。Linux 中的直接 I/O 技术非常适用于自缓存这类应用程序,该技术省略掉缓存 I/O 技术中操作系统内核缓冲区的使用,数据直接在应用程序地址空间和磁盘之间进行传输,从而使得自缓存应用程序可以省略掉复杂的系统级别的缓存结构,而执行程序自己定义的数据读写管理,从而降低系统级别的管理对应用程序访问数据的影响。在下面一节中,我们会着重介绍 Linux 中提供的直接 I/O 机制的设计与实现,该机制为自缓存应用程序提供了很好的支持。
Linux 2.6 中的直接 I/O 技术
Linux 2.6 中提供的几种文件访问方式
所有的 I/O 操作都是通过读文件或者写文件来完成的。在这里,我们把所有的外围设备,包括键盘和显示器,都看成是文件系统中的文件。访问文件的方法多种多样,这里列出下边这几种 Linux 2.6 中支持的文件访问方式。
标准访问文件的方式
在 Linux 中,这种访问文件的方式是通过两个系统调用实现的:read() 和 write()。当应用程序调用 read() 系统调用读取一块数据的时候,如果该块数据已经在内存中了,那么就直接从内存中读出该数据并返回给应用程序;如果该块数据不在内存中,那么数据会被从磁盘上读到页高缓存中去,然后再从页缓存中拷贝到用户地址空间中去。如果一个进程读取某个文件,那么其他进程就都不可以读取或者更改该文件;对于写数据操作来说,当一个进程调用了 write() 系统调用往某个文件中写数据的时候,数据会先从用户地址空间拷贝到操作系统内核地址空间的页缓存中去,然后才被写到磁盘上。但是对于这种标准的访问文件的方式来说,在数据被写到页缓存中的时候,write() 系统调用就算执行完成,并不会等数据完全写入到磁盘上。Linux 在这里采用的是我们前边提到的延迟写机制( deferred writes )。
同步访问文件的方式
同步访问文件的方式与上边这种标准的访问文件的方式比较类似,这两种方法一个很关键的区别就是:同步访问文件的时候,写数据的操作是在数据完全被写回磁盘上才算完成的;而标准访问文件方式的写数据操作是在数据被写到页高速缓冲存储器中的时候就算执行完成了。
内存映射方式
在很多操作系统包括 Linux 中,内存区域( memory region )是可以跟一个普通的文件或者块设备文件的某一个部分关联起来的,若进程要访问内存页中某个字节的数据,操作系统就会将访问该内存区域的操作转换为相应的访问文件的某个字节的操作。Linux 中提供了系统调用 mmap() 来实现这种文件访问方式。与标准的访问文件的方式相比,内存映射方式可以减少标准访问文件方式中 read() 系统调用所带来的数据拷贝操作,即减少数据在用户地址空间和操作系统内核地址空间之间的拷贝操作。映射通常适用于较大范围,对于相同长度的数据来讲,映射所带来的开销远远低于 CPU 拷贝所带来的开销。当大量数据需要传输的时候,采用内存映射方式去访问文件会获得比较好的效率。
直接 I/O 方式
凡是通过直接 I/O 方式进行数据传输,数据均直接在用户地址空间的缓冲区和磁盘之间直接进行传输,完全不需要页缓存的支持。操作系统层提供的缓存往往会使应用程序在读写数据的时候获得更好的性能,但是对于某些特殊的应用程序,比如说数据库管理系统这类应用,他们更倾向于选择他们自己的缓存机制,因为数据库管理系统往往比操作系统更了解数据库中存放的数据,数据库管理系统可以提供一种更加有效的缓存机制来提高数据库中数据的存取性能。
数据传输不经过操作系统内核缓冲区
异步访问文件的方式
Linux 异步 I/O 是 Linux 2.6 中的一个标准特性,其本质思想就是进程发出数据传输请求之后,进程不会被阻塞,也不用等待任何操作完成,进程可以在数据传输的时候继续执行其他的操作。相对于同步访问文件的方式来说,异步访问文件的方式可以提高应用程序的效率,并且提高系统资源利用率。直接 I/O 经常会和异步访问文件的方式结合在一起使用。
CPU 处理其他任务和 I/O 操作可以重叠执行
Linux 2.6 中直接 I/O 的设计与实现
在块设备或者网络设备中执行直接 I/O 完全不用担心实现直接 I/O 的问题,Linux 2.6 操作系统内核中高层代码已经设置和使用了直接 I/O,驱动程序级别的代码甚至不需要知道已经执行了直接 I/O;但是对于字符设备来说,执行直接 I/O 是不可行的,Linux 2.6 提供了函数 get_user_pages() 用于实现直接 I/O。本小节会分别对这两种情况进行介绍。
内核为块设备执行直接 I/O 提供的支持
要在块设备中执行直接 I/O,进程必须在打开文件的时候设置对文件的访问模式为 O_DIRECT,这样就等于告诉操作系统进程在接下来使用 read() 或者 write() 系统调用去读写文件的时候使用的是直接 I/O 方式,所传输的数据均不经过操作系统内核缓存空间。使用直接 I/O 读写数据必须要注意缓冲区对齐( buffer alignment )以及缓冲区的大小的问题,即对应 read() 以及 write() 系统调用的第二个和第三个参数。这里边说的对齐指的是文件系统块大小的对齐,缓冲区的大小也必须是该块大小的整数倍。
这一节主要介绍三个函数:open(),read() 以及 write()。Linux 中访问文件具有多样性,所以这三个函数对于处理不同的文件访问方式定义了不同的处理方法,本文主要介绍其与直接 I/O 方式相关的函数与功能.首先,先来看 open() 系统调用,其函数原型如下所示:
int open(const char *pathname, int oflag, … /*, mode_t mode * / ) ;
当应用程序需要直接访问文件而不经过操作系统页高速缓冲存储器的时候,它打开文件的时候需要指定 O_DIRECT 标识符。
操作系统内核中处理 open() 系统调用的内核函数是 sys_open(),sys_open() 会调用 do_sys_open() 去处理主要的打开操作。它主要做了三件事情:首先, 它调用 getname() 从进程地址空间中读取文件的路径名;接着,do_sys_open() 调用 get_unused_fd() 从进程的文件表中找到一个空闲的文件表指针,相应的新文件描述符就存放在本地变量 fd 中;之后,函数 do_filp_open() 会根据传入的参数去执行相应的打开操作。清单 1 列出了操作系统内核中处理 open() 系统调用的一个主要函数关系图。
sys_open()
|-----do_sys_open()
|---------getname()
|---------get_unused_fd()
|---------do_filp_open()
|--------nameidata_to_filp()
|----------__dentry_open()
函数 do_flip_open() 在执行的过程中会调用函数 nameidata_to_filp(),而 nameidata_to_filp() 最终会调用 __dentry_open() 函数,若进程指定了 O_DIRECT 标识符,则该函数会检查直接 I./O 操作是否可以作用于该文件。清单 2 列出了 __dentry_open() 函数中与直接 I/O 操作相关的代码。
if (f->f_flags & O_DIRECT) {
if (!f->f_mapping->a_ops ||
((!f->f_mapping->a_ops->direct_IO) &&
(!f->f_mapping->a_ops->get_xip_page))) {
fput(f);
f = ERR_PTR(-EINVAL);
}
}
当文件打开时指定了 O_DIRECT 标识符,那么操作系统就会知道接下来对文件的读或者写操作都是要使用直接 I/O 方式的。
下边我们来看一下当进程通过 read() 系统调用读取一个已经设置了 O_DIRECT 标识符的文件的时候,系统都做了哪些处理。 函数 read() 的原型如下所示:
ssize_t read(int feledes, void *buff, size_t nbytes) ;
操作系统中处理 read() 函数的入口函数是 sys_read(),其主要的调用函数关系图如下清单 3 所示:
sys_read()
|-----vfs_read()
|----generic_file_read()
|----generic_file_aio_read()
|--------- generic_file_direct_IO()
直接 I/O 技术的特点
直接 I/O 的优点
直接 I/O 最主要的优点就是通过减少操作系统内核缓冲区和应用程序地址空间的数据拷贝次数,降低了对文件读取和写入时所带来的 CPU 的使用以及内存带宽的占用。这对于某些特殊的应用程序,比如自缓存应用程序来说,不失为一种好的选择。如果要传输的数据量很大,使用直接 I/O 的方式进行数据传输,而不需要操作系统内核地址空间拷贝数据操作的参与,这将会大大提高性能。
直接 I/O 潜在可能存在的问题
直接 I/O 并不一定总能提供令人满意的性能上的飞跃。设置直接 I/O 的开销非常大,而直接 I/O 又不能提供缓存 I/O 的优势。缓存 I/O 的读操作可以从高速缓冲存储器中获取数据,而直接 I/O 的读数据操作会造成磁盘的同步读,这会带来性能上的差异 , 并且导致进程需要较长的时间才能执行完;对于写数据操作来说,使用直接 I/O 需要 write() 系统调用同步执行,否则应用程序将会不知道什么时候才能够再次使用它的 I/O 缓冲区。与直接 I/O 读操作类似的是,直接 I/O 写操作也会导致应用程序关闭缓慢。所以,应用程序使用直接 I/O 进行数据传输的时候通常会和使用异步 I/O 结合使用。
总结
Linux 中的直接 I/O 访问文件方式可以减少 CPU 的使用率以及内存带宽的占用,但是直接 I/O 有时候也会对性能产生负面影响。所以在使用直接 I/O 之前一定要对应用程序有一个很清醒的认识,只有在确定了设置缓冲 I/O 的开销非常巨大的情况下,才考虑使用直接 I/O。直接 I/O 经常需要跟异步 I/O 结合起来使用,本文对异步 I/O 没有作详细介绍,有兴趣的读者可以参看 Linux 2.6 中相关的文档介绍
原文
https://www.ibm.com/developerworks/cn/linux/l-cn-directio/index.html