基于ARM64架构的Linux4.x内核一书,作者余华兵。系列文章主要用于记录Linux内核的大部分机制及参数的总结说明进程读文件的方式有3种:
(1)调用内核提供的读文件的系统调用。
(2)调用glibc库封装的读文件的标准I/O流函数。
(3)创建基于文件的内存映射,把文件的一个区间映射到进程的虚拟地址空间,然后直接读内存。
第2种方式在用户空间创建了缓冲区,能减少系统调用的次数,提高性能。第3种方式可以避免系统调用,性能最高。
内核提供了下面这些读文件的系统调用。
(1)系统调用read从文件的当前偏移读文件,把数据存放在一个缓冲区。
ssize_t read(int fd, void *buf, size_t count);
(2)系统调用pread64从指定偏移开始读文件。
ssize_t pread64(int fd, void *buf, size_t count, off_t offset);
(3)系统调用readv从文件的当前偏移读文件,把数据存放在多个分散的缓冲区。
ssize_t readv(int fd, const struct iovec *iov, int iovcnt);
(4)系统调用preadv从指定偏移开始读文件,把数据存放在多个分散的缓冲区。
ssize_t preadv(int fd, const struct iovec *iov, int iovcnt, off_t offset);
(5)系统调用preadv2在系统调用preadv的基础上增加了参数“int flags”。
ssize_t preadv2(int fd, const struct iovec *iov, int iovcnt, off_t offset, int flags);
其中preadv和preadv2是Linux内核私有的系统调用。
对于除了pread64以外的系统调用,glibc库封装了同名的库函数。针对系统调用pread64,glibc库封装的函数是pread,原型如下:
ssize_t pread(int fd, void *buf, size_t count, off_t offset);
glibc库还封装了一个读文件的标准I/O流函数:
size_t fread(void *ptr, size_t size, size_t nmemb, FILE *stream);
使用基于文件的内存映射读文件的方法如下所示,把文件从偏移offset开始、长度为len字节的区间映射到进程的虚拟地址空间,偏移offset 必须是页长度的整数倍:
int fd, i;
char *addr;
fd = open("/a/b.txt", O_RDWR);
if (fd < 0) {
exit(EXIT_FAILURE);
}
addr = mmap(NULL, len, PROT_READ | PROT_WRITE, MAP_SHARED, fd, offset);
if (addr == MAP_FAILED) {
exit(EXIT_FAILURE);
}
for (i = 0; i < len; i++) {
printf("%c", *(addr + i));
}
读文件的主要步骤如下:
(1)调用具体文件系统类型提供的文件操作集合的read或read_iter方法来读文件。
(2)read或read_iter方法根据页索引在文件的页缓存中查找页,如果没有找到,那么调用具体文件系统类型提供的地址空间操作集合的readpage方法来从存储设备读取文件页到内存中。
为了提高读文件的速度,从存储设备读取文件页到内存中的时候,除了读取请求的文件页,还会预读后面的文件页。如果进程按顺序读文件,预读文件页可以提高读文件的速度;如果进程随机读文件,预读文件页对提高读文件的速度帮助不大。
常用的读文件系统调用是read,其定义如下:
fs/read_write.c
SYSCALL_DEFINE3(read, unsigned int, fd, char __user *, buf, size_t, count)
系统调用read的执行流程如下所示:
(1)调用函数fdget_pos,根据文件描述符在当前进程的打开文件表中查找文件的打开实例:file结构体。
(2)调用函数file_pos_read,从文件的打开实例读取文件的当前偏移。
(3)调用函数vfs_read读文件。
(4)调用函数file_pos_write,把文件的当前偏移加上读取的字节数。
(5)调用函数fdput_pos,释放文件的打开实例。
read方法和read_iter方法的区别是:read方法只能传入一个连续的缓冲区,read_iter方法可以传入多个分散的缓冲区。‘
以EXT4文件系统为例,它提供了文件操作集合的read_iter方法:
fs/ext4/file.c
const struct file_operations ext4_file_operations = {
…
.read_iter = ext4_file_read_iter,
…
};
函数ext4_file_read_iter调用通用的读文件函数generic_file_read_iter,执行流程如下所示,针对请求的每一页,执行下面的操作:
(1)调用函数find_get_page,根据页索引在文件的页缓存中查找页。
(2)如果没有找到页,执行下面的操作。
1)调用函数page_cache_sync_readahead,从存储设备读取请求的页,并且预读后面的页。假设请求读第0页,同时预读第1页、第2页和第3页,会给预读的第一页设置预读标志。
2)第二次根据页索引在文件的页缓存中查找页。
3)如果没有找到页,执行下面的操作。
(3)如果为页设置了预读标志,说明这一页是读取前一页的时候预读到内存的,那么调用函数page_cache_async_readahead继续预读后面的页,使用异步模式,不等待读操作结束。
(4)调用函数mark_page_accessed以标记页被访问过。
(5)调用函数copy_page_to_iter,把数据从页缓存复制到用户缓冲区。
进程写文件的方式有3种:
(1)调用内核提供的写文件的系统调用。
(2)调用glibc库封装的写文件的标准I/O流函数。
(3)创建基于文件的内存映射,把文件的一个区间映射到进程的虚拟地址空间,然后直接写内存。
第2种方式在用户空间创建了缓冲区,能够减少系统调用的次数,提高性能。第3种方式可以避免系统调用,性能最高。
内核提供了下面这些写文件的系统调用:
(1)函数write从文件的当前偏移写文件,调用进程把要写入的数据存放在一个缓冲区。
ssize_t write(int fd, const void *buf, size_t count);
(2)函数pwrite64从指定偏移开始写文件。
ssize_t pwrite64(int fd, const void *buf, size_t count, off_t offset);
(3)函数writev从文件的当前偏移写文件,调用进程把要写入的数据存放在多个分散的缓冲区。
ssize_t writev(int fd, const struct iovec *iov, int iovcnt);
(4)函数pwritev从指定偏移开始写文件,调用进程把要写入的数据存放在多个分散的缓冲区。
ssize_t pwritev(int fd, const struct iovec *iov, int iovcnt, off_t offset);
(5)函数pwritev2在函数pwritev的基础上增加了参数“int flags”。
ssize_t pwritev2(int fd, const struct iovec *iov, int iovcnt, off_t offset, int flags);
其中pwritev和pwritev2是Linux内核私有的系统调用。
对于除了pwrite64以外的系统调用,glibc库封装了同名的库函数。针对系统调用pwrite64,glibc库封装的函数是pwrite,原型如下:
ssize_t pwrite(int fd, const void *buf, size_t count, off_t offset);
glibc库还封装了一个写文件的标准I/O流函数:
size_t fwrite(const void *ptr, size_t size, size_t nmemb, FILE *stream);
使用基于文件的内存映射写文件的方法如下所示,把文件从偏移offset开始、长度为len字节的区间映射到进程的虚拟地址空间,偏移offset 必须是页长度的整数倍:
int fd, i;
char *addr;
fd = open("/a/b.txt", O_RDWR);
if (fd < 0) {
exit(EXIT_FAILURE);
}
addr = mmap(NULL, len, PROT_READ | PROT_WRITE, MAP_SHARED, fd, offset);
if (addr == MAP_FAILED) {
exit(EXIT_FAILURE);
}
for (i = 0; i < len; i++) {
*(addr + i) = ‘a’;
}
写文件的主要步骤如下:
(1)调用具体文件系统类型提供的文件操作集合的write或write_iter方法来写文件。
(2)write或write_iter方法调用文件的地址空间操作集合的write_begin方法,在页缓存中查找页,如果页不存在,那么分配页;然后把数据从用户缓冲区复制到页缓存的页中;最后调用文件的地址空间操作集合的write_end方法。
常用的写文件系统调用是write,其定义如下:
fs/read_write.c
SYSCALL_DEFINE3(write, unsigned int, fd, const char __user *, buf, size_t, count)
系统调用write的执行流程如下所示:
(1)调用函数fdget_pos,根据文件描述符在当前进程的打开文件表中查找文件的打开实例:file结构体。
(2)调用函数file_pos_read,从文件的打开实例读取文件的当前偏移。
(3)调用函数vfs_write写文件。
(4)调用函数file_pos_write,把文件的当前偏移加上写入的字节数。
(5)调用函数fdput_pos,释放文件的打开实例。
write方法和write_iter方法的区别是:write方法只能传入一个连续的缓冲区,write_iter方法可以传入多个分散的缓冲区。
以EXT4文件系统为例,它提供了文件操作集合的write_iter方法:
fs/ext4/file.c
const struct file_operations ext4_file_operations = {
…
.write_iter = ext4_file_write_iter,
…
};
函数ext4_file_write_iter调用通用的写文件函数__generic_file_write_iter,执行流程如下所示,针对要写入的每一页,执行下面的操作:
(1)调用函数iov_iter_fault_in_readable,故意触发页错误异常,确保用户缓冲区的当前页在内存中。如果页被换出到交换区,那么触发页错误异常,把页换入到内存中。
(2)调用文件的地址空间操作集合的write_begin方法,EXT4文件系统提供的write_begin方法是函数ext4_write_begin,在页缓存中查找页,如果页不存在,那么分配页。
(3)调用函数iov_iter_copy_from_user_atomic,把数据从用户缓冲区复制到页缓存的页中。
(4)调用函数flush_dcache_page,把数据缓存中的数据写回到内存。上一步把数据从用户缓冲区复制到页缓存,数据可能在处理器的数据缓存中,如果数据缓存使用虚拟地址生成索引,可能存在缓存别名问题。
(5)调用文件的地址空间操作集合的write_end方法,EXT4文件系统提供的write_end方法是函数ext4_write_end,在向页缓存写入一页以后执行特定的操作。
(6)调用函数iov_iter_advance,把指针移到下一次要写入的数据的起始位置。
(7)调用函数balance_dirty_pages_ratelimited,控制进程写文件时生成脏页的速度。
进程写文件时,内核的文件系统模块把数据写到文件的页缓存,没有立即写回到存储设备。文件系统模块会定期把脏页(即数据被修改过的文件页)写回到存储设备,进程也可以调用系统调用把脏页强制写回到存储设备。
管理员可以执行命令“sync”,把内存中所有修改过的文件元数据和文件数据写回到存储设备。
内核提供了下面这些把文件同步到存储设备的系统调用:
(1)sync把内存中所有修改过的文件元数据和文件数据写回到存储设备:
void sync(void);
(2)syncfs把文件描述符fd引用的文件所属的文件系统写回到存储设备:
int syncfs(int fd);
(3)fsync把文件描述符fd引用的文件修改过的元数据和数据写回到存储设备:
int fsync(int fd);
(4)fdatasync把文件描述符fd引用的文件修改过的数据写回到存储设备,还会把检索这些数据需要的元数据写回到存储设备:
int fdatasync(int fd);
(5)Linux私有的系统调用sync_file_range把文件的一个区间修改过的数据写回到存储设备:
int sync_file_range(int fd, off64_t offset, off64_t nbytes, unsigned int flags);
glibc库针对这些系统调用封装了同名的库函数,还封装了一个把数据从用户空间缓冲区写到内核的标准I/O流函数:
int fflush(FILE *stream);
把文件写回到存储设备的时机如下:
(1)周期回写。
(2)当脏页的数量达到限制的时候,强制回写。
(3)进程调用sync和syncfs等系统调用。
1.数据结构
文件回写的数据结构如下所示:
在挂载存储设备上的文件系统时,具体文件系统类型提供的mount方法从存储设备读取超级块,在内存中创建超级块的副本,把超级块关联到描述存储设备信息的结构体backing_dev_info。结构体backing_dev_info的主要成员如下:
include/linux/backing-dev-defs.h
struct backing_dev_info {
struct list_head bdi_list;
…
struct bdi_writeback wb;
…
};
成员bdi_list用来把所有backing_dev_info实例链接到全局链表bdi_list。
结构体backing_dev_info有一个类型为bdi_writeback的成员wb,结构体bdi_writeback是回写控制块,主要成员如下:
include/linux/backing-dev-defs.h
struct bdi_writeback {
…
struct list_head b_dirty;
struct list_head b_io;
…
struct delayed_work dwork;
…
};
链表b_dirty用来存放该文件系统中所有数据或属性被修改过的索引节点。
链表b_io用来存放准备写回到存储设备的索引节点。
成员dwork是一个延迟工作项,处理函数是文件“fs/fs-writeback.c”中定义的函数wb_workfn,它负责把该文件系统中的脏页写回到后备存储设备。
内核创建了一个名为“writeback”的工作队列,专门负责把文件写回到存储设备,称为回写工作队列。全局变量bdi_wq指向回写工作队列:
mm/backing-dev.c
struct workqueue_struct *bdi_wq;
static int __init default_bdi_init(void)
{
…
bdi_wq = alloc_workqueue("writeback", WQ_MEM_RECLAIM | WQ_FREEZABLE |
WQ_UNBOUND | WQ_SYSFS, 0);
…
}
subsys_initcall(default_bdi_init);
把回写控制块中的延迟工作项添加到回写工作队列的时机是:修改文件的属性或数据。
修改文件的属性,以调用chmod修改文件的访问权限为例,假设文件属于EXT4文件系统,执行流程如下所示。系统调用chmod调用EXT4文件系统提供的索引节点操作集合的setattr方法:函数ext4_setattr。函数ext4_setattr的执行过程如下:
(1)调用函数setattr_copy,把访问权限保存到索引节点。
(2)给索引节点的字段i_state设置I_DIRTY。I_DIRTY是标志位组合(I_DIRTY_SYNC | I_DIRTY_DATASYNC | I_DIRTY_PAGES),I_DIRTY_SYNC表示文件的属性变化(系统调用fdatasync不需要同步),I_DIRTY_DATASYNC表示检索数据需要的属性变化(系统调用fdatasync需要同步),I_DIRTY_PAGES表示文件有脏页,即文件的数据有变化。
(3)把索引节点添加到回写控制块的链表b_dirty中。
(4)调用函数wb_wakeup_delayed,把回写控制块的延迟工作项添加到回写工作队列。
以调用write写EXT4文件系统的一个文件为例,如下所示,系统调用write调用EXT4文件系统提供的文件操作集合的write_iter方法:函数ext4_file_write_iter:
调用函数iov_iter_copy_from_user_atomic把一页数据从用户缓冲区复制到页缓存以后,调用EXT4文件系统提供的地址空间操作集合的write_end方法:函数ext4_write_end。函数ext4_write_end的执行过程如下。
(1)调用函数__set_page_dirty,在页缓存中给页设置脏标记。
(2)给索引节点的字段i_state设置标志位I_DIRTY_PAGES,表示文件有脏页,即文件的数据有变化。
(3)把索引节点添加到回写控制块的链表b_dirty中。
(4)调用函数wb_wakeup_delayed,把回写控制块的延迟工作项添加到回写工作队列。
函数wb_wakeup_delayed把回写控制块的延迟工作项添加到回写工作队列,超时是周期回写的时间间隔:
mm/backing-dev.c
void wb_wakeup_delayed(struct bdi_writeback *wb)
{
unsigned long timeout;
timeout = msecs_to_jiffies(dirty_writeback_interval * 10);
spin_lock_bh(&wb->work_lock);
if (test_bit(WB_registered, &wb->state))
queue_delayed_work(bdi_wq, &wb->dwork, timeout);
spin_unlock_bh(&wb->work_lock);
}
2.周期回写
周期回写的时间间隔是5秒,管理员可以通过文件“/proc/sys/vm/dirty_writeback_centisecs”来配置,单位是厘秒,即百分之秒。
mm/page-writeback.c
unsigned int dirty_writeback_interval = 5 * 100; /*厘秒*/
一页保持为脏状态的最长时间是30秒,管理员可以通过文件“/proc/sys/vm/dirty_expire_centisecs”来配置,单位是厘秒,即百分之秒:
mm/page-writeback.c
unsigned int dirty_expire_interval = 30 * 100; /*厘秒*/
周期回写的执行流程如下所示:
(1)如果距上一次周期回写的时间间隔大于或等于dirty_writeback_interval,那么执行周期回写。
(2)把保持脏状态时间大于或等于dirty_expire_interval的索引节点从回写控制块的链表b_dirty移到链表b_io中。
(3)从回写控制块的链表b_io的尾部取索引节点,调用函数__writeback_single_inode,把文件的脏页写回到存储设备。
3.强制回写
当脏页的数量超过后台回写阈值时,后台回写线程开始把脏页写回到存储设备。后台回写阈值是脏页占可用内存大小(包括空闲页和可回收页,不等于内存容量)的比例或者脏页的字节数,默认的脏页比例是10。管理员可以通过文件“/proc/sys/vm/dirty_background_ratio”修改脏页比例,通过文件“/proc/sys/vm/dirty_background_bytes”修改脏页的字节数,这两个参数是互斥的关系:
mm/page-writeback.c
int dirty_background_ratio = 10;
unsigned long dirty_background_bytes;
后台回写的执行流程如下所示:
(1)如果脏页的数量超过后台回写线程开始回写的阈值,那么执行后台回写。
(2)只要脏页的数量超过后台回写线程开始回写的阈值,就一直执行后台回写。
当脏页的数量达到进程主动回写阈值后,正在写文件的进程开始把脏页写回到存储设备,并且挂起等待。进程主动回写阈值是脏页占可用内存大小(包括空闲页和可回收页,不等于内存容量)的比例或者脏页的字节数,默认的脏页比例是20。管理员可以通过文件“/proc/sys/vm/dirty_ratio”修改脏页比例,通过文件“/proc/sys/vm/dirty_bytes”修改脏页的字节数,这两个参数是互斥的关系:
mm/page-writeback.c
int vm_dirty_ratio = 20;
unsigned long vm_dirty_bytes;
以调用write写EXT4文件系统的一个文件为例,如下所示,调用函数balance_dirty_pages_ratelimited控制进程写文件时生成脏页的速度,如果脏页的数量超过(后台回写阈值 + 进程主动回写阈值)/2,那么反复执行下面的操作:
(1)如果没有正在回写,那么启动后台回写。
(2)进程睡眠一段时间。
4.系统调用sync
执行命令sync的时候,命令处理函数调用系统调用sync,把内存中所有修改过的文件属性和数据写回到存储设备。
系统调用sync的定义如下:
fs/sync.c
SYSCALL_DEFINE0(sync)
系统调用sync的执行流程如下所示:
(1)遍历链表bdi_list,针对每个存储设备的backing_dev_info实例,把回写控制块的工作项添加到回写工作队列。
(2)遍历链表super_blocks,针对每个超级块,把回写控制块的工作项添加到回写工作队列,并且等待工作项执行完成,也就是等待当前文件系统中所有修改过的索引节点和数据写回到存储设备。
(3)遍历链表super_blocks,针对每个超级块,调用超级块操作集合的sync_fs方法,把文件系统写回到存储设备,不等待写操作完成。例如,EXT2文件系统的sync_fs方法把超级块写回到存储设备,EXT4文件系统的sync_fs方法提交日志。
(4)遍历链表super_blocks,针对每个超级块,调用超级块操作集合的sync_fs方法,把文件系统写回到存储设备,需要等待写操作完成。
(5)执行两遍:针对每个块设备,把块缓存中修改过的数据块写回到存储设备。