路徑名查詢¶
本文基於 lwn.net 上發表的三篇文章:
<https://lwn.net/Articles/649115/> Linux 中的路徑名查詢
<https://lwn.net/Articles/649729/> RCU-walk:Linux 中更快的路徑名查詢
<https://lwn.net/Articles/650786/> 符號連結之旅
由 Neil Brown 撰寫,Al Viro 和 Jon Corbet 協助。此後已更新,以反映核心中的更改,包括
每目錄並行名稱查詢。
openat2()解析限制標誌。
路徑名查詢簡介¶
路徑名查詢最明顯的方面,幾乎無需深入探索即可發現,就是其複雜性。它包含許多規則、特殊情況和實現替代方案,這些都可能讓粗心的讀者感到困惑。計算機科學早就熟悉這種複雜性,並擁有幫助管理它的工具。我們將廣泛使用的一種工具是“分而治之”。在分析的早期部分,我們將把符號連結(symlinks)分開處理——把它們留到最後一部分。在處理符號連結之前,我們還有另一個重要的劃分,它是基於 VFS 的鎖定方法,這將使我們能夠單獨回顧“REF-walk”和“RCU-walk”。但我們有些超前了。首先我們需要澄清一些重要的低階區別。
有兩種...¶
路徑名(有時也稱為“檔名”)用於標識檔案系統中的物件,大多數讀者對此都很熟悉。它們包含兩種元素:“斜槓”,即一個或多個“/”字元的序列;以及“元件”,即一個或多個非“/”字元的序列。這些形成兩種路徑。以斜槓開頭的路徑是“絕對路徑”,從檔案系統根目錄開始。其他路徑是“相對路徑”,從當前目錄開始,或者從透過檔案描述符指定給“*at()”系統呼叫(例如 openat())的某個其他位置開始。
描述第二種路徑以元件開頭很吸引人,但這並不總是準確的:路徑名可以既沒有斜槓也沒有元件,換句話說,它可以是空的。這在 POSIX 中通常是禁止的,但 Linux 中的一些“*at()”系統呼叫在給定 AT_EMPTY_PATH 標誌時允許這樣做。例如,如果你有一個指向可執行檔案的開啟檔案描述符,你可以透過呼叫 execveat() 並傳遞該檔案描述符、一個空路徑和 AT_EMPTY_PATH 標誌來執行它。
這些路徑可以分為兩部分:最終元件和其餘部分。“其餘部分”是簡單的部分。在所有情況下,它必須標識一個已存在的目錄,否則將報告 ENOENT 或 ENOTDIR 等錯誤。
最終元件則不那麼簡單。不僅不同的系統呼叫對其解釋大相徑庭(例如,有些建立它,有些不建立),而且它甚至可能不存在:空路徑名和僅由斜槓組成的路徑名都沒有最終元件。如果它確實存在,它可能是“.”或“..”,這些與其它元件的處理方式截然不同。
如果路徑名以斜槓結尾,例如“/tmp/foo/”,可能很想認為它有一個空的最終元件。在許多方面,這會產生正確的結果,但並非總是如此。特別是,mkdir() 和 rmdir() 都建立或刪除由最終元件命名的目錄,並且它們被要求能夠處理以“/”結尾的路徑名。根據 POSIX:
包含至少一個非斜槓字元並以一個或多個尾隨斜槓字元結尾的路徑名,除非尾隨斜槓字元之前的最後一個路徑名元件命名一個現有目錄,或者是在路徑名解析後立即為一個目錄建立的目錄項,否則將無法成功解析。
Linux 路徑遍歷程式碼(主要在 fs/namei.c 中)處理所有這些問題:將路徑分解為元件,將“其餘部分”與最終元件完全分開處理,並檢查尾隨斜槓是否未在不允許的地方使用。它還解決了併發訪問的重要問題。
當一個程序正在查詢路徑名時,另一個程序可能正在進行會影響該查詢的更改。一個相當極端的例子是,如果“a/b”被重新命名為“a/c/b”,而另一個程序正在查詢“a/b/..”,那麼該程序可能會成功解析到“a/c”。大多數競態條件要微妙得多,路徑名查詢的一大部分任務就是防止它們產生破壞性影響。許多可能的競態條件在“dcache”的上下文中表現得最清楚,理解它對於理解路徑名查詢至關重要。
不僅僅是快取¶
“dcache”快取每個檔案系統中關於名稱的資訊,以便快速查詢。每個條目(稱為“dentry”)包含三個重要欄位:元件名稱、指向父 dentry 的指標以及指向“inode”的指標,inode 包含該父目錄中具有給定名稱的物件的更多資訊。inode 指標可以為 NULL,表示該名稱在父目錄中不存在。雖然目錄的 dentry 中可能存在指向子目錄 dentry 的連結,但該連結不用於路徑名查詢,因此在此不予考慮。
dcache 除了加速查詢之外還有許多用途。其中一個特別相關的是它與掛載表緊密整合,掛載表記錄了哪個檔案系統掛載在哪裡。掛載表實際儲存的是哪個 dentry 掛載在哪個其他 dentry 之上。
考慮 dcache 時,我們又遇到了“兩種型別”的區別:檔案系統有兩種型別。
一些檔案系統確保 dcache 中的資訊始終完全準確(儘管不一定完整)。這使得 VFS 可以在不檢查檔案系統的情況下確定特定檔案是否存在,並意味著 VFS 可以保護檔案系統免受某些競態條件和其他問題的影響。這些通常是“本地”檔案系統,例如 ext3、XFS 和 Btrfs。
其他檔案系統不提供該保證,因為它們不能。這些通常是跨網路共享的檔案系統,無論是像 NFS 和 9P 這樣的遠端檔案系統,還是像 ocfs2 或 cephfs 這樣的叢集檔案系統。這些檔案系統允許 VFS 重新驗證快取的資訊,並且必須提供自己的保護以防止棘手的競態條件。VFS 可以透過 dentry 中設定的 DCACHE_OP_REVALIDATE 標誌來檢測這些檔案系統。
REF-walk:使用引用計數和自旋鎖的簡單併發管理¶
在對所有這些劃分進行了仔細分類之後,我們現在可以開始研究路徑遍歷的實際過程。特別是,我們將從路徑名中“其餘部分”的處理開始,並重點關注“REF-walk”併發管理方法。如果你忽略所有僅在設定了“LOOKUP_RCU”(表示使用 RCU-walk)時執行的地方,此程式碼可以在 link_path_walk() 函式中找到。
REF-walk 在鎖和引用計數方面相當“強硬”。雖然不像過去“大核心鎖”時代那樣強硬,但肯定不害怕在需要時獲取鎖。它使用了各種不同的併發控制機制。這裡假設讀者對各種原語有背景知識,或者可以從其他地方(例如 Meet the Lockers)瞭解它們。
REF-walk 使用的鎖定機制包括:
dentry->d_lockref¶
這使用 lockref 原語來提供自旋鎖和引用計數。這個原語的特別之處在於,概念上的“鎖定;增加引用計數;解鎖”序列通常可以透過單個原子記憶體操作完成。
持有 dentry 的引用可確保 dentry 不會突然被釋放並用於其他目的,從而使各種欄位中的值按預期執行。它還在一定程度上保護了對 inode 的 ->d_inode 引用。
dentry 及其 inode 之間的關聯是相當永久的。例如,當檔案重新命名時,dentry 和 inode 會一起移動到新位置。當檔案建立時,dentry 最初將是負的(即 d_inode 為 NULL),並將在建立過程中分配給新的 inode。
當檔案被刪除時,這可以在快取中透過兩種方式反映:一是將 d_inode 設定為 NULL,二是從用於在父目錄中查詢名稱的雜湊表(稍後描述)中移除它。如果 dentry 仍在被使用,則使用第二種選項,因為在檔案被刪除後繼續使用開啟的檔案是完全合法的,並且保留 dentry 有助於此。如果 dentry 沒有被其他方式使用(即 d_lockref 中的引用計數為 1),則只有此時才會將 d_inode 設定為 NULL。這樣做對於非常常見的情況效率更高。
因此,只要持有對 dentry 的計數引用,非 NULL 的 ->d_inode 值就不會被改變。
dentry->d_lock¶
d_lock 是上述 d_lockref 中自旋鎖的同義詞。就我們的目的而言,持有此鎖可防止 dentry 被重新命名或解除連結。特別是,它的父項(d_parent)和它的名稱(d_name)不能被改變,並且它不能從 dentry 雜湊表中移除。
在目錄中查詢名稱時,REF-walk 會對它在雜湊表中找到的每個候選 dentry 獲取 d_lock,然後檢查父項和名稱是否正確。因此,它在快取中搜索時不會鎖定父項;它只鎖定子項。
當查詢給定名稱的父項(以處理“..”)時,REF-walk 可以獲取 d_lock 以獲取對 d_parent 的穩定引用,但它首先嚐試一種更輕量級的方法。正如在 dget_parent() 中所見,如果可以獲取對父項的引用,並且隨後發現 d_parent 沒有改變,那麼實際上沒有必要對子項獲取鎖。
rename_lock¶
在給定目錄中查詢給定名稱涉及從兩個值(名稱和目錄的 dentry)計算雜湊值,訪問雜湊表中的相應槽位,並搜尋在那裡找到的連結串列。
當 dentry 被重新命名時,名稱和父 dentry 都可以更改,因此雜湊值也幾乎肯定會更改。這將把 dentry 移動到雜湊表中的不同鏈。如果檔名搜尋碰巧正在查詢以這種方式移動的 dentry,它最終可能會沿著錯誤的鏈繼續搜尋,從而錯過正確鏈的一部分。
名稱查詢過程(d_lookup())*不*嘗試阻止這種情況發生,而只是在發生時檢測到它。rename_lock 是一個 seqlock,每當有 dentry 被重新命名時,它都會更新。如果 d_lookup 發現在它掃描雜湊表中的一個鏈失敗時發生了重新命名,它會簡單地重試。
rename_lock 也用於檢測和防禦在解析“..”時(父目錄被移到根目錄之外,繞過 path_equal() 檢查)針對 LOOKUP_BENEATH 和 LOOKUP_IN_ROOT 的潛在攻擊。如果在查詢過程中 rename_lock 被更新,並且路徑遇到“..”,則表示發生了潛在攻擊,handle_dots() 將以 -EAGAIN 退出。
inode->i_rwsem¶
i_rwsem 是一個讀/寫訊號量,它將對特定目錄的所有更改序列化。這確保了例如 unlink() 和 rename() 不能同時發生。它還在檔案系統被要求查詢當前不在 dcache 中的名稱時,或者(可選地)在使用 readdir() 檢索目錄中的條目列表時,保持目錄的穩定。
這與 d_lock 具有互補作用:目錄上的 i_rwsem 保護該目錄中的所有名稱,而名稱上的 d_lock 僅保護目錄中的一個名稱。對 dcache 的大多數更改都會在相關目錄 inode 上持有 i_rwsem,並在更改發生時短暫地對一個或多個 dentry 獲取 d_lock。一個例外是當空閒 dentry 因記憶體壓力而從 dcache 中移除時。這使用 d_lock,但 i_rwsem 不起作用。
該訊號量以兩種不同的方式影響路徑名查詢。首先,它防止在目錄中查詢名稱時發生更改。walk_component() 首先使用 lookup_fast(),後者反過來檢查名稱是否在快取中,僅使用 d_lock 鎖定。如果未找到名稱,則 walk_component() 會回退到 lookup_slow(),後者在 i_rwsem 上獲取共享鎖,再次檢查名稱是否不在快取中,然後呼叫檔案系統以獲取確定性答案。無論結果如何,新的 dentry 都將被新增到快取中。
其次,當路徑名查詢到達最終元件時,有時需要在執行最後一次查詢之前對 i_rwsem 獲取排他鎖,以便實現所需的排他性。路徑查詢如何選擇獲取或不獲取 i_rwsem 是後續章節中討論的問題之一。
如果兩個執行緒同時嘗試查詢同一個名稱(一個尚未在 dcache 中的名稱),i_rwsem 上的共享鎖將無法阻止它們同時新增具有相同名稱的新 dentry。由於這會導致混淆,因此採用了額外一層的互鎖,基於一個輔助雜湊表(in_lookup_hashtable)和一個每 dentry 標誌位(DCACHE_PAR_LOOKUP)。
要在僅持有 i_rwsem 共享鎖的情況下向快取新增新的 dentry,執行緒必須呼叫 d_alloc_parallel()。這會分配一個 dentry,在其中儲存所需的名稱和父項,檢查主雜湊表或輔助雜湊表中是否已存在匹配的 dentry,如果不存在,則將新分配的 dentry 儲存在輔助雜湊表中,並設定 DCACHE_PAR_LOOKUP。
如果在主雜湊表中找到匹配的 dentry,則返回該 dentry,呼叫者便可知它在與另一個執行緒新增條目的競爭中失敗了。如果在任何快取中都沒有找到匹配的 dentry,則返回新分配的 dentry,呼叫者可以透過 DCACHE_PAR_LOOKUP 的存在來檢測這一點。在這種情況下,它知道它贏得了任何競爭,現在負責要求檔案系統執行查詢並找到匹配的 inode。當查詢完成時,它必須呼叫 d_lookup_done(),該函式會清除標誌並執行其他一些內部管理工作,包括從輔助雜湊表中移除 dentry——它通常已經新增到主雜湊表中了。請注意,一個 struct waitqueue_head 會傳遞給 d_alloc_parallel(),並且必須在 waitqueue_head 仍在作用域內時呼叫 d_lookup_done()。
如果在輔助雜湊表中找到匹配的 dentry,d_alloc_parallel() 還有更多工作要做。它首先等待 DCACHE_PAR_LOOKUP 被清除,使用傳遞給贏得競態的 d_alloc_parallel() 例項的 wait_queue,該 wait_queue 將由對 d_lookup_done() 的呼叫喚醒。然後它檢查 dentry 是否已新增到主雜湊表。如果已新增,則返回該 dentry,呼叫者只是看到它在任何競爭中失敗了。如果它尚未新增到主雜湊表,最可能的解釋是使用 d_splice_alias() 添加了其他 dentry。無論如何,d_alloc_parallel() 會從頭開始重複所有查詢,並且通常會從主雜湊表中返回一些東西。
mnt->mnt_count¶
mnt_count 是“mount”結構上的每 CPU 引用計數器。這裡的“每 CPU”意味著增加計數成本很低,因為它只使用 CPU 本地記憶體,但檢查計數是否為零成本很高,因為它需要檢查每個 CPU。獲取 mnt_count 引用可防止掛載結構因常規解除安裝操作而消失,但不會阻止“惰性”解除安裝。因此,持有 mnt_count 並不能確保掛載保留在名稱空間中,特別是不能穩定到掛載點 dentry 的連結。然而,它確實確保了 mount 資料結構保持一致,並且它提供了對掛載檔案系統根 dentry 的引用。因此,透過 ->mnt_count 的引用提供了對已掛載 dentry 的穩定引用,但不是對掛載點 dentry 的穩定引用。
mount_lock¶
mount_lock 是一個全域性的 seqlock,有點像 rename_lock。它可用於檢查是否對任何掛載點進行了任何更改。
在向下遍歷樹(遠離根)時,此鎖用於在跨越掛載點時檢查跨越是否安全。也就是說,讀取 seqlock 中的值,然後程式碼查詢當前目錄上掛載的掛載點(如果有),並增加 mnt_count。最後,將 mount_lock 中的值與舊值進行比較。如果沒有更改,則跨越是安全的。如果發生了更改,則 mnt_count 遞減,整個過程重新嘗試。
當透過遵循“..”連結向上遍歷樹(朝向根)時,需要更加小心。在這種情況下,seqlock(包含計數器和自旋鎖)被完全鎖定,以防止在向上移動時對任何掛載點進行任何更改。此鎖定是穩定到掛載點 dentry 的連結所必需的,而掛載本身的引用計數無法保證這一點。
mount_lock 也用於檢測和防禦在解析“..”時(父目錄被移到根目錄之外,繞過 path_equal() 檢查)針對 LOOKUP_BENEATH 和 LOOKUP_IN_ROOT 的潛在攻擊。如果在查詢過程中 mount_lock 被更新,並且路徑遇到“..”,則表示發生了潛在攻擊,handle_dots() 將以 -EAGAIN 退出。
RCU¶
最後,全域性(但極其輕量級的)RCU 讀鎖會不時地持有,以確保某些資料結構不會意外地被釋放。
特別是在掃描 dcache 雜湊表和掛載點雜湊表中的鏈時持有。
結合 struct nameidata¶
在路徑遍歷的整個過程中,當前狀態儲存在 struct nameidata 中,“namei”是傳統名稱——追溯到 Unix 第一版——用於將“名稱”轉換為“inode”的函式。struct nameidata 包含(除其他欄位外):
struct path path¶
一個 path 包含一個 struct vfsmount(嵌入在 struct mount 中)和一個 struct dentry。它們共同記錄遍歷的當前狀態。它們最初指向起點(當前工作目錄、根目錄或由檔案描述符標識的某個其他目錄),並在每一步中更新。透過 d_lockref 和 mnt_count 的引用始終被持有。
struct qstr last¶
這是一個字串及其長度(即 *不* 以 nul 終止),它是路徑名中的“下一個”元件。
int last_type¶
這是一個 LAST_NORM、LAST_ROOT、LAST_DOT 或 LAST_DOTDOT 之一。last 欄位僅在型別為 LAST_NORM 時有效。
struct path root¶
這用於儲存對檔案系統有效根的引用。通常不需要該引用,因此此欄位僅在首次使用時或請求非標準根時才分配。在 nameidata 中保留引用可確保在整個路徑遍歷過程中只有一個根生效,即使它與 chroot() 系統呼叫發生競態。
應該注意的是,在 LOOKUP_IN_ROOT 或 LOOKUP_BENEATH 的情況下,有效根成為傳遞給 openat2()(暴露這些 LOOKUP_ 標誌的函式)的目錄檔案描述符。
當滿足以下兩個條件之一時,需要根目錄:(1)路徑名或符號連結以“/”開頭;或(2)正在處理“..”元件,因為從根目錄的“..”必須始終停留在根目錄。使用的值通常是呼叫程序的當前根目錄。可以提供替代根,例如當 sysctl() 呼叫 file_open_root() 時,以及當 NFSv4 或 Btrfs 呼叫 mount_subtree() 時。在每種情況下,路徑名都在檔案系統的特定部分中查詢,並且不允許查詢逃離該子樹。它的工作方式有點像區域性 chroot()。
忽略符號連結的處理,我們現在可以描述“link_path_walk()”函式,它處理除最終元件之外的所有內容的查詢,如下所示:
給定一個路徑 (
name) 和一個 nameidata 結構 (nd),檢查當前目錄是否具有執行許可權,然後將name推進一個元件,同時更新last_type和last。如果這是最終元件,則返回,否則呼叫walk_component()並從頭開始重複。
walk_component() 甚至更簡單。如果元件是 LAST_DOTS,它會呼叫 handle_dots(),後者執行已描述的必要鎖定。如果它發現一個 LAST_NORM 元件,它首先呼叫“lookup_fast()”,該函式只在 dcache 中查詢,但如果檔案系統是那種型別的檔案系統,它會要求檔案系統重新驗證結果。如果這沒有得到好的結果,它會呼叫“lookup_slow()”,該函式獲取 i_rwsem 的共享鎖,重新檢查快取,然後要求檔案系統找到最終答案。
作為 walk_component() 的最後一步,step_into() 將直接從 walk_component() 或 handle_dots() 中呼叫。它呼叫 handle_mounts() 來檢查和處理掛載點,其中會建立一個新的 struct path,包含對新 dentry 的計數引用以及對新 vfsmount 的引用(僅當其與之前的 vfsmount 不同時才計數)。然後,如果存在符號連結,step_into() 呼叫 pick_link() 來處理它,否則它會在 struct nameidata 中安裝新的 struct path,並丟棄不需要的引用。
這種“手把手”的順序——在釋放對前一個 dentry 的引用之前獲取對新 dentry 的引用——可能看起來很明顯,但值得指出,以便我們能在“RCU-walk”版本中識別出它的類似物。
處理最終元件¶
link_path_walk() 只遍歷到設定 nd->last 和 nd->last_type 以引用路徑的最終元件。它不會在最後一次呼叫 walk_component()。處理該最終元件的任務留給呼叫者解決。這些呼叫者是 path_lookupat()、path_parentat() 和 path_openat(),它們各自處理不同系統呼叫的不同要求。
path_parentat() 顯然是最簡單的——它只是在 link_path_walk() 周圍包裝了一些內務處理,並向呼叫者返回父目錄和最終元件。呼叫者要麼旨在建立名稱(透過 filename_create()),要麼刪除或重新命名名稱(在這種情況下使用 user_path_parent())。他們將使用 i_rwsem 來排除其他更改,同時他們驗證並執行其操作。
path_lookupat() 也差不多簡單——它用於需要現有物件的情況,例如透過 stat() 或 chmod()。它本質上只是透過呼叫 lookup_last() 來對最終元件呼叫 walk_component()。path_lookupat() 只返回最終的 dentry。值得注意的是,當設定了標誌 LOOKUP_MOUNTPOINT 時,path_lookupat() 將在 nameidata 中取消設定 LOOKUP_JUMPED,以便在隨後的路徑遍歷中不會呼叫 d_weak_revalidate()。這在解除安裝不可訪問的檔案系統(例如由宕機的 NFS 伺服器提供的檔案系統)時很重要。
最後,path_openat() 用於 open() 系統呼叫;它在以“open_last_lookups()”開頭的輔助函式中包含了處理 O_CREAT(帶或不帶 O_EXCL)、最終“/”字元和尾隨符號連結等不同微妙之處所需的所有複雜性。我們將在本系列的最後一部分重新探討這一點,該部分重點關注這些符號連結。“open_last_lookups()”有時會(但並非總是)獲取 i_rwsem,具體取決於它發現的內容。
這些函式,或呼叫它們的函式,都需要警惕最終元件不是 LAST_NORM 的可能性。如果查詢的目標是建立某個東西,那麼 last_type 的任何值(除了 LAST_NORM)都將導致錯誤。例如,如果 path_parentat() 報告 LAST_DOTDOT,那麼呼叫者就不會嘗試建立該名稱。它們還透過測試 last.name[last.len] 來檢查尾隨斜槓。如果最終元件之外有任何字元,那它必須是一個尾隨斜槓。
重新驗證和自動掛載¶
除了符號連結之外,“REF-walk”過程還有兩個尚未涵蓋的部分。一個是處理陳舊的快取條目,另一個是自動掛載。
在需要的檔案系統上,查詢例程將呼叫 ->d_revalidate() dentry 方法以確保快取資訊是當前的。這通常會確認有效性或從伺服器更新一些詳細資訊。在某些情況下,它可能會發現路徑上游發生了變化,並且以前認為有效的東西實際上並非如此。發生這種情況時,整個路徑的查詢會被中止,並設定“LOOKUP_REVAL”標誌進行重試。這會強制重新驗證更徹底。我們將在下一篇文章中看到有關此重試過程的更多詳細資訊。
自動掛載點是檔案系統中的位置,在這些位置上嘗試查詢名稱可能會觸發該查詢的處理方式發生變化,特別是透過在該處掛載檔案系統。這些內容在 Linux 文件樹中的 autofs - 工作原理 中有更詳細的介紹,但此處需要特別指出一些與路徑查詢相關的注意事項。
Linux VFS 有一個“託管”dentry 的概念。關於這些 dentry,有三點可能有趣的地方,它們對應於 dentry->d_flags 中可能設定的三個不同標誌:
DCACHE_MANAGE_TRANSIT¶
如果設定了此標誌,則檔案系統已請求在處理任何可能的掛載點之前呼叫 d_manage() dentry 操作。這可以提供兩種特定服務:
它可以阻塞以避免競態條件。如果正在解除安裝一個自動掛載點,d_manage() 函式通常會等待該過程完成,然後才允許新的查詢繼續並可能觸發新的自動掛載。
它可以選擇性地只允許某些程序透過掛載點。當伺服器程序管理自動掛載時,它可能需要訪問目錄而不觸發正常的自動掛載處理。該伺服器程序可以向 autofs 檔案系統表明身份,然後 autofs 將透過返回 -EISDIR 來給予其透過 d_manage() 的特殊許可權。
DCACHE_MOUNTED¶
此標誌設定在每個被掛載的 dentry 上。由於 Linux 支援多個檔案系統名稱空間,因此 dentry 可能並未在此名稱空間中掛載,而是在其他名稱空間中掛載。因此,此標誌被視為一個提示,而非承諾。
如果此標誌已設定,並且 d_manage() 未返回 -EISDIR,則呼叫 lookup_mnt() 來檢查掛載雜湊表(尊重前面描述的 mount_lock),並可能返回新的 vfsmount 和新的 dentry(兩者均帶有計數引用)。
DCACHE_NEED_AUTOMOUNT¶
如果 d_manage() 允許我們走到這一步,並且 lookup_mnt() 沒有找到掛載點,那麼這個標誌會觸發呼叫 d_automount() dentry 操作。
鑑於 d_automount() 操作可以任意複雜,並且可能與伺服器程序等進行通訊,它最終應該要麼報告發生錯誤,要麼報告沒有要掛載的內容,或者提供一個包含新的 dentry 和 vfsmount 的更新的 struct path。
在後一種情況下,將呼叫 finish_automount() 以安全地將新掛載點安裝到掛載表中。
這裡沒有新的重要鎖定,並且重要的是,在此處理過程中不持有任何鎖(僅持有計數引用),因為這極有可能導致長時間延遲。這在下次我們研究對延遲特別敏感的 RCU-walk 時將變得更加重要。
RCU-walk - Linux 中更快的路徑名查詢¶
RCU-walk 是 Linux 中執行路徑名查詢的另一種演算法。它在許多方面與 REF-walk 相似,並且兩者共享相當多的程式碼。RCU-walk 的顯著區別在於它如何允許併發訪問的可能性。
我們注意到 REF-walk 很複雜,因為它有許多細節和特殊情況。RCU-walk 透過簡單地拒絕處理某些情況來降低這種複雜性——它反而回退到 REF-walk。RCU-walk 的困難來自另一個方向:不熟悉。依賴 RCU 時的鎖定規則與傳統鎖定截然不同,因此當我們遇到這些規則時,我們將花費更多時間。
明確的角色劃分¶
管理併發最簡單的方法是強制阻止任何其他執行緒更改給定執行緒正在檢視的資料結構。在沒有其他執行緒會想到更改資料並且許多不同執行緒希望同時讀取的情況下,這可能會非常昂貴。即使使用允許多個併發讀取器的鎖,簡單地更新當前讀取器數量的計數也可能帶來不必要的開銷。因此,當讀取其他程序未更改的共享資料結構時,目標是完全避免向記憶體寫入任何內容。不獲取鎖,不增加計數,不留下任何痕跡。
已經描述的 REF-walk 機制顯然不遵循這一原則,但它確實設計用於可能存在其他執行緒修改資料的情況。相比之下,RCU-walk 旨在處理常見情況:存在大量頻繁讀取器而只有偶爾寫入器。這在檔案系統樹的所有部分可能不常見,但在許多部分會是這樣。對於其他部分,重要的是 RCU-walk 可以快速回退到使用 REF-walk。
路徑名查詢總是以 RCU-walk 模式開始,但只有當它查詢的內容在快取中且穩定時才保持該模式。它輕快地沿著快取的檔案系統映像向下“舞蹈”,不留下任何痕跡,並仔細觀察其位置,以確保它不會出錯。如果它發現某些東西已更改或正在更改,或者某些東西不在快取中,那麼它會嘗試優雅地停止並切換到 REF-walk。
這種停止需要獲取對當前 vfsmount 和 dentry 的計數引用,並確保這些仍然有效——即使用 REF-walk 遍歷路徑也會找到相同的條目。這是 RCU-walk 必須保證的一個不變式。它只能做出 REF-walk 在同時遍歷樹時也能做出的決策,例如選擇下一步。如果優雅停止成功,路徑的其餘部分將由可靠但略顯遲緩的 REF-walk 處理。如果 RCU-walk 發現它無法優雅停止,它會簡單地放棄並使用 REF-walk 從頭開始重新啟動。
這種“嘗試 RCU-walk,如果失敗則嘗試 REF-walk”的模式可以清楚地在 filename_lookup()、filename_parentat()、do_filp_open() 和 do_file_open_root() 等函式中看到。這四個函式大致對應於我們前面遇到的三個 path_*() 函式,每個函式都呼叫 link_path_walk()。path_*() 函式會使用不同的模式標誌進行呼叫,直到找到一個有效模式。它們首先在設定了 LOOKUP_RCU 的情況下呼叫,以請求“RCU-walk”。如果失敗並返回錯誤 ECHILD,它們會再次在沒有特殊標誌的情況下呼叫,以請求“REF-walk”。如果其中任何一個報告錯誤 ESTALE,則會進行最後一次嘗試,設定 LOOKUP_REVAL(且不設定 LOOKUP_RCU),以確保快取中找到的條目被強制重新驗證——通常只有當檔案系統確定它們太舊而不可信時才重新驗證條目。
LOOKUP_RCU 嘗試可能會在內部放棄該標誌並切換到 REF-walk,但此後絕不會嘗試切換回 RCU-walk。阻礙 RCU-walk 的地方更有可能靠近葉子,因此切換回來幾乎不會有任何好處。
RCU 和 Seqlocks:快速輕量¶
RCU 對 RCU-walk 模式至關重要,這並不奇怪。rcu_read_lock() 在 RCU-walk 遍歷路徑的整個過程中都被持有。它提供的具體保證是,在持有鎖期間,關鍵資料結構——dentries、inodes、super_blocks 和 mounts——不會被釋放。它們可能以某種方式被解除連結或失效,但記憶體不會被重新利用,因此各個欄位中的值仍然有意義。這是 RCU 提供的唯一保證;其他一切都使用 seqlock 完成。
正如我們上面看到的,REF-walk 持有對當前 dentry 和當前 vfsmount 的計數引用,並且在獲取對“下一個”dentry 或 vfsmount 的引用之前不會釋放這些引用。它有時也會獲取 d_lock 自旋鎖。獲取這些引用和鎖是為了防止某些更改發生。RCU-walk 不得獲取這些引用或鎖,因此無法阻止此類更改。相反,它會檢查是否發生了更改,如果發生了更改,則中止或重試。
為了保持上述不變式(即 RCU-walk 只能做出 REF-walk 也能做出的決策),它必須在 REF-walk 持有引用的相同或附近位置進行檢查。因此,當 REF-walk 增加引用計數或獲取自旋鎖時,RCU-walk 會使用 read_seqcount_begin() 或類似函式取樣 seqlock 的狀態。當 REF-walk 減少計數或釋放鎖時,RCU-walk 會使用 read_seqcount_retry() 或類似函式檢查取樣的狀態是否仍然有效。
然而,seqlock 還有更多內容。如果 RCU-walk 訪問受 seqlock 保護的結構中兩個不同的欄位,或者兩次訪問同一個欄位,則無法先驗保證這些訪問之間存在任何一致性。當需要一致性時(通常是這樣),RCU-walk 必須複製一份,然後使用 read_seqcount_retry() 來驗證該副本。
read_seqcount_retry() 不僅檢查序列號,還強制設定記憶體屏障,以確保 CPU 或編譯器不會將 *呼叫之前* 的任何記憶體讀取指令延遲到 *呼叫之後*。一個簡單的例子可以在 slow_dentry_cmp() 中看到,對於不使用簡單位元組級名稱相等性的檔案系統,該函式會呼叫檔案系統來將名稱與 dentry 進行比較。長度和名稱指標被複制到區域性變數中,然後呼叫 read_seqcount_retry() 以確認兩者一致,然後才呼叫 ->d_compare()。當使用標準檔名比較時,則呼叫 dentry_cmp()。值得注意的是,它 *不* 使用 read_seqcount_retry(),而是有一個詳細的註釋解釋為什麼不需要一致性保證。隨後的 read_seqcount_retry() 將足以捕獲此時可能發生的任何問題。
在簡單回顧了 seqlock 之後,我們可以看看 RCU-walk 如何使用 seqlock 的更大圖景。
mount_lock 和 nd->m_seq¶
我們在 REF-walk 中已經遇到過 mount_lock seqlock,它用於確保安全地跨越掛載點。RCU-walk 也為此目的使用它,但用途遠不止於此。
RCU-walk 在向下遍歷樹時,不獲取每個 vfsmount 的計數引用,而是在遍歷開始時取樣 mount_lock 的狀態,並將此初始序列號儲存在 struct nameidata 的 m_seq 欄位中。這一個鎖和一個序列號用於驗證對所有 vfsmounts 的所有訪問以及所有掛載點交叉。由於掛載表的更改相對較少,因此在發生任何“掛載”或“解除安裝”時,回退到 REF-walk 是合理的。
在 RCU-walk 序列結束時,m_seq 會被檢查(使用 read_seqretry()),無論是在路徑的其餘部分切換到 REF-walk 還是到達路徑末尾。在向下遍歷掛載點(在 __follow_mount_rcu() 中)或向上遍歷(在 follow_dotdot_rcu() 中)時也會檢查它。如果發現它已更改,則整個 RCU-walk 序列將被中止,並由 REF-walk 再次處理路徑。
如果 RCU-walk 發現 mount_lock 沒有改變,那麼它可以確定,如果 REF-walk 對每個 vfsmount 都取得了計數引用,結果也會相同。這確保了不變式成立,至少對於 vfsmount 結構而言。
dentry->d_seq 和 nd->seq¶
RCU-walk 不在 d_reflock 上獲取計數或鎖,而是取樣每個 dentry 的 d_seq seqlock,並將序列號儲存在 nameidata 結構的 seq 欄位中,因此 nd->seq 應該始終是 nd->dentry 的當前序列號。在複製之後、使用 dentry 的名稱、父級或 inode 之前,需要重新驗證此數字。
名稱的處理我們已經看過,而父項只在 follow_dotdot_rcu() 中訪問,該函式相當簡單地遵循所需的模式,儘管它處理了三種不同的情況。
當不在掛載點時,會遵循 d_parent 並收集其 d_seq。當我們在掛載點時,我們會改為遵循 mnt->mnt_mountpoint 連結以獲取新的 dentry 並收集其 d_seq。然後,在最終找到要遵循的 d_parent 後,我們必須檢查是否已到達掛載點,如果到達,則必須找到該掛載點並遵循 mnt->mnt_root 連結。這可能意味著一種有些不尋常但肯定可能發生的情況,即路徑查詢的起點位於檔案系統的某個已掛載部分,因此從根目錄不可見。
儲存在 ->d_inode 中的 inode 指標更有趣一些。inode 總是需要至少訪問兩次,一次是確定它是否為 NULL,另一次是驗證訪問許可權。符號連結處理也需要一個經過驗證的 inode 指標。與其在每次訪問時都重新驗證,不如在第一次訪問時複製一份,並將其儲存在 nameidata 的 inode 欄位中,從那裡可以安全訪問而無需進一步驗證。
lookup_fast() 是 RCU 模式下唯一使用的查詢例程,因為 lookup_slow() 太慢且需要鎖。正是在 lookup_fast() 中,我們發現了對當前 dentry 重要的“手遞手”跟蹤。
當前的 dentry 和當前的 seq 數字被傳遞給 __d_lookup_rcu(),該函式在成功時返回一個新的 dentry 和一個新的 seq 數字。lookup_fast() 隨後複製 inode 指標並重新驗證新的 seq 數字。然後它最後一次使用舊的 seq 數字驗證舊的 dentry,然後才繼續。獲取新 dentry 的 seq 數字然後檢查舊 dentry 的 seq 數字的這個過程,與我們在 REF-walk 中看到的在釋放舊 dentry 的計數引用之前獲取新 dentry 的計數引用這一過程完全相同。
無 inode->i_rwsem 甚至無 rename_lock¶
訊號量是一種相當重量級的鎖,只有在允許休眠時才能獲取。由於 rcu_read_lock() 禁止休眠,因此 inode->i_rwsem 在 RCU-walk 中不起作用。如果其他執行緒獲取 i_rwsem 並以 RCU-walk 需要注意的方式修改目錄,結果將是 RCU-walk 未能找到它正在尋找的 dentry,或者它會找到一個 read_seqretry() 無法驗證的 dentry。在這兩種情況下,它都會降級到 REF-walk 模式,該模式可以獲取所需的任何鎖。
儘管 rename_lock 可以被 RCU-walk 使用,因為它不需要任何休眠,但 RCU-walk 不會費心使用它。REF-walk 使用 rename_lock 來防止在搜尋 dcache 中的雜湊鏈時發生變化。這可能導致未能找到實際存在的內容。當 RCU-walk 未能在 dentry 快取中找到某些內容時,無論它是否真的存在,它都會降級到 REF-walk 模式,並使用適當的鎖重新嘗試。這巧妙地處理了所有情況,因此新增額外的 rename_lock 檢查不會帶來顯著價值。
unlazy walk() 和 complete_walk()¶
“降級到 REF-walk”通常涉及呼叫 unlazy_walk(),之所以這樣命名是因為“RCU-walk”有時也被稱為“lazy walk”。當沿著路徑到達當前的 vfsmount/dentry 對似乎已成功進行,但下一步出現問題時,會呼叫 unlazy_walk()。這可能發生在以下情況:dcache 中找不到下一個名稱;在持有 rcu_read_lock()(禁止休眠)時無法完成許可權檢查或名稱重新驗證;發現自動掛載點;或者涉及符號連結的幾種情況。它也會在查詢到達最終元件或路徑的末尾時(取決於使用的特定查詢方式)從 complete_walk() 呼叫。
不觸發呼叫 unlazy_walk() 而退出 RCU-walk 的其他原因包括髮現無法立即處理的不一致性,例如 mount_lock 或其中一個 d_seq 序列鎖報告更改。在這些情況下,相關函式將返回 -ECHILD,這將向上滲透直到觸發從頂部使用 REF-walk 的新嘗試。
對於那些 unlazy_walk() 是一個選項的情況,它本質上是對其持有的每個指標(vfsmount、dentry 以及可能的某些符號連結)進行引用,然後驗證相關序列鎖是否未被更改。如果發生了更改,它也會以 -ECHILD 中止,否則到 REF-walk 的轉換成功,查詢過程繼續。
對這些指標進行引用並不像簡單地遞增計數器那麼簡單。如果你已經有了一個引用(通常透過另一個物件間接獲得),它可以用作第二個引用,但如果你根本沒有計數引用,它就不夠了。對於 dentry->d_lockref,除非它被明確標記為“dead”(這涉及到將計數器設定為 -128),否則遞增引用計數器以獲取引用是安全的。lockref_get_not_dead() 實現了這一點。
對於 mnt->mnt_count,只要隨後使用 mount_lock 來驗證引用,獲取引用就是安全的。如果該驗證失敗,則可能不安全以呼叫 mnt_put() 的標準方式直接刪除該引用——解除安裝可能已經進展得太遠。因此,legitimize_mnt() 中的程式碼,當它發現所獲得的引用可能不安全時,會檢查 MNT_SYNC_UMOUNT 標誌以確定簡單的 mnt_put() 是否正確,或者它是否應該只是遞減計數並假裝這一切從未發生過。
檔案系統中的注意事項¶
RCU-walk 幾乎完全依賴於快取資訊,並且通常根本不會呼叫檔案系統。然而,除了前面提到的元件名稱比較之外,檔案系統可能被包含在 RCU-walk 中的兩個地方,並且它必須知道要小心。
如果檔案系統具有非標準的許可權檢查要求——例如可能需要與伺服器檢查的網路檔案系統——則 i_op->permission 介面可能會在 RCU-walk 期間被呼叫。在這種情況下,會傳遞一個額外的“MAY_NOT_BLOCK”標誌,以便它知道不能休眠,而是如果不能及時完成則返回 -ECHILD。i_op->permission 獲得了 inode 指標,而不是 dentry,因此它不需要擔心進一步的一致性檢查。但是,如果它訪問任何其他檔案系統資料結構,它必須確保它們在僅持有 rcu_read_lock() 的情況下可以安全訪問。這通常意味著它們必須使用 kfree_rcu() 或類似方式釋放。
如果檔案系統可能需要重新驗證 dcache 條目,那麼 d_op->d_revalidate 也可能在 RCU-walk 中被呼叫。這個介面確實傳遞了 dentry,但無法訪問 inode 或來自 nameidata 的 seq 號,因此在訪問 dentry 中的欄位時需要格外小心。這種“格外小心”通常涉及使用 READ_ONCE() 來訪問欄位,並在使用之前驗證結果是否非 NULL。這種模式可以在 nfs_lookup_revalidate() 中看到。
一對模式¶
在 REF-walk 和 RCU-walk 的細節中,以及在整體上,有幾個相關的模式值得注意。
第一個是“快速嘗試並檢查,如果失敗則慢速嘗試”。我們可以在高層方法中看到這一點,即首先嚐試 RCU-walk,然後嘗試 REF-walk,以及在 unlazy_walk() 用於將剩餘路徑切換到 REF-walk 的地方。我們之前在 dget_parent() 遵循“..”連結時也看到了這一點。它嘗試一種快速方式獲取引用,然後在需要時回退到獲取鎖。
第二種模式是“快速嘗試並檢查,如果失敗則重複嘗試”。這在 REF-walk 中使用 rename_lock 和 mount_lock 時可見。RCU-walk 不使用這種模式——如果出現任何問題,中止並嘗試更穩健的方法要安全得多。
這裡的重點是“快速嘗試並檢查”。它可能應該是“快速且仔細地嘗試,然後檢查”。需要檢查這一事實提醒我們系統是動態的,只有有限數量的東西是完全安全的。整個過程中最常見的錯誤原因是假設某物是安全的,而實際上它並非如此。有時有必要仔細考慮究竟是什麼保證了每次訪問的安全性。
符號連結之旅¶
我們將考察幾個基本問題以理解符號連結的處理:符號連結棧,以及快取生命週期,這將幫助我們理解符號連結的整體遞迴處理,並引出最終元件所需的特殊處理。然後,考察訪問時間更新和查詢控制的各種標誌的總結將結束本篇故事。
符號連結棧¶
在最終元件之前,路徑中只有兩種檔案系統物件可以有效出現:目錄和符號連結。處理目錄相當簡單:新目錄簡單地成為解釋路徑中下一個元件的起點。處理符號連結需要更多的工作。
從概念上講,可以透過編輯路徑來處理符號連結。如果一個元件名稱引用了一個符號連結,那麼該元件將被連結的主體替換,如果該主體以“/”開頭,則路徑中所有前面的部分都將被丟棄。這是“readlink -f”命令所做的,儘管它也編輯掉了“.”和“..”元件。
在查詢路徑時,直接編輯路徑字串並非必要,丟棄早期元件也毫無意義,因為它們無論如何都不會被檢視。跟蹤所有剩餘元件很重要,但它們當然可以分開儲存;無需將它們連線起來。由於一個符號連結很容易引用另一個符號連結,而另一個符號連結又可以引用第三個,我們可能需要保留多個路徑的剩餘元件,每個路徑在前面的路徑完成後進行處理。這些路徑殘餘保留在一個有限大小的棧上。
對單個路徑查詢中可以出現的符號連結數量施加限制有兩個原因。最明顯的是避免迴圈。如果一個符號連結直接或透過中間實體引用自身,那麼遵循該符號連結將永遠無法成功完成——必須返回錯誤 ELOOP。無需施加限制也可以檢測迴圈,但限制是最簡單的解決方案,並且考慮到限制的第二個原因,這已經足夠了。
第二個原因最近由 Linus 闡述:Linus 最近概述了
因為它也關乎延遲和拒絕服務問題。我們需要很好地應對真正的迴圈,也要應對“非常深”的非迴圈。這與記憶體使用無關,而是使用者觸發不合理的 CPU 資源。
Linux 對任何路徑名長度施加了限制:PATH_MAX,即 4096。這個限制有幾個原因;不讓核心在一個路徑上花費太多時間是其中之一。使用符號連結可以有效地生成更長的路徑,因此出於同樣的原因需要某種限制。Linux 對任何路徑查詢中的符號連結數量最多限制為 40 (MAXSYMLINKS)。它之前還對最大遞迴深度施加了八個的限制,但在實現單獨的棧時,該限制提高到 40,因此現在只有一個限制。
我們在前一篇文章中遇到的 nameidata 結構包含一個小型棧,可用於儲存最多兩個符號連結的剩餘部分。在許多情況下,這將足夠。如果不夠,則會分配一個單獨的棧,其中有足夠的空間容納 40 個符號連結。路徑名查詢永遠不會超過該棧,因為一旦檢測到第 40 個符號連結,就會返回錯誤。
看起來好像這個棧只需要儲存名稱殘餘,但我們需要更多。要理解這一點,我們需要繼續討論快取生命週期。
快取符號連結的儲存和生命週期¶
與其他檔案系統資源(如 inode 和目錄條目)一樣,Linux 會快取符號連結,以避免重複昂貴的外部儲存訪問。對於 RCU-walk 來說,能夠找到並臨時持有這些快取條目尤為重要,這樣它就不需要降級到 REF-walk。
雖然每個檔案系統都可以自由選擇,但符號連結通常儲存在兩個位置之一。短符號連結通常直接儲存在 inode 中。當檔案系統分配 struct inode 時,它通常會分配額外的空間來儲存私有資料(核心中常見的面向物件設計模式)。這有時會包括符號連結的空間。另一個常見位置是頁快取,它通常儲存檔案內容。符號連結中的路徑名可以被視為該符號連結的內容,並且可以像檔案內容一樣輕鬆地儲存在頁快取中。
當以上兩種方式都不適用時,最可能的情況是檔案系統會分配一些臨時記憶體,並在需要時將符號連結內容複製或構建到該記憶體中。
當符號連結儲存在 inode 中時,它與 inode 具有相同的生命週期,而 inode 本身受到 RCU 或 dentry 上的計數引用保護。這意味著路徑名查詢用於安全訪問 dcache 和 icache(inode 快取)的機制足以安全訪問某些快取的符號連結。在這些情況下,inode 中的 i_link 指標被設定為指向符號連結的儲存位置,並且可以在需要時直接訪問。
當符號連結儲存在頁快取或其他地方時,情況就不那麼簡單了。對 dentry 甚至 inode 的引用不意味著對該 inode 的快取頁有任何引用,即使 rcu_read_lock() 也不足以確保頁不會消失。因此,對於這些符號連結,路徑名查詢程式碼需要請求檔案系統提供一個穩定的引用,並且重要的是,在完成後需要釋放該引用。
即使在 RCU-walk 模式下,通常也可以對快取頁進行引用。它確實需要對記憶體進行更改,這最好避免,但這不一定是很大的成本,而且比完全退出 RCU-walk 模式要好。即使是那些分配空間來複制符號連結的檔案系統,也可以使用 GFP_ATOMIC 來成功分配記憶體,而無需退出 RCU-walk。如果檔案系統無法在 RCU-walk 模式下成功獲取引用,它必須返回 -ECHILD,然後呼叫 unlazy_walk() 返回到 REF-walk 模式,在該模式下檔案系統允許休眠。
所有這些發生的地方是 i_op->get_link() inode 方法。這在 RCU-walk 和 REF-walk 中都會被呼叫。在 RCU-walk 中,dentry* 引數為 NULL,->get_link() 可以返回 -ECHILD 以退出 RCU-walk。就像我們之前看過的 i_op->permission() 方法一樣,->get_link() 需要小心,確保它引用的所有資料結構在不持有計數引用而只持有 RCU 鎖的情況下可以安全訪問。一個回撥 struct delayed_called 將被傳遞給 ->get_link():檔案系統可以透過 set_delayed_call() 設定自己的 put_link 函式和引數。稍後,當 VFS 想要放置連結時,它將呼叫 do_delayed_call() 以呼叫帶有引數的回撥函式。
為了在 walk 完成時(無論是在 RCU-walk 還是 REF-walk 中)釋放對每個符號連結的引用,符號連結棧需要包含,除了路徑殘餘之外:
struct path提供對先前路徑的引用const char *提供對先前名稱的引用seq允許路徑安全地從 RCU-walk 切換到 REF-walkstruct delayed_call用於稍後呼叫。
這意味著符號連結棧中的每個條目需要儲存五個指標和一個整數,而不是僅僅一個指標(路徑殘餘)。在 64 位系統上,每個條目大約 40 位元組;40 個條目加起來總共 1600 位元組,不到半頁。所以看起來很多,但絕非過度。
請注意,在給定的棧幀中,路徑殘餘(name)不是其他欄位所引用的符號連結的一部分。它是該符號連結完全解析後要遵循的殘餘。
跟隨符號連結¶
link_path_walk() 中的主迴圈無縫地遍歷路徑中的所有元件以及所有非最終符號連結。隨著符號連結的處理,name 指標被調整為指向新的符號連結,或者從棧中恢復,因此大部分迴圈不需要注意。將這個 name 變數進出棧非常簡單;壓入和彈出引用則稍微複雜一些。
當找到符號連結時,walk_component() 透過 step_into() 呼叫 pick_link(),後者從檔案系統返回連結。如果操作成功,舊路徑 name 被放置到棧上,新值在一段時間內用作 name。當找到路徑的末尾(即 *name 為 '\0')時,舊的 name 從棧中恢復,路徑遍歷繼續。
壓入和彈出引用指標(inode、cookie 等)更加複雜,部分原因是希望處理尾遞迴。當符號連結的最後一個元件本身指向一個符號連結時,我們希望在壓入剛找到的符號連結之前,從棧中彈出剛完成的符號連結,以避免留下空的路徑殘餘,這隻會妨礙處理。
最方便的做法是在 walk_component() 中,當符號連結被發現時立即將新的符號連結引用壓入棧中;walk_component() 也是在遍歷最後一個元件時需要檢視舊符號連結的最後一段程式碼。因此,walk_component() 釋放舊符號連結並在壓入新符號連結的引用資訊之前彈出引用,這非常方便。它受三個標誌的引導:WALK_NOFOLLOW 禁止它在找到符號連結時跟隨;WALK_MORE 表示現在釋放當前符號連結還為時過早;以及 WALK_TRAILING 表示它處於查詢的最終元件,因此我們將檢查使用者空間標誌 LOOKUP_FOLLOW 以決定當它是符號連結時是否跟隨它,並呼叫 may_follow_link() 來檢查我們是否有許可權跟隨它。
沒有最終元件的符號連結¶
有幾對特殊情況的符號連結值得進一步解釋。它們都導致在 nameidata 中設定一個新的 struct path(帶有 mount 和 dentry),並導致 pick_link() 返回 NULL。
更明顯的情況是到“/”的符號連結。所有以“/”開頭的符號連結都在 pick_link() 中被檢測到,它會重置 nameidata 以指向有效的根檔案系統。如果符號連結只包含“/”,那麼就沒有更多可做的了,根本沒有元件,因此返回 NULL 以指示可以釋放符號連結並丟棄棧幀。
另一種情況涉及 /proc 中看起來像符號連結但實際上不是的東西(因此通常被稱為“魔術連結”)。
$ ls -l /proc/self/fd/1
lrwx------ 1 neilb neilb 64 Jun 13 10:19 /proc/self/fd/1 -> /dev/pts/4
任何程序中的每個開啟檔案描述符在 /proc 中都表示為一個看起來像符號連結的物件。它實際上是對目標檔案的引用,而不僅僅是它的名稱。當你 readlink 這些物件時,你會得到一個可能指向同一檔案的名稱——除非它已被取消連結或被掛載覆蓋。walk_component() 跟蹤其中一個時,“procfs”中的 ->get_link() 方法不返回字串名稱,而是呼叫 nd_jump_link(),它就地更新 nameidata 以指向該目標。->get_link() 然後返回 NULL。同樣,沒有最終元件,pick_link() 返回 NULL。
在最終元件中跟隨符號連結¶
所有這些都導致 link_path_walk() 遍歷每個元件,並跟隨它找到的所有符號連結,直到到達最終元件。這只是在 nameidata 的 last 欄位中返回。對於某些呼叫者來說,這正是他們所需要的;他們希望如果 last 名稱不存在則建立它,如果存在則報錯。其他呼叫者希望如果找到符號連結則跟隨它,並可能對該符號連結的最後一個元件進行特殊處理,而不僅僅是原始檔名的最後一個元件。這些呼叫者可能需要一遍又一遍地在連續的符號連結上呼叫 link_path_walk(),直到找到一個不指向另一個符號連結的符號連結。
這種情況由 link_path_walk() 的相關呼叫者(例如 path_lookupat()、path_openat())透過一個迴圈來處理,該迴圈呼叫 link_path_walk(),然後透過呼叫 open_last_lookups() 或 lookup_last() 來處理最終元件。如果需要跟隨一個符號連結,open_last_lookups() 或 lookup_last() 會正確設定,並返回路徑,以便迴圈重複,再次呼叫 link_path_walk()。如果每個符號連結的最後一個元件都是另一個符號連結,這可能會迴圈多達 40 次。
在檢查最終元件的各種函式中,open_last_lookups() 是最有趣的,因為它與 do_open() 協同工作以開啟檔案。open_last_lookups() 的一部分在持有 i_rwsem 的情況下執行,這部分在一個單獨的函式 lookup_open() 中。
完全解釋 open_last_lookups() 和 do_open() 超出了本文的範圍,但一些要點應該能幫助那些有興趣探索程式碼的人。
do_open() 不僅僅是查詢目標檔案,它在 open_last_lookup() 之後用於開啟該檔案。如果檔案在 dcache 中找到,則使用
vfs_open()進行此操作。如果未找到,則lookup_open()將呼叫atomic_open()(如果檔案系統提供)以將最終查詢與開啟操作結合起來,或者直接執行單獨的i_op->lookup()和i_op->create()步驟。在後一種情況下,這個新找到或建立的檔案的實際“開啟”將由vfs_open()執行,就像在 dcache 中找到名稱一樣。如果快取資訊不夠最新,
vfs_open()可能會失敗並返回-EOPENSTALE。如果它在 RCU-walk 中,將返回-ECHILD,否則返回-ESTALE。當返回-ESTALE時,呼叫者可以設定LOOKUP_REVAL標誌並重試。與其它建立系統呼叫(如
mkdir)不同,使用 O_CREAT 標誌開啟檔案會跟隨最終元件中的符號連結。因此,以下序列ln -s bar /tmp/foo echo hello > /tmp/foo
將建立一個名為
/tmp/bar的檔案。如果設定了O_EXCL,則不允許這樣做,但對於 O_CREAT 開啟,其處理方式與非建立開啟非常相似:lookup_last()或open_last_lookup()返回非NULL值,然後呼叫link_path_walk(),開啟過程繼續在找到的符號連結上進行。
更新訪問時間¶
我們之前談到 RCU-walk 時說它“不加鎖,不增計數,不留痕跡”。我們後來看到,在處理符號連結時,有時需要一些“痕跡”,因為可能需要計數引用(甚至記憶體分配)。但這些痕跡最好保持在最小。
另一個在遍歷符號連結時可能留下痕跡,而這種方式不影響目錄的地方是更新訪問時間。在 Unix(和 Linux)中,每個檔案系統物件都有一個“上次訪問時間”,即“atime”。為了 atime 的目的,透過目錄訪問其中的檔案不被視為一次訪問;只有列出目錄內容才能更新其 atime。符號連結似乎有所不同。讀取符號連結(使用 readlink())和在前往其他目標的過程中查詢符號連結都可以更新該符號連結的 atime。
尚不清楚為何會如此;POSIX 對此主題鮮有提及。最明確的宣告是,如果某個特定實現更新了 POSIX 未指定位置的時間戳,則必須記錄此情況,“除非是路徑名解析引起的變化則無需記錄”。這似乎意味著 POSIX 並不真正關心路徑名查詢期間的訪問時間更新。
歷史考察表明,在 Linux 1.3.87 之前,至少 ext2 檔案系統在跟蹤連結時不會更新 atime。不幸的是,我們沒有關於這種行為為何改變的記錄。
無論如何,現在必須更新訪問時間,而該操作可能相當複雜。在執行此操作時嘗試保持在 RCU-walk 模式下最好避免。幸運的是,通常允許跳過 atime 更新。因為 atime 更新在各個方面都會導致效能問題,Linux 支援 relatime 掛載選項,該選項通常將未更改檔案的 atime 更新限制為每天一次(符號連結一旦建立就永遠不會更改)。即使沒有 relatime,許多檔案系統也以一秒的粒度記錄 atime,因此每秒只需一次更新。
在 RCU-walk 模式下,很容易測試是否需要更新 atime,如果不需要,則可以跳過更新並繼續 RCU-walk 模式。只有當確實需要更新 atime 時,路徑遍歷才會降級到 REF-walk。所有這些都在 get_link() 函式中處理。
幾個標誌¶
結束路徑遍歷之旅的合適方式是列出可以儲存在 nameidata 中以指導查詢過程的各種標誌。其中許多標誌僅在考慮最終元件時才有意義,其他標誌反映路徑查詢的當前狀態,還有一些標誌對路徑查詢中遇到的所有路徑元件施加限制。
然後是 LOOKUP_EMPTY,它在概念上與其他的格格不入。如果未設定此標誌,空路徑名會很早地導致錯誤。如果設定了此標誌,空路徑名則不被視為錯誤。
全域性狀態標誌¶
我們已經遇到了兩個全域性狀態標誌:LOOKUP_RCU 和 LOOKUP_REVAL。它們在三種總體查詢方法之間進行選擇:RCU-walk、REF-walk 和強制重新驗證的 REF-walk。
LOOKUP_PARENT 指示尚未到達最終元件。這主要用於告訴審計子系統正在審計的特定訪問的完整上下文。
ND_ROOT_PRESET 表示 nameidata 中的 root 欄位是由呼叫者提供的,因此當不再需要時,不應將其釋放。
ND_JUMPED 意味著當前 dentry 不是因為它名稱正確而被選中,而是由於其他原因。當跟隨“..”、跟隨指向 / 的符號連結、跨越掛載點或訪問“/proc/$PID/fd/$FD”符號連結(也稱為“魔術連結”)時會發生這種情況。在這種情況下,檔案系統未被要求重新驗證名稱(使用 d_revalidate())。在這種情況下,inode 可能仍然需要重新驗證,因此如果查詢完成時(可能在最終元件,或者在建立、取消連結或重新命名時在倒數第二個元件)設定了 ND_JUMPED,則會呼叫 d_op->d_weak_revalidate()。
解析限制標誌¶
為了讓使用者空間保護自己免受某些競爭條件和涉及更改路徑元件的攻擊場景,提供了一系列標誌,這些標誌對路徑查詢過程中遇到的所有路徑元件施加限制。這些標誌透過 openat2() 的 resolve 欄位公開。
LOOKUP_NO_SYMLINKS 阻止所有符號連結遍歷(包括魔術連結)。這與 LOOKUP_FOLLOW 明顯不同,因為後者僅與限制跟蹤符號連結有關。
LOOKUP_NO_MAGICLINKS 阻止所有魔術連結遍歷。檔案系統必須確保它們從 nd_jump_link() 返回錯誤,因為這就是 LOOKUP_NO_MAGICLINKS 和其他魔術連結限制的實現方式。
LOOKUP_NO_XDEV 阻止所有 vfsmount 遍歷(這包括繫結掛載和普通掛載)。請注意,包含查詢的 vfsmount 由路徑查詢到達的第一個掛載點決定——絕對路徑以 / 的 vfsmount 開始,相對路徑以 dfd 的 vfsmount 開始。魔術連結只有在路徑的 vfsmount 未更改的情況下才被允許。
LOOKUP_BENEATH 阻止任何解析到起始點之外的路徑元件。這是透過阻止 nd_jump_root() 以及如果“..”會跳出起始點則阻止它來實現的。rename_lock 和 mount_lock 用於檢測針對“..”解析的攻擊。魔術連結也被阻止。
LOOKUP_IN_ROOT 將所有路徑元件解析為彷彿起始點就是檔案系統根目錄。nd_jump_root() 將解析帶回到起始點,而起始點處的“..”將作為無操作。與 LOOKUP_BENEATH 一樣,rename_lock 和 mount_lock 用於檢測針對“..”解析的攻擊。魔術連結也被阻止。
最終元件標誌¶
其中一些標誌僅在考慮最終元件時設定。其他標誌僅在考慮該最終元件時才進行檢查。
LOOKUP_AUTOMOUNT 確保如果最終元件是自動掛載點,則觸發掛載。有些操作無論如何都會觸發它,但像 stat() 這樣的操作則故意不觸發。statfs() 需要觸發掛載,但其行為與 stat() 非常相似,因此它會設定 LOOKUP_AUTOMOUNT,就像“quotactl()”和“mount --bind”的處理一樣。
LOOKUP_FOLLOW 的功能與 LOOKUP_AUTOMOUNT 類似,但針對符號連結。有些系統呼叫會隱式設定或清除它,而其他系統呼叫則有 API 標誌,例如 AT_SYMLINK_FOLLOW 和 UMOUNT_NOFOLLOW 來控制它。它的效果與我們之前遇到的 WALK_GET 相似,但用法不同。
LOOKUP_DIRECTORY 堅持最終元件必須是目錄。各種呼叫者會設定此標誌,當發現最終元件後面帶有斜槓時也會設定它。
最後,LOOKUP_OPEN、LOOKUP_CREATE、LOOKUP_EXCL 和 LOOKUP_RENAME_TARGET 不由 VFS 直接使用,但可供檔案系統,特別是 ->d_revalidate() 方法使用。如果檔案系統知道很快就會被要求開啟或建立檔案,它可以選擇不過分地重新驗證。這些標誌以前對 ->lookup() 也很有用,但隨著 ->atomic_open() 的引入,它們在那裡的相關性降低了。
路之盡頭¶
儘管路徑查詢程式碼複雜,但它似乎狀況良好——與幾個版本前相比,現在理解各個部分確實更容易了。但這並不意味著它“已完成”。如前所述,RCU-walk 目前只跟隨儲存在 inode 中的符號連結,因此,雖然它處理許多 ext4 符號連結,但它對 NFS、XFS 或 Btrfs 沒有幫助。這種支援很可能不會延遲太久。