[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介紹
[2]. Linux字元設備驅動file_operations
[3]. 驅動程式操作的三個內核數據結構(file_operations、file、inode)