Linux 内核源代码情景分析(四)(上):https://developer.aliyun.com/article/1597998
5.5 文件的打开与关闭
用户进程在能够读/写一个文件之前必须要先 “打开” 这个文件。对文件的读/写从概念上说是一种进程与文件系统之间的一种 “有连接” 通信,所谓 “打开文件” 实质上就是在进程与文件之间建 立起连接,而 “打开文件号” 就惟一地标识着这样一个连接。不过,严格意义上的 “连接” 意味着一个独立的 “上下文” ,如果一个进程与某个目标之间重复建立起多个连接,则每个连接都应该是互相独立的。在文件系统的处理中,每当一个进程重复打开同一个文件时就建立起一个由 file 数据结构代表的独立的上下文。通常,一个 file 数据结构,即一个读/写文件的上下文,都由一个 “打开文件号” 加以标识,但是通过系统调用 dup() 或 dup2() 却可以使同一个 file 结构对应到多个 “打开文件号。”
1、sys_open
打开文件的系统调用是 open() ,在内核中通过 sys_open() 实现,其代码在 fs/open.c 中。
// fs/open.c asmlinkage long sys_open(const char * filename, int flags, int mode) { char * tmp; int fd, error; #if BITS_PER_LONG != 32 flags |= O_LARGEFILE; #endif tmp = getname(filename); fd = PTR_ERR(tmp); if (!IS_ERR(tmp)) { fd = get_unused_fd(); if (fd >= 0) { // 显然,sys_open() 的主体是filp_open() ,其代码也在fs/open.c中 struct file *f = filp_open(tmp, flags, mode); error = PTR_ERR(f); if (IS_ERR(f)) goto out_error; fd_install(fd, f); } out: putname(tmp); } return fd; out_error: put_unused_fd(fd); fd = error; goto out; }
调用参数 filename 实际上是文件的路径名(绝对路件名或相对路径名);mode 表示打开的模式, 如 “只读” 等等;而 flags 则包含了许多标志位,用以表示打开模式以外的一些属性和要求。函数通过 getname() 从用户空间把文件的路径名拷贝到系统空间,并通过 get_unused_fd() 从当前进程的 “打开文件表” 中找到一个空闲的表项,该表项的下标即为 “打开文件号” 。然后,根据文件名通过 filp_open() 找到或创建一个 “连接” ,或者说读/写该文件的上下文。文件读写的上下文是由 file 数据结构代表和描绘的,其定义见 include/linux/fs.h:
(1)file
// include/linux/fs.h struct file { struct list_head f_list; struct dentry *f_dentry; struct vfsmount *f_vfsmnt; struct file_operations *f_op; atomic_t f_count; unsigned int f_flags; mode_t f_mode; loff_t f_pos; unsigned long f_reada, f_ramax, f_raend, f_ralen, f_rawin; struct fown_struct f_owner; unsigned int f_uid, f_gid; int f_error; unsigned long f_version; /* needed for tty driver, and maybe others */ void *private_data; };
(2)get_unused_fd
// fs/open.c /* * Find an empty file descriptor entry, and mark it busy. */ int get_unused_fd(void) { struct files_struct * files = current->files; int fd, error; error = -EMFILE; write_lock(&files->file_lock); repeat: fd = find_next_zero_bit(files->open_fds, files->max_fdset, files->next_fd); /* * N.B. For clone tasks sharing a files structure, this test * will limit the total number of files that can be opened. */ if (fd >= current->rlim[RLIMIT_NOFILE].rlim_cur) goto out; /* Do we need to expand the fdset array? */ if (fd >= files->max_fdset) { error = expand_fdset(files, fd); if (!error) { error = -EMFILE; goto repeat; } goto out; } /* * Check whether we need to expand the fd array. */ if (fd >= files->max_fds) { error = expand_fd_array(files, fd); if (!error) { error = -EMFILE; goto repeat; } goto out; } FD_SET(fd, files->open_fds); FD_CLR(fd, files->close_on_exec); files->next_fd = fd + 1; #if 1 /* Sanity check */ if (files->fd[fd] != NULL) { printk("get_unused_fd: slot %d not NULL!\n", fd); files->fd[fd] = NULL; } #endif error = fd; out: write_unlock(&files->file_lock); return error; }
(3)files_struct
// include/linux/sched.h /* * Open file table structure */ struct files_struct { atomic_t count; rwlock_t file_lock; int max_fds; int max_fdset; int next_fd; struct file ** fd; /* current fd array */ fd_set *close_on_exec; fd_set *open_fds; fd_set close_on_exec_init; fd_set open_fds_init; struct file * fd_array[NR_OPEN_DEFAULT]; };
这个数据结构中最主要的成分是一个 file 结构指针数组 fd_array[],这个数组的大小是固定的,即 32,其下标即为 “打开文件号” 。另外,结构中还有个指针 fd ,最初时指向 fd_array[]。结构中还有两个位图 close_on_exec_init 和 open_fds_init,这些位图大致对应着 file 结构指针数组的内容,但是比 fd_array[] 的大小要大得多。同时,又有两个指针 close_on_exec 和 open_fds,最初时分别指向上述两个位图。每次打开文件分配一个打开文件号时就将由 open_fds 所指向位图中的相应位设成 1 。此外,该数据结构中还有两个参数 max_fds 和 max_fdset,分别反映着当前 file 结构指针数组与位图的容量。一个进程可以有多少个已打开文件只取决于该进程的 task_struct 结构中关于可用资源的限制(见上面代码中的第701行)。在这个限制以内,如果超出了其 file 结构指针数组的容量就通过 expand_fd_array() 扩充该数组的容量,并让指针 fd 指向新的数组;如果超出了位图的容量就通过 expand_fdset() 扩充两个位图的容量,并使两个指针也分别指向新的位图。这样,就克服了早期Unix因只采用固定大小的 file 结构指针数组而使每个进程可以同时打开文件数量受到限制的缺陷。
打开文件时,更确切地说是分配空闲打开文件号时,通过宏操作 FD_SET() 将 open_fds 所指向的位图中的相应位设成 1,表示这个打开文件号已不再空闲,这个位图代表着已经在使用中的打开文件号。 同时,还通过 FD_CLR() 将由指针 close_on_exec 所指向的位图中的相应位清 0,表示如果当前进程通过 exec() 系统调用执行一个可执行程序的话无需将这个文件关闭。这个位图的内容可以通过 ioctl() 系统调用来设置。
动态地调整可同时打开的文件数量对于现代、特别是面向对象的环境具有重要意义,因为在这些环境下常常要求同时打开数量众多(但是每个文件却很小)的文件。
(4)filp_open
// fs/open.c /* * Note that while the flag value (low two bits) for sys_open means: * 00 - read-only * 01 - write-only * 10 - read-write * 11 - special * it is changed into * 00 - no permissions needed * 01 - read-permission * 10 - write-permission * 11 - read-write * for the internal routines (ie open_namei()/follow_link() etc). 00 is * used by symlinks. */ struct file *filp_open(const char * filename, int flags, int mode) { int namei_flags, error; struct nameidata nd; namei_flags = flags; if ((namei_flags+1) & O_ACCMODE) namei_flags++; if (namei_flags & O_TRUNC) namei_flags |= 2; error = open_namei(filename, namei_flags, mode, &nd); if (!error) return dentry_open(nd.dentry, nd.mnt, flags); return ERR_PTR(error); }
这里的参数 flags 就是系统调用 open() 传下来的,它遵循 open() 界面上对 flags 的约定,但是这里调用的 open_namei() 却对这些标志位有不同的约定(见600行至613行中的注释),所以要在调用 open_namei() 前先加以变换,对 i386 处理器,用于 open() 界面上的标志位是在 include/asm-i386/fcntl.h 中定义的。
(5)open_namei
// fs/namei.c /* * open_namei() * * namei for open - this is in fact almost the whole open-routine. * * Note that the low bits of "flag" aren't the same as in the open * system call - they are 00 - no permissions needed * 01 - read permission needed * 10 - write permission needed * 11 - read/write permissions needed * which is a lot more logical, and also allows the "no perm" needed * for symlinks (where the permissions are checked later). * SMP-safe */ int open_namei(const char * pathname, int flag, int mode, struct nameidata *nd) { int acc_mode, error = 0; struct inode *inode; struct dentry *dentry; struct dentry *dir; int count = 0; acc_mode = ACC_MODE(flag); /* * The simplest case - just a plain lookup. */ // 调用参数 flag 中的 O_CREAT 标志位表示如果要打开的文件不存在就创建这个文件。所以,如果 // 这个标志位为0,就仅仅是在文件系统中寻找目标节点,这就是通过 path_init() 和path_walk() 根据目 // 标节点的路径名找到该节点的dentry结构(以及inode结构)的过程,那一经在前面介绍过了。这里 // 惟一值得一提的是在调用path_init() 时的参数flag还要通过lookup_flags() 进行一些处埋,其代码也在 // fs/namei.c 中 if (!(flag & O_CREAT)) { if (path_init(pathname, lookup_flags(flag), nd)) error = path_walk(pathname, nd); if (error) return error; dentry = nd->dentry; goto ok; } /* * Create - we need to know the parent. */ if (path_init(pathname, LOOKUP_PARENT, nd)) error = path_walk(pathname, nd); if (error) return error; /* * We have the parent and last component. First of all, check * that we are not asked to creat(2) an obvious directory - that * will not do. */ error = -EISDIR; if (nd->last_type != LAST_NORM || nd->last.name[nd->last.len]) goto exit; // 我们已经找到了目标文件所在目录的 dentry 结构,并让指针 dir 指向这个数据结构,下一步就 // 是通过 lookup_hash() 寻找目标文件的 dentry 结构了(980行)。函数 lookup_hash() 的代码也在 // fs/namei.c 中。 dir = nd->dentry; down(&dir->d_inode->i_sem); dentry = lookup_hash(&nd->last, nd->dentry); do_last: error = PTR_ERR(dentry); if (IS_ERR(dentry)) { up(&dir->d_inode->i_sem); goto exit; } /* Negative dentry, just create the file */ if (!dentry->d_inode) { error = vfs_create(dir->d_inode, dentry, mode); up(&dir->d_inode->i_sem); dput(nd->dentry); nd->dentry = dentry; if (error) goto exit; /* Don't check for write permission, don't truncate */ acc_mode = 0; flag &= ~O_TRUNC; goto ok; } /* * It already exists. */ up(&dir->d_inode->i_sem); error = -EEXIST; if (flag & O_EXCL) goto exit_dput; if (d_mountpoint(dentry)) { error = -ELOOP; if (flag & O_NOFOLLOW) goto exit_dput; do __follow_down(&nd->mnt,&dentry); while(d_mountpoint(dentry)); } error = -ENOENT; if (!dentry->d_inode) goto exit_dput; if (dentry->d_inode->i_op && dentry->d_inode->i_op->follow_link) goto do_link; dput(nd->dentry); nd->dentry = dentry; error = -EISDIR; if (dentry->d_inode && S_ISDIR(dentry->d_inode->i_mode)) goto exit; ok: error = -ENOENT; inode = dentry->d_inode; if (!inode) goto exit; error = -ELOOP; if (S_ISLNK(inode->i_mode)) goto exit; error = -EISDIR; if (S_ISDIR(inode->i_mode) && (flag & FMODE_WRITE)) goto exit; error = permission(inode,acc_mode); if (error) goto exit; /* * FIFO's, sockets and device files are special: they don't * actually live on the filesystem itself, and as such you * can write to them even if the filesystem is read-only. */ if (S_ISFIFO(inode->i_mode) || S_ISSOCK(inode->i_mode)) { flag &= ~O_TRUNC; } else if (S_ISBLK(inode->i_mode) || S_ISCHR(inode->i_mode)) { error = -EACCES; if (IS_NODEV(inode)) goto exit; flag &= ~O_TRUNC; } else { error = -EROFS; if (IS_RDONLY(inode) && (flag & 2)) goto exit; } /* * An append-only file must be opened in append mode for writing. */ error = -EPERM; if (IS_APPEND(inode)) { if ((flag & FMODE_WRITE) && !(flag & O_APPEND)) goto exit; if (flag & O_TRUNC) goto exit; } /* * Ensure there are no outstanding leases on the file. */ error = get_lease(inode, flag); if (error) goto exit; if (flag & O_TRUNC) { error = get_write_access(inode); if (error) goto exit; /* * Refuse to truncate files with mandatory locks held on them. */ error = locks_verify_locked(inode); if (!error) { DQUOT_INIT(inode); error = do_truncate(dentry, 0); } put_write_access(inode); if (error) goto exit; } else if (flag & FMODE_WRITE) DQUOT_INIT(inode); return 0; exit_dput: dput(dentry); exit: path_release(nd); return error; do_link: error = -ELOOP; if (flag & O_NOFOLLOW) goto exit_dput; /* * This is subtle. Instead of calling do_follow_link() we do the * thing by hands. The reason is that this way we have zero link_count * and path_walk() (called from ->follow_link) honoring LOOKUP_PARENT. * After that we have the parent and last component, i.e. * we are in the same situation as after the first path_walk(). * Well, almost - if the last component is normal we get its copy * stored in nd->last.name and we will have to putname() it when we * are done. Procfs-like symlinks just set LAST_BIND. */ UPDATE_ATIME(dentry->d_inode); // 读过 path_walk() 的读者对这段代码不应该感到陌生。找到连接目标的操作主要是由具体文件系 // 统在其 inode_operations 结构中的函数指针如 follow_link 提供的。对于 Ext2 这个函数为 // ext2_follow_link() ,而最后这个函数又会通过 vfs_follow_link() 调用 path_walk() , // 这读者已经看到过了。注意前面通过 path_init() 设置在 nameidata 数据结构中的标志位并未改变, // 仍是 LOOKUP_PARENT 。 // 对于目标节点是符号连接的情况,如果说在 follow_link 之前的“目标节点”是“视在目标节点”, 那么 // follow_link 以后的nd->last就是“真实目标节点”的节点名了,而nd->dentry则仍旧指向其父 // 节点的dentry结构。 error = dentry->d_inode->i_op->follow_link(dentry, nd); dput(dentry); if (error) return error; if (nd->last_type == LAST_BIND) { dentry = nd->dentry; goto ok; } error = -EISDIR; if (nd->last_type != LAST_NORM) goto exit; if (nd->last.name[nd->last.len]) { putname(nd->last.name); goto exit; } if (count++==32) { dentry = nd->dentry; putname(nd->last.name); goto ok; } dir = nd->dentry; down(&dir->d_inode->i_sem); dentry = lookup_hash(&nd->last, nd->dentry); putname(nd->last.name); goto do_last; }
(6)导图
5.6 文件的写与读
只有在 “打开” 了文件以后,或者说建立起进程与文件之间的 “连接” 之后,才能对文件进行读 /写。文件的读/写主要是通过系统调用 read() 和 write() 完成的,对于读/写文件的进程,目标文件由一个 “打开文件号” 代表。
为了提高效率,稍为复杂一些的操作系统对文件的读/写都是带缓冲的,Linux 当然也不例外。像 VFS 一样,Linux文件系统的缓冲机制也是它的一大特色。所谓缓冲,是指系统为最近刚读/写过的文件内容在内核中保留一份副本,以便当再次需要已经缓冲存储在副本中的内容时就不必再临时从设备上读入,而需要写的时候则可以先写到副本中,待系统较为空闲时再从副本写入设备。在多进程的系统中,由于同一文件可能为多个进程所共享,缓冲的作用就更显著了。
然而,怎样实现缓冲,在哪一个层次上实现缓冲,却是一个值得仔细加以考虑的问题。回顾一下本章开头处的文件系统层次图(图5.3和图5.1),在系统中处于最高层的是进程,这一层可以称为 “应用层” ,是在用户空间运行的,在这里代表着目标文件的是 “打开文件号” 。在这一层中提供缓冲似乎最贴近文件内容的使用者,但是那样就需要用户进程的介入,从而不能做到对使用者 “透明” ,并且缓冲的内容不能为其他进程所共享,所以显然是不妥当的。在应用层以下是 “文件层’',又可细分为VFS 层和具体的文件系统层,再下面就是 “设备层” 了。这些层次都在内核中,所以在这些层次上实现缓冲都可以达到对用户透明的目标。设备层是最贴近设备,即文件内容的 “源头” 的地方,在这里实现缓冲显然是可行的。事实上,早期Unix内核中的文件缓冲就是以数据块缓冲的形式在这一层上实现的。 但是,设备层上的缓冲离使用者的距离太远了一点,特别是当文件层又分为 VFS 和具体文件系统两个子层时,每次读/写都要穿越这么多界面深入到设备层就难免使人有一种 “长途跋涉” 之感。很自然地,设计人员把眼光投向了文件层。
在文件层中有三种主要的数据结构,就是 file 结构、dentry 结构以及 inode 结构。
先看 file 结构。前面讲过,一个 file 结构代表着目标文件的一个上下文,不但不同的进程可以在同 一个文件上建立不同的上下文,就是同一个进程也可以通过打开同一个文件多次而建立起多个上下文。 如果在 file 结构中设置一个缓冲区队列,那么缓冲区中的内容虽然贴近这个特定上下文的使用者,却不便于为多个进程共享,甚至不便于同一个进程打开的不同上下文 “共享” 。这显然是不合适的,需要把这些缓冲区像数学上的 “提取公因子” 那样放到一个公共的地方。
那么 dentry 结构怎么样?这个数据结构并不属于某一个上下文,也不属于某一个进程,可以为所有的进程和上下文共享。可是,dentry 结构与目标文件并不是一对一的关系,通过文件连接,我们可以为已经存在的文件建立 “别名” 。一个 dentry 结构只是惟一地代表着文件系统中的一个节点,也就是一个路径名,但是多个节点可以同时代表着同一个文件,所以,还应该再来一次 “提取公因子” 。
显然,在 inode 数据结构中设置一个缓冲区队列是最合适不过的了,首先,inode 结构与文件是一对一的关系,即使一个文件有多个路径名,最后也归结到同一个 inode 结构上。再说,一个文件中的内容是不能由其他文件共享的,在同一时间里,设备上的每一个记录块都只能属于至多一个文件(或者就是空闲),将载有同一个文件内容的缓冲区都放在其所属文件的 inode 结构中是很自然的事。因此,在 inode 数据结构中设置了一个指针 i_mapping,它指向一个 address_space 数据结构(通常这个数据结构就是 inode 结构中的 i_data ),缓冲区队列就在这个数据结构中。
不过,挂在缓冲区队列中的并不是记录块而是内存页面。也就是说,文件的内容并不是以记录块为单位,而是以页面为单位进行缓冲的。如果记录块的大小为 1K 字节,那么一个页面就相当于 4 个记录块。为什么要这样做呢?这是为了将文件内容的缓冲与文件的内存映射结合在一起。我们在第2章中提到过,一个进程可以通过系统调用 mmap() 将一个文件映射到它的用户空间。建立了这样的映射以后,就可以像访问内存一样地访问这个文件。如果将文件的内容以页面为单位缓冲,放在附属于该文件的 inode 结构的缓冲队列中,那么只要相应地设置进程的内存映射表,就可以很自然地将这些缓冲页面映射到进程的用户空间中。这样,在按常规的文件操作访问一个文件时,可以通过 read() 和 write() 系统调用目标文件的 inode 结构访问这些缓冲页面;而通过内存映射机制访问这个文件时,就可以经由页面映射表直接读写这些缓冲着的页面。当目标页面不在内存中时,常规的文件操作通过系统调用 read() 、write() 的底层将其从设备上读入,而通过内存映射机制访问这个文件时则由 “缺页异常” 的服务程序将目标页面从设备上读入。也就是说,同一个缓冲页面可以满足两方面的要求,文件系统的缓 冲机制和文件的内存映射机制巧妙地结合在一起了。明白了这个背景,对于上述的指针为什么叫 i_mapping,它所指向的数据结构为什么叫 address_space,就不会感到奇怪了。
可是,尽管以页面为单位的缓冲对于文件层确实是很好的选择,对于设备层则不那么合适了。对设备层而言,最自然的当然还是以记录块为单位的缓冲,因为设备的读/写都是以记录块为单位的。 不过,从磁盘上读/写时主要的时间都花在准备工作(如磁头组的定位)上,一旦准备好了以后读一个记录块与接连读几个记录块相差不大,而且每次只读写一个记录块倒反而是不经济的。所以每次读写若干连续的记录块、以页面为单位来缓冲也并不成为问题。另一方面,如果以页面为单位缓冲,而一个页面相当于若干个记录块,那么无论是对于缓冲页面还是对于记录块缓冲区,其控制和附加信息(如链接指针等)显然应该游离于该页面之外,这些信息不应该映射到进程的用户空间。这个问题也不难解决。读者不妨回顾一下,第2章中讲过的 page 数据结构就是这样。在 page 数据结构中有个指针 virtual 指向其所代表的页面,但是 page 结构本身则不在这个页面中。同样地,在 “缓冲区头部” 即 buffer_head 数据结构中有一个指针 b_data 指向缓冲区,而 buffer_head 结构本身则不在缓冲区中。所以,在设备层中只要保持一些 buffer_head 结构,让它们的 b_data 指针分别指向缓冲页面中的相应位置上就可以了。 以一个缓冲页面为例,在文件层它通过一个 page 数据结构挂入所属 inode 结构的缓冲页面队列,并且同时又可以通过各个进程的页面映射表映射到这些进程的内存空间;而在设备层则又通过若干(通常是四个,因为页面的大小为 4KB,而缓冲区的大小为 1KB) buffer_head 结构挂入其所在设备的缓冲区队列。这样,以页面为单位为文件内容建立缓冲真是 “一箭三雕” 。下页的示意图(图5.6)也许有助于读者对缓冲机制的理解。
在这样一个结构框架中,一旦所欲访问的内容已经在缓冲页面队列中,读文件的效率就很高了, 只要找到文件的 inode 结构( file 结构中有指针指向 dentry 结构,而 dentry 结构中有指针指向 inode 结构)就找到了缓冲页面队列,从队列中找到相应的页面就可以读出了。缓冲页面的 page 结构除链入附属于 inode 结构的缓冲页面队列外,同时也链入到一个杂凑表 page_hash_table 中的杂凑队列中(图中没有画出),所以寻找目标页面的操作也是效率很高的,并不需要在整个缓冲页面队列中线性搜索。
那么,写操作又如何呢?如前所述,一旦目标记录块已经存在于缓冲页面中,写操作只是把内容写到该缓冲页面中,所以从发动写操作的进程的角度来看速度也是很快的。至于改变了内容的缓冲页面,则由系统负责在 CPU 较为空闲时写入设备。为了这个目的,内核中设置了一个内核线程 kflushd。 平时这个线程总是在睡眠,有需要时(例如写操作以后)就将其唤醒,然后当 CPU 较为空闲时就会调度其运行,将已经改变了内容的缓冲页面写回设备上。这样,启动写操作的进程和 kflushd 就好像是一条流水作业线上的上下两个工位上的操作工,而改变缓冲页面的内容(写操作)与将改变了内容的缓冲页面写回设备上(称为 “同步” )则好像是上下两道工序。除这样的 “分工合作” 以外,每个打开了某个文件的进程还可以直接通过系统调用 sync() 强行将缓冲页面写回设备上。此外,缓冲页面的 page 结构还链入到一个 LRU 队列中,要是一个页面很久没有受到访问,内存空间又比较短缺,就可以把它释放而另作他用。
除通过缓冲来提高文件读/写的效率外,还有个措施是 “预读” 。就是说,如果一个进程发动了对某一个缓冲页面的读(或写)操作,并且该页面尚不在内存中而需要从设备上读入,那么就可以预测, 通常情况下它接下去可能会继续往下读写,因此不妨预先将后面几个页面也一起读进来。如前所述, 对于磁盘一类的 “块设备” ,读操作中最费时间的是磁头组定位,一旦到了位,从设备多读几个记录块并不相差多少时间。一般而言,对文件的访问有两种形式。一种是 “随机访问” ,其访问的位置并无规律;另一种是 “顺序访问” 。预读之所以可能提高效率就是因为大量的文件操作都是顺序访问。其实, 以页面(而不是记录块)为单位的缓冲本身就隐含着预读,因为通常一个页面包含着 4 个记录块,只要访问的位置不在其最后一个记录块中,就多少要预读几个记录块,只不过预读的量很小面已。
在早期的 Unix 系统中,由于当时的磁盘容量小,速度慢,内存也小,一般只预读一个记录块。而现在的预读,则动辍就是几十K字节,甚至上百K字节。当然,那也要视具体情况而定,所以在 include/linux/blkdev.h 中定义了 一个常数 MAX_READAHEAD,其定义为:
// include/linux/blkdev.h /* read-ahead in pages.. */ #define MAX_READAHEAD 31 #define MIN_READAHEAD 3
这里的数值 31 表示 31 个页面,即 124K 字节。从这里也可以看出,许多比较小的文件其实都是一次就全部预读入内存的。当然,这里说的是最大预读量,实际运行时还要看其他条件,未必真的预读那么多。
由于预读的提前量已经不再限于一个记录块,现在 file 结构中实际上要维持两个上下文了。一个就是由 “当前位置” f_pos 代表的真正的读/写上下文,而另一个则是预读的上下文。为此目的,在 file 结构中增设了 f_reada、f_ramax、f_raend、f_rawin 等几个字段。这几个字段的名称反映了它们的用途( ra 表示 “read ahead"),具体的含义在下面的代码中就可看到。
另一方面,预读虽然并不花费很多时间,但毕竟还是需要一点时间。当一个进程启动一次对文件内容的访问,而访问的目标又恰好不在内存中因而需要从设备上读入时,该进程只好暂时交出运行权, 进入睡眠中等待,称之为 “受阻” (blocked)。可是等待多久呢? 一旦本次访问的目标页面进入了内存, 等待中的进程就可以而且应该恢复运行了,而没有理由等待到所有预读的页面也全部进入内存。从设备上读/写一般都是通过 DMA 进行的,它固然需要一定的时间,但是并不需要 CPU 太多的干预,CPU 完全可以忙自己的事。所以,从设备上读入的操作可以分成两部分。第一部分是必须要等待的,在此期间启动本次操作的进程只好暂时停下来,这一部分操作是 “同步” 的。第二部分则无须等待,在此期间启动本次操作的进程可以继续运行,所以这一部分操作是异步的。至于写操作,则如前所述在大多数情况下是留给内核线程 kflushd 完成的,那当然是异步的。
读完了面这一大段的概述,现在可以开始读代码了。先看 sys_write() ,这是系统调用 write() 在内核中的实现,其代码在 fs/read_write.c 中:
1、sys_write
// fs/read_write.c asmlinkage ssize_t sys_write(unsigned int fd, const char * buf, size_t count) { ssize_t ret; struct file * file; ret = -EBADF; file = fget(fd); if (file) { if (file->f_mode & FMODE_WRITE) { struct inode *inode = file->f_dentry->d_inode; ret = locks_verify_area(FLOCK_VERIFY_WRITE, inode, file, file->f_pos, count); if (!ret) { ssize_t (*write)(struct file *, const char *, size_t, loff_t *); ret = -EINVAL; if (file->f_op && (write = file->f_op->write) != NULL) ret = write(file, buf, count, &file->f_pos); } } if (ret > 0) inode_dir_notify(file->f_dentry->d_parent->d_inode, DN_MODIFY); fput(file); } return ret; }
先检查该文件究竟是否加了锁,以及足否允许使用强制锁。如果确实加了锁,并且可能是强制锁, 就进一步通过 locks_mandatory_area() 检查所要求的区域是否也被强制锁住了。这个函数的代码在 fs/locks.c 中,我们在这里就不看了。它的算法是很简单的,无非就是扫描该文件的 inode 结构中的 i_flock 队列里面每一个 file_lock 数据结构并进行比对。从这里读者可以看出为什么强制锁并不总是比协调锁优越,因为对每次读/写操作它都要扫描这个队列进行比对,这显然会降低文件读写的速度。特别是如果每次读/写的长度都很小,那样花在强制锁检查上的开销所占比例就相当大了。
通过了对强制锁的检查以后,就是写操作本身了。可想而知,不同的文件系统有不同的写操作, 具体的文件系统通过其 file_operations 数据结构提供用于写操作的函数指针,就 Ext2 文件系统而言, 它有两个这样的数据结构,一个是 ext2_file_operations,另一个是 ext2_dir_operations,视操作的目标为文件或目录而选择其一,在打开该文件时 “安装” 在其 file 结构中。对于普通的文件,这个函数指针指向 generic_file_write() ,其代码在 mm/filemap.c 中。
Linux 内核源代码情景分析(四)(下):https://developer.aliyun.com/article/1598006