[apue] 一图读懂 unix 文件句柄及文件共享过程

2022-08-19 10:35:00 浏览数 (1)

与文件相关的一些概念

在开始上图之前,先说明几个和 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 与文件的操作函数:

代码语言:javascript复制
 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介绍

[2]. Linux字符设备驱动file_operations

[3]. 驱动程序操作的三个内核数据结构(file_operations、file、inode)

0 人点赞