文件描述符 fd 究竟是什麼?
前情概要
通過上篇 Go 存儲基礎 — 文件 IO 的姿勢, 我們看到有兩種文件讀寫的方式,一種是系統調用的方式,操作的對象是一個整數 fd,另一種是 Go 標準庫自己封裝的標準庫 IO ,操作對象是 Go 封裝的 file
結構體,但其內部還是針對整數 fd 的操作。所以一切的本源是通過 fd 來操作的,那麼,這個 fd 究竟是什麼?就這個點我們深入剖析。
fd 是什麼?
fd
是 File descriptor
的縮寫,中文名叫做:文件描述符。文件描述符是一個非負整數,本質上是一個索引值(這句話非常重要)。
什麼時候拿到的 fd
?
當打開一個文件時,內核向進程返回一個文件描述符( open
系統調用得到 ),後續 read
、write
這個文件時,則只需要用這個文件描述符來標識該文件,將其作爲參數傳入 read
、write
。
fd 的值範圍是什麼?
在 POSIX 語義中,0,1,2 這三個 fd 值已經被賦予特殊含義,分別是標準輸入( STDIN_FILENO ),標準輸出( STDOUT_FILENO ),標準錯誤( STDERR_FILENO )。
文件描述符是有一個範圍的:0 ~ OPEN_MAX-1 ,最早期的 UNIX 系統中範圍很小,現在的主流系統單就這個值來說,變化範圍是幾乎不受限制的,只受到系統硬件配置和系統管理員配置的約束。
你可以通過 ulimit
命令查看當前系統的配置:
➜ ulimit -n
4864
如上,我係統上進程默認最多打開 4864 文件。
窺探 Linux 內核
fd
究竟是什麼?必須去 Linux 內核看一眼。
用戶使用系統調用 open
或者 creat
來打開或創建一個文件,用戶態得到的結果值就是 fd
,後續的 IO
操作全都是用 fd
來標識這個文件,可想而知內核做的操作並不簡單,我們接下來就是要揭開這層面紗。
task_struct
首先,我們知道進程的抽象是基於 struct task_struct
結構體,這是 Linux 裏面最複雜的結構體之一 ,成員字段非常多,我們今天不需要詳解這個結構體,我稍微簡化一下,只提取我們今天需要理解的字段如下:
struct task_struct {
// ...
/* Open file information: */
struct files_struct *files;
// ...
}
files;
這個字段就是今天的主角之一,files
是一個指針,指向一個爲 struct files_struct
的結構體。這個結構體就是用來管理該進程打開的所有文件的管理結構。
重點理解一個概念:
struct task_struct
是進程的抽象封裝,標識一個進程,在 Linux 裏面的進程各種抽象視角,都是這個結構體給到你的。當創建一個進程,其實也就是 new
一個 struct task_struct
出來;
files_struct
好,上面通過進程結構體引出了 struct files_struct
這個結構體。這個結構體管理某進程打開的所有文件的管理結構,這個結構體本身是比較簡單的:
/*
* Open file table structure
*/
struct files_struct {
// 讀相關字段
atomic_t count;
bool resize_in_progress;
wait_queue_head_t resize_wait;
// 打開的文件管理結構
struct fdtable __rcu *fdt;
struct fdtable fdtab;
// 寫相關字段
unsigned int next_fd;
unsigned long close_on_exec_init[1];
unsigned long open_fds_init[1];
unsigned long full_fds_bits_init[1];
struct file * fd_array[NR_OPEN_DEFAULT];
};
files_struct
這個結構體我們說是用來管理所有打開的文件的。怎麼管理?本質上就是數組管理的方式,所有打開的文件結構都在一個數組裏。這可能會讓你疑惑,數組在那裏?有兩個地方:
-
struct file * fd_array[NR_OPEN_DEFAULT]
是一個靜態數組,隨着files_struct
結構體分配出來的,在 64 位系統上,靜態數組大小爲 64; -
struct fdtable
也是個數組管理結構,只不過這個是一個動態數組,數組邊界是用字段描述的;
思考:爲什麼會有這種靜態 + 動態的方式?
性能和資源的權衡 !大部分進程只會打開少量的文件,所以靜態數組就夠了,這樣就不用另外分配內存。如果超過了靜態數組的閾值,那麼就動態擴展。
可以回憶下,這個是不是跟 inode
的直接索引,一級索引的優化思路類似。
fdtable
簡單介紹下 fdtable
結構體,這個結構體就是封裝用來管理 fd
的結構體,fd
的祕密就在這個裏面。簡化結構體如下:
struct fdtable {
unsigned int max_fds;
struct file __rcu **fd; /* current fd array */
};
注意到 fdtable.fd
這個字段是一個二級指針,什麼意思?
就是指向 fdtable.fd
是一個指針字段,指向的內存地址還是存儲指針的(元素指針類型爲 struct file *
)。換句話說,fdtable.fd
指向一個數組,數組元素爲指針(指針類型爲 struct file *
)。
其中 max_fds
指明數組邊界。
files_struct
小結
file_struct
本質上是用來管理所有打開的文件的,內部的核心是由一個靜態數組和動態數組管理結構實現。
還記得上面我們說文件描述符 fd
本質上就是索引嗎?這裏就把概念接上了,fd
就是這個數組的索引,也就是數組的槽位編號而已。 通過非負數 fd
就能拿到對應的 struct file
結構體的地址。
我們把概念串起來(注意,這裏爲了突出 fd
的本質, 把 fdtable
管理簡化掉):
fd
真的就是files
這個字段指向的指針數組的索引而已(僅此而已)。通過fd
能夠找到對應文件的struct file
結構體;
file
現在我們知道了 fd
本質是數組索引,數組元素是 struct file
結構體的指針。那麼這裏就引出了一個 struct file
的結構體。這個結構體又是用來幹什麼的呢?
這個結構體是用來表徵進程打開的文件的。簡化結構如下:
struct file {
// ...
struct path f_path;
struct inode *f_inode;
const struct file_operations *f_op;
atomic_long_t f_count;
unsigned int f_flags;
fmode_t f_mode;
struct mutex f_pos_lock;
loff_t f_pos;
struct fown_struct f_owner;
// ...
}
這個結構體非常重要,它標識一個進程打開的文件,下面解釋 IO 相關的幾個最重要的字段:
-
f_path
:標識文件名 -
f_inode
:非常重要的一個字段,inode
這個是 vfs 的inode
類型,是基於具體文件系統之上的抽象封裝; -
f_pos
:這個字段非常重要,偏移,對,就是當前文件偏移。還記得上一篇 IO 基礎裏也提過偏移對吧,指的就是這個,f_pos
在open
的時候會設置成默認值,seek
的時候可以更改,從而影響到write/read
的位置;
思考問題
思考問題一:files_struct
結構體只會屬於一個進程,那麼struct file
這個結構體呢,是隻會屬於某一個進程?還是可能被多個進程共享?
劃重點:struct file
是屬於系統級別的結構,換句話說是可以共享與多個不同的進程。
思考問題二:什麼時候會出現多個進程的 fd
指向同一個 file
結構體?
比如 fork
的時候,父進程打開了文件,後面 fork
出一個子進程。這種情況就會出現共享 file
的場景。如圖:
思考問題三:在同一個進程中,多個 fd
可能指向同一個 file 結構嗎?
可以。dup
函數就是做這個的。
#include <unistd.h>
int dup(int oldfd);
int dup2(int oldfd, int newfd);
inode
我們看到 struct file
結構體裏面有一個 inode 的指針,也就自然引出了 inode 的概念。這個指向的 inode 並沒有直接指向具體文件系統的 inode ,而是操作系統抽象出來的一層虛擬文件系統,叫做 VFS ( Virtual File System ),然後在 VFS 之下才是真正的文件系統,比如 ext4 之類的。
完整架構圖如下:
思考:爲什麼會有這一層封裝呢?
其實很容裏理解,就是解耦。如果讓 struct file
直接和 struct ext4_inode
這樣的文件系統對接,那麼會導致 struct file
的處理邏輯非常複雜,因爲每對接一個具體的文件系統,就要考慮一種實現。所以操作系統必須把底下文件系統屏蔽掉,對外提供統一的 inode
概念,對下定義好接口進行回調註冊。這樣讓 inode
的概念得以統一,Unix 一切皆文件的基礎就來源於此。
再來看一樣 VFS 的 inode
的結構:
struct inode {
// 文件相關的基本信息(權限,模式,uid,gid等)
umode_t i_mode;
unsigned short i_opflags;
kuid_t i_uid;
kgid_t i_gid;
unsigned int i_flags;
// 回調函數
const struct inode_operations *i_op;
struct super_block *i_sb;
struct address_space *i_mapping;
// 文件大小,atime,ctime,mtime等
loff_t i_size;
struct timespec64 i_atime;
struct timespec64 i_mtime;
struct timespec64 i_ctime;
// 回調函數
const struct file_operations *i_fop;
struct address_space i_data;
// 指向後端具體文件系統的特殊數據
void *i_private; /* fs or device private pointer */
};
其中包括了一些基本的文件信息,包括 uid,gid,大小,模式,類型,時間等等。
一個 vfs 和 後端具體文件系統的紐帶:i_private
字段。** 用來傳遞一些具體文件系統使用的數據結構。
至於 i_op
回調函數在構造 inode
的時候,就註冊成了後端的文件系統函數,比如 ext4 等等。
思考問題:通用的 VFS 層,定義了所有文件系統通用的 inode,叫做 vfs inode,而後端文件系統也有自身特殊的 inode 格式,該格式是在 vfs inode 之上進行擴展的,怎麼通過 vfs inode 怎麼得到具體文件系統的 inode 呢?
下面以 ext4 文件系統舉例(因爲所有的文件系統套路一樣),ext4 的 inode 類型是 struct ext4_inode_info
。
劃重點:方法其實很簡單,這個是屬於 c 語言一種常見的(也是特有)編程手法:強轉類型。vfs inode 出生就和 ext4_inode_info
結構體分配在一起的,直接通過 vfs inode 結構體的地址強轉類型就能得到 ext4_inode_info
結構體。
struct ext4_inode_info {
// ext4 inode 特色字段
// ...
// 重要!!!
struct inode vfs_inode;
};
舉個例子,現已知 inode 地址和 vfs_inode 字段的內偏移如下:
-
inode 的地址爲 0xa89be0;
-
ext4_inode_info
裏有個內嵌字段 vfs_inode,類型爲struct inode
,該字段在結構體內偏移爲 64 字節;
則可以得到:
ext4_inode_info
的地址爲
(struct ext4_inode_info *)(0xa89be0 - 64)
強轉方法使用了一個叫做 container_of
的宏,如下:
// 強轉函數
static inline struct ext4_inode_info *EXT4_I(struct inode *inode)
{
return container_of(inode, struct ext4_inode_info, vfs_inode);
}
// 強轉實際封裝
#define container_of(ptr, type, member) \
(type *)((char *)(ptr) - (char *) &((type *)0)->member)
#endif
所以,你懂了嗎?
分配 inode 的時候,其實分配的是 ext4_inode_info
結構體,包含了 vfs inode,然後對外給出去 vfs_inode 字段的地址即可。VFS 層拿 inode 的地址使用,底下文件系統強轉類型後,取外層的 inode 地址使用。
舉個 ext4 文件系統的例子:
static struct inode *ext4_alloc_inode(struct super_block *sb)
{
struct ext4_inode_info *ei;
// 內存分配,分配 ext4_inode_info 的地址
ei = kmem_cache_alloc(ext4_inode_cachep, GFP_NOFS);
// ext4_inode_info 結構體初始化
// 返回 vfs_inode 字段的地址
return &ei->vfs_inode;
}
vfs 拿到的就是這個 inode 地址。
劃重點:inode 的內存由後端文件系統分配,vfs inode 結構體內嵌在不同的文件系統的 inode 之中。不同的層次用不同的地址,ext4 文件系統用 ext4_inode_info
的結構體的地址,vfs 層用 ext4_inode_info.vfs_inode
字段的地址。
這種用法在 C 語言編程中很常見,算是 C 的特色了(仔細想想,這種用法和面向對象的多態的實現異曲同工)。
思考問題:怎麼理解 vfs inode
和 ext2_inode_info
,ext4_inode_info
等結構體的區別?
所有文件系統共性的東西抽象到 vfs inode
,不同文件系統差異的東西放在各自的 inode
結構體中。
小結梳理
當用戶打開一個文件,用戶只得到了一個 fd
句柄,但內核做了很多事情,梳理下來,我們得到幾個關鍵的數據結構,這幾個數據結構是有層次遞進關係的,我們簡單梳理下:
-
進程結構
task_struct
:表徵進程實體,每一個進程都和一個task_struct
結構體對應,其中task_struct.files
指向一個管理打開文件的結構體fiels_struct
; -
文件表項管理結構
files_struct
:用於管理進程打開的 open 文件列表,內部以數組的方式實現(靜態數組和動態數組結合)。返回給用戶的fd
就是這個數組的編號索引而已,索引元素爲file
結構;
files_struct
只從屬於某進程;
- 文件
file
結構:表徵一個打開的文件,內部包含關鍵的字段有:當前文件偏移,inode 結構地址;
- 該結構雖然由進程觸發創建,但是
file
結構可以在進程間共享;
- vfs
inode
結構體:文件file
結構指向 的是 vfs 的inode
,這個是操作系統抽象出來的一層,用於屏蔽後端各種各樣的文件系統的inode
差異;
- inode 這個具體進程無關,是文件系統級別的資源;
- ext4
inode
結構體(指代具體文件系統 inode ):後端文件系統的inode
結構,不同文件系統自定義的結構體,ext2 有ext2_inode_info
,ext4 有ext4_inode_info
,minix 有minix_inode_info
,這些結構裏都是內嵌了一個 vfsinode
結構體,原理相同;
完整的架構圖:
思考實驗
現在我們已經徹底瞭解 fd 這個所謂的非負整數代表的深層含義了,我們可以準備一些 IO 的思考舉一反三。
文件讀寫( IO )的時候會發生什麼?
-
在完成 write 操作後,在文件
file
中的當前文件偏移量會增加所寫入的字節數,如果這導致當前文件偏移量超處了當前文件長度,則會把 inode 的當前長度設置爲當前文件偏移量(也就是文件變長) -
O_APPEND
標誌打開一個文件,則相應的標識會被設置到文件file
狀態的標識中,每次對這種具有追加寫標識的文件執行write
操作的時候,file
的當前文件偏移量首先會被設置成inode
結構體中的文件長度,這就使得每次寫入的數據都追加到文件的當前尾端處(該操作對用戶態提供原子語義); -
若一個文件
seek
定位到文件當前的尾端,則file
中的當前文件偏移量設置成inode
的當前文件長度; -
seek
函數值修改file
中的當前文件偏移量,不進行任何I/O
操作; -
每個進程對有它自己的
file
,其中包含了當前文件偏移,當多個進程寫同一個文件的時候,由於一個文件 IO 最終只會是落到全局的一個inode
上,這種併發場景則可能產生用戶不可預期的結果;
總結
回到初心,理解 fd 的概念有什麼用?
一切 IO 的行爲到系統層面都是以 fd
的形式進行。無論是 C/C++,Go,Python,JAVA 都是一樣,任何語言都是一樣,這纔是最本源的東西,理解了 fd
關聯的一系列結構,你才能對 IO 遊刃有餘。
簡要的總結:
-
從姿勢上來講,用戶
open
文件得到一個非負數句柄fd
,之後針對該文件的 IO 操作都是基於這個fd
; -
文件描述符
fd
本質上來講就是數組索引,fd
等於 5 ,那對應數組的第 5 個元素而已,該數組是進程打開的所有文件的數組,數組元素類型爲struct file
; -
結構體
task_struct
對應一個抽象的進程,files_struct
是這個進程管理該進程打開的文件數組管理器。fd
則對應了這個數組的編號,每一個打開的文件用file
結構體表示,內含當前偏移等信息; -
file
結構體可以爲進程間共享,屬於系統級資源,同一個文件可能對應多個file
結構體,file
內部有個inode
指針,指向文件系統的inode
; -
inode
是文件系統級別的概念,只由文件系統管理維護,不因進程改變(file
是進程出發創建的,進程open
同一個文件會導致多個file
,指向同一個inode
);
**回顧一眼架構圖:
**
後記
內核把最複雜的活幹了,只暴露給您最簡單的一個非負整數 fd
。所以,絕大部分場景會用fd
就行,倒不用想太多。當然如果能再深入看一眼知其所以然是最好不過。本文分享是基礎準備篇,希望能給你帶來不一樣的 IO 視角。
本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源:https://mp.weixin.qq.com/s/m20WmbojpW_f-m3Thklwog