[apue] 一图读懂 unix 文件句柄及文件共享过程
与文件相关的一些概念
在开始上图之前,先说明几个和 unix 文件密切相关的术语,方便后续讨论使用
- 文件句柄 / 文件描述符 (file descriptor 或 FD):描述一个打开文件相关属性的类型;
- 文件描述符表 (file descriptor table 或 FDT):每个进程拥有一个 FDT,其中每个表项是一个 FD,使用 FDT 的下标表示各个 FD(从 0 开始的整数);
- 全局打开文件表 (open file table 或 OFT):系统只有一个 OFT,其中每个表项被 FD 所引用;
- i 节点 (inode):描述文件系统上的一个文件,例如 所有者/大小/设备/起始位置 等,它只包含和文件系统相关的属性;
- v 节点 (vnode):描述文件相关的操作,例如 读 / 写 / 移动相对偏移量 等,它只包含和文件系统无关的属性,用于统合各种不同类型的文件系统;
其中前三项只有文件被打开后才有相应的结构,而后两项只要文件存在就存在了,与文件是否打开没有关系。
文件相关概念之间的关系
它们之间的关系是怎样的呢,现在上图
图中左侧展示了两个进程,蓝色的为 ProcessA (PA),红色的为 ProcessB (PB),每个进程都有一个 FDT,其中包含若干个 FD,可以看到每个 FD 由两部分组成:
- pflag :在进程中的标志位,目前只有一个标志位 O_CLOEXEC,置位的话表示在进程执行 exec 函数族后自动关闭此文件句柄,默认是不关闭的;
- fileptr :指向 OFT 中相应的表项,来描述文件剩余的属性。
再观察 OFT 中表项的内容,可以看到它是由以下几部分组成:
- oflag :文件打开标志位,除 O_CLOEXEC 之外的标志位,如权限位 O_RDONLY / O_WRONLY / O_RDWR,创建位 O_CREAT / O_EXCL,追加位 O_APPEND,截断位 O_TRUNC,异步位 O_NONBLOCK 等均由这个字段指定。
- offset :当前文件偏移;
- vnode :指向该文件的 v 节点。
再观察文件属性相关的节点,它一般由下面两部分组成:
- vnode :文件的 v 节点信息,通常是一些操作的抽象,用于构建文件系统无关的 VFS;
- inode :文件的 i 节点信息。
对于 vnode,你可以理解成是一组函数指针,例如在 Linux 上,它分别定义了 inode 与文件的操作函数:
1 struct inode_operations { 2 struct dentry * (*lookup) (struct inode *,struct dentry *, struct nameidata *); 3 void * (*follow_link) (struct dentry *, struct nameidata *); 4 int (*permission) (struct inode *, int); 5 struct posix_acl * (*get_acl)(struct inode *, int); 6 int (*readlink) (struct dentry *, char __user *,int); 7 void (*put_link) (struct dentry *, struct nameidata *, void *); 8 int (*create) (struct inode *,struct dentry *,int, struct nameidata *); 9 int (*link) (struct dentry *,struct inode *,struct dentry *); 10 int (*unlink) (struct inode *,struct dentry *); 11 int (*symlink) (struct inode *,struct dentry *,const char *); 12 int (*mkdir) (struct inode *,struct dentry *,int); 13 int (*rmdir) (struct inode *,struct dentry *); 14 int (*mknod) (struct inode *,struct dentry *,int,dev_t); 15 int (*rename) (struct inode *, struct dentry *, struct inode *, struct dentry *); 16 void (*truncate) (struct inode *); 17 int (*setattr) (struct dentry *, struct iattr *); 18 int (*getattr) (struct vfsmount *mnt, struct dentry *, struct kstat *); 19 int (*setxattr) (struct dentry *, const char *,const void *,size_t,int); 20 ssize_t (*getxattr) (struct dentry *, const char *, void *, size_t); 21 ssize_t (*listxattr) (struct dentry *, char *, size_t); 22 int (*removexattr) (struct dentry *, const char *); 23 void (*truncate_range)(struct inode *, loff_t, loff_t); 24 int (*fiemap)(struct inode *, struct fiemap_extent_info *, u64 start, u64 len); 25 } ____cacheline_aligned; 26 27 struct file_operations { 28 struct module *owner;//拥有该结构的模块的指针,一般为THIS_MODULES 29 loff_t (*llseek) (struct file *, loff_t, int);//用来修改文件当前的读写位置 30 ssize_t (*read) (struct file *, char __user *, size_t, loff_t *);//从设备中同步读取数据 31 ssize_t (*write) (struct file *, const char __user *, size_t, loff_t *);//向设备发送数据 32 ssize_t (*aio_read) (struct kiocb *, const struct iovec *, unsigned long, loff_t);//初始化一个异步的读取操作 33 ssize_t (*aio_write) (struct kiocb *, const struct iovec *, unsigned long, loff_t);//初始化一个异步的写入操作 34 int (*readdir) (struct file *, void *, filldir_t);//仅用于读取目录,对于设备文件,该字段为NULL 35 unsigned int (*poll) (struct file *, struct poll_table_struct *); //轮询函数,判断目前是否可以进行非阻塞的读写或写入 36 int (*ioctl) (struct inode *, struct file *, unsigned int, unsigned long); //执行设备I/O控制命令 37 long (*unlocked_ioctl) (struct file *, unsigned int, unsigned long); //不使用BLK文件系统,将使用此种函数指针代替ioctl 38 long (*compat_ioctl) (struct file *, unsigned int, unsigned long); //在64位系统上,32位的ioctl调用将使用此函数指针代替 39 int (*mmap) (struct file *, struct vm_area_struct *); //用于请求将设备内存映射到进程地址空间 40 int (*open) (struct inode *, struct file *); //打开 41 int (*flush) (struct file *, fl_owner_t id); 42 int (*release) (struct inode *, struct file *); //关闭 43 int (*fsync) (struct file *, struct dentry *, int datasync); //刷新待处理的数据 44 int (*aio_fsync) (struct kiocb *, int datasync); //异步刷新待处理的数据 45 int (*fasync) (int, struct file *, int); //通知设备FASYNC标志发生变化 46 int (*lock) (struct file *, int, struct file_lock *); 47 ssize_t (*sendpage) (struct file *, struct page *, int, size_t, loff_t *, int); 48 unsigned long (*get_unmapped_area)(struct file *, unsigned long, unsigned long, unsigned long, unsigned long); 49 int (*check_flags)(int); 50 int (*flock) (struct file *, int, struct file_lock *); 51 ssize_t (*splice_write)(struct pipe_inode_info *, struct file *, loff_t *, size_t, unsigned int); 52 ssize_t (*splice_read)(struct file *, loff_t *, struct pipe_inode_info *, size_t, unsigned int); 53 int (*setlease)(struct file *, long, struct file_lock **); 54 };
ext2 上的 read 与 nfs 的 read 实现肯定不同,但是这里通过函数指针来屏蔽了这种差异。注意:linux 上并没有 vnode 的概念,它使用与文件系统相关的 inode 和文件系统无关的 inode,后者就是我们这里说的 vnode。
上面的大图是最普通的场景,就是两个进程都打开不同的文件,相互之间没有共享,下面我们分几个场景来看一下共享文件时这里的关系是如何变化的。
一个进程多次打开同一个文件
使用 open 多次打开同一个文件(文件路径可能相同,也可能不同,考虑链接的情况)的场景如上图,每个 FD 都有独立的 OFT 对应项,虽然最后都是在操作同一个文件,但一个 FD 的文件偏移改变,不影响另外一个 FD 的文件偏移;同理与文件相关的 pflag、oflag 也是如此。
多个进程打开同一个文件
多个进程打开同一个文件的场景如上图,除了跨进程外,其它与进程内并无任何不同。这里着重考察一个具体场景,就是两个进程同时打开文件进行追加(O_APPEND)写。假设 PA 写入一些数据完成后,它的 offset 会被更新,如果这个值大于 inode 中的文件 size,则更新 inode.size 到 offset 表示文件增长了;然后 PB 开始写入数据,由于指定了 O_APPEND 标志位,在写入前,系统会先将它的 OFT 表项中的 offset 更新为当前 inode.size,这样就可以得到 PA 写入后的文件末尾位置,接着在这个位置写入 PB 的数据,写入完成后的逻辑与 PA 相同,会更新 offset、inode.size 来表示文件的最新增长。由于更新 offset 与 inode.size 是在一个 api 完成的,所以这个操作完全可以被某种锁保护起来,从而实现原子性。相对的,如果没有指定 O_APPEND 选项,而使用 lseek (fd, 0, SEEK_END) + write (fd, buf, size) 的方式,由于这个操作需要使用两个 api 来完成,无法跨 api 加锁使得这样的操作没有原子性保证,而可能产生的竞争会导致一个进程写入的数据被另一个进程所覆盖,从而丢失数据。
进程内文件句柄 dup
进程内文件句柄 dup 的场景如上图,执行的是 fd2 = dup(fd1) 语句,复制成功后,fd2 与 fd1 都将指向同一个 OFT 表项。而 pflag 不在复制之列,也就是说,如果 fd1 指定了 O_CLOEXEC,则复制后的 fd2 默认是没有设置这个标志位的。除此之外,与文件相关的其它属性完全一样,包括 oflag 的各种标志位、offset 和文件 inode 信息。如果修改 fd1 的 oflag,例如 O_NONBLOCK,则 fd2 也将变成非阻塞的;如果读写 fd2,则 fd1 的 offset 也会随之改变……
进程 fork
进程 PA 打开一个文件后 fork 产生子进程 PB 的场景如上图,之前打开的句柄将指向同样的 OFT 表项,这样的表现有点类似跨进程文件句柄 dup,除了 fd0 分属 PA 与 PB 两个不同进程外,其它方面与上一个场景完全相同。所以如果希望通过 fork 来共享某些文件数据,则在 PA 写入数据后,PB 并不能读到父进程刚刚写入的数据,这是因为它的 fd0 对应的文件偏移也被更新了的缘故。
进程间传递文件句柄
说到进程间传递文件句柄,很多人是不是第一反应是直接传递 FD 值啊?那就理解错了。关于在进程间如何传递文件句柄,请参考我之前写过的一篇文章:记一次传递文件句柄引发的血案 ,简单说的话,可以引用 apue 书中的一句话来解释:“在技术上,发送进程实际上向接收进程传送一个指向一打开文件表项的指针,该指针被分配存放在接收进程的第一个可用描述符项中”,其实非常类似 fork 所产生的效果,不同之处在于两点:
- 发送与接收文件句柄的进程不一定是父子进程关系;
- 原进程与新进程中复制的文件句柄值一般不同(fork 结果一般是相同)
上面的图展示了这种细节的差异,PA 发送的文件句柄是 fd0,PB 由于已经打开了 fd0,所以接收后新的文件句柄是 fd1,其它方面与 fork 场景的结论完全一致。
结语
其实判断两个句柄是在哪个级别共享的方法很简单,就是改变一个句柄的文件偏移,观察另外一个句柄的文件偏移是否变化。如果变了,则是在 OFT 层面共享的;如果没变,则只是打开同一个文件而已。另外,有些东西会随着时代而更新,有些原理则不会变,以本文开头的这张结构图来说,自 UNIX 的早期版本(1978)以来就没有发生过根本性的变化,可见学知识还是要学原理性的东西,万变不离其宗。
参考
[1]. inode_operations介绍
本文来自博客园,作者:goodcitizen,转载请注明原文链接:https://www.cnblogs.com/goodcitizen/p/unix_file_descriptor_and_its_sharing_in_one_picture.html