本文原创作者:源理君头条号:底层软件架构微信公众号:技术原理君在介绍直接 I/O 之前,先来介绍下直接I/O这种机制产生的原因。毕竟已经有了快取I/O(Buffered 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 可以减少读盘的次数,从而提高效能。对于读操作:当应用程序要去读取某块资料的时候,如果这块资料已经在页快取中,那就返回之。而不需要经过硬盘的读取操作了。如果这块资料不在页快取中,就需要从硬盘中读取资料到页快取。
对于写操作:应用程序会将资料先写到页快取中,资料是否会被立即写到磁盘,这取决于所采用的写操作机制:
同步机制,资料会立即被写到磁盘中,直到资料写完,写接口才返回;延迟机制:写界面立即返回,操作系统会定期地将页快取中的资料刷到硬盘。所以这个机制会存在丢失资料的风险。想象下写界面返回的时候,页快取的资料还没刷到硬盘,正好断电。对于应用程序来说,认为资料已经在硬盘中。
快取I/O的写操作
快取 I/O 的缺点
在快取I/O的机制中,以写操作为例,资料先从使用者态拷贝到核心态中的页快取中,然后又会从页快取中写到磁盘中,这些拷贝操作带来的CPU以及内存的开销是非常大的。对于某些特殊的应用程序来说,能够绕开核心缓冲区能够获取更好的效能,这就是直接I/O出现的意义。
直接I/O写操作
直接I/O 介绍
凡是通过直接I/O方式进行资料传输,资料直接从使用者态地址空间写入到磁盘中,直接跳过核心缓冲区。对于一些应用程序,例如:数据库。他们更倾向于自己的快取机制,这样可以提供更好的缓冲机制提高数据库的读写效能。直接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() 会根据传入的引数去执行相应的开启操作。下面列出了操作系统核心中处理 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 操作是否可以作用于该档案。下面列出了 __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(),其主要的呼叫函式关系图如下:
sys_read()
|-----vfs_read()
|----generic_file_read()
|----generic_file_aio_read()
|--------- generic_file_direct_IO()
函式 sys_read() 从程序中获取档案描述符以及档案当前的操作位置后会呼叫 vfs_read() 函式去执行具体的操作过程,而 vfs_read() 函式最终是呼叫了 file 结构中的相关操作去完成档案的读操作,即呼叫了 generic_file_read() 函式,其程式码如下所示:
ssize_t
generic_file_read(struct file *filp,
char __user *buf, size_t count, loff_t *ppos)
{
struct iovec local_iov = { .iov_base = buf, .iov_len = count };
struct kiocb kiocb;
ssize_t ret;
init_sync_kiocb(&kiocb, filp);
ret = __generic_file_aio_read(&kiocb, &local_iov, 1, ppos);
if (-EIOCBQUEUED == ret)
ret = wait_on_sync_kiocb(&kiocb);
return ret;
}
函式 generic_file_read() 初始化了 iovec 以及 kiocb 描述符。描述符 iovec 主要是用于存放两个内容:用来接收所读取资料的使用者地址空间缓冲区的地址和缓冲区的大小;描述符 kiocb 用来跟踪 I/O 操作的完成状态。之后,函式 generic_file_read() 凋用函式 __generic_file_aio_read()。该函式检查 iovec 中描述的使用者地址空间缓冲区是否可用,接着检查访问模式,若访问模式描述符设定了 O_DIRECT,则执行与直接 I/O 相关的程式码。函式 __generic_file_aio_read() 中与直接 I/O 有关的程式码如下所示:
if (filp->f_flags & O_DIRECT) {
loff_t pos = *ppos, size;
struct address_space *mapping;
struct inode *inode;
mapping = filp->f_mapping;
inode = mapping->host;
retval = 0;
if (!count)
goto out;
size = i_size_read(inode);
if (pos retval = generic_file_direct_IO(READ, iocb,
iov, pos, nr_segs);
if (retval > 0 && !is_sync_kiocb(iocb))
retval = -EIOCBQUEUED;
if (retval > 0)
*ppos = pos + retval;
}
file_accessed(filp);
goto out;
}
上边的程式码段主要是检查了档案指标的值,档案的大小以及所请求读取的字节数目等,之后,该函式呼叫 generic_file_direct_io(),并将操作型别 READ,描述符 iocb,描述符 iovec,当前档案指标的值以及在描述符 io_vec 中指定的使用者地址空间缓冲区的个数等值作为引数传给它。当 generic_file_direct_io() 函式执行完成,函式 __generic_file_aio_read()会继续执行去完成后续操作:更新档案指标,设定访问档案 i 节点的时间戳;这些操作全部执行完成以后,函式返回。 函式 generic_file_direct_IO() 会用到五个引数,各引数的含义如下所示:
rw:操作型别,可以是 READ 或者 WRITEiocb:指标,指向 kiocb 描述符 iov:指标,指向 iovec 描述符阵列offset:file 结构偏移量nr_segs:iov 阵列中 iovec 的个数函式 generic_file_direct_IO() 程式码如下所示:
static ssize_t
generic_file_direct_IO(int rw, struct kiocb *iocb, const struct iovec *iov,
loff_t offset, unsigned long nr_segs)
{
struct file *file = iocb->ki_filp;
struct address_space *mapping = file->f_mapping;
ssize_t retval;
size_t write_len = 0;
if (rw == WRITE) {
write_len = iov_length(iov, nr_segs);
if (mapping_mapped(mapping))
unmap_mapping_range(mapping, offset, write_len, 0);
}
retval = filemap_write_and_wait(mapping);
if (retval == 0) {
retval = mapping->a_ops->direct_IO(rw, iocb, iov,
offset, nr_segs);
if (rw == WRITE && mapping->nrpages) {
pgoff_t end = (offset + write_len - 1)
>> PAGE_CACHE_SHIFT;
int err = invalidate_inode_pages2_range(mapping,
offset >> PAGE_CACHE_SHIFT, end);
if (err)
retval = err;
}
}
return retval;
}
函式 generic_file_direct_IO() 对 WRITE 操作型别进行了一些特殊处理。除此之外,它主要是呼叫了 direct_IO 方法去执行直接 I/O 的读或者写操作。在进行直接 I/O 读操作之前,先将页快取中的相关脏资料刷回到磁盘上去,这样做可以确保从磁盘上读到的是最新的资料。这里的 direct_IO 方法最终会对应到 __blockdev_direct_IO() 函式上去。__blockdev_direct_IO() 函式的程式码如下所示:
ssize_t
__blockdev_direct_IO(int rw, struct kiocb *iocb, struct inode *inode,
struct block_device *bdev, const struct iovec *iov, loff_t offset,
unsigned long nr_segs, get_block_t get_block, dio_iodone_t end_io,
int dio_lock_type)
{
int seg;
size_t size;
unsigned long addr;
unsigned blkbits = inode->i_blkbits;
unsigned bdev_blkbits = 0;
unsigned blocksize_mask = (1 ssize_t retval = -EINVAL;
loff_t end = offset;
struct dio *dio;
int release_i_mutex = 0;
int acquire_i_mutex = 0;
if (rw & WRITE)
rw = WRITE_SYNC;
if (bdev)
bdev_blkbits = blksize_bits(bdev_hardsect_size(bdev));
if (offset & blocksize_mask) {
if (bdev)
blkbits = bdev_blkbits;
blocksize_mask = (1 if (offset & blocksize_mask)
goto out;
}
for (seg = 0; seg addr = (unsigned long)iov[seg].iov_base;
size = iov[seg].iov_len;
end += size;
if ((addr & blocksize_mask) || (size & blocksize_mask)) {
if (bdev)
blkbits = bdev_blkbits;
blocksize_mask = (1 if ((addr & blocksize_mask) || (size & blocksize_mask))
goto out;
}
}
dio = kmalloc(sizeof(*dio), GFP_KERNEL);
retval = -ENOMEM;
if (!dio)
goto out;
dio->lock_type = dio_lock_type;
if (dio_lock_type != DIO_NO_LOCKING) {
if (rw == READ && end > offset) {
struct address_space *mapping;
mapping = iocb->ki_filp->f_mapping;
if (dio_lock_type != DIO_OWN_LOCKING) {
mutex_lock(&inode->i_mutex);
release_i_mutex = 1;
}
retval = filemap_write_and_wait_range(mapping, offset,
end - 1);
if (retval) {
kfree(dio);
goto out;
}
if (dio_lock_type == DIO_OWN_LOCKING) {
mutex_unlock(&inode->i_mutex);
acquire_i_mutex = 1;
}
}
if (dio_lock_type == DIO_LOCKING)
down_read_non_owner(&inode->i_alloc_sem);
}
dio->is_async = !is_sync_kiocb(iocb) && !((rw & WRITE) &&
(end > i_size_read(inode)));
retval = direct_io_worker(rw, iocb, inode, iov, offset,
nr_segs, blkbits, get_block, end_io, dio);
if (rw == READ && dio_lock_type == DIO_LOCKING)
release_i_mutex = 0;
out:
if (release_i_mutex)
mutex_unlock(&inode->i_mutex);
else if (acquire_i_mutex)
mutex_lock(&inode->i_mutex);
return retval;
}
该函式将要读或者要写的资料进行拆分,并检查缓冲区对齐的情况。本文在前边介绍 open() 函式的时候指出,使用直接 I/O 读写资料的时候必须要注意缓冲区对齐的问题,从上边的程式码可以看出,缓冲区对齐的检查是在 __blockdev_direct_IO() 函式里边进行的。使用者地址空间的缓冲区可以通过 iov 阵列中的 iovec 描述符确定。直接 I/O 的读操作或者写操作都是同步进行的,也就是说,函式 __blockdev_direct_IO() 会一直等到所有的 I/O 操作都结束才会返回,因此,一旦应用程序 read() 系统呼叫返回,应用程序就可以访问使用者地址空间中含有相应资料的缓冲区。但是,这种方法在应用程序读操作完成之前不能关闭应用程序,这将会导致关闭应用程序缓慢。
直接I/O 优点
最大的优点就是减少操作系统缓冲区和使用者地址空间的拷贝次数。降低了CPU的开销,和内存带宽。对于某些应用程序来说简直是福音,将会大大提高效能。直接I/O 缺点
直接IO并不总能让人如意。直接IO的开销也很大,应用程序没有控制好读写,将会导致磁盘读写的效率低下。磁盘的读写是通过磁头的切换到不同的磁道上读取和写入资料,如果需要写入资料在磁盘位置相隔比较远,就会导致寻道的时间大大增加,写入读取的效率大大降低。总结
直接IO方式确实能够减少CPU的使用率以及内存带宽的占用,但是有时候也会造成效能的影响。所以在使用直接IO之前一定要清楚它的原理,只有在各项都清晰的情况下,才考虑使用。本人只是介绍了原理,如想深入,建议参考核心相关文件。觉得不错,记得转发点赞“在看”





























