本文原作者:Ralf Jung,原文地址:https://www.ralfj.de/blog/2019/11/25/how-to-panic-in-rust.html
我之前也寫過一篇介紹Rust的panic機制的文章,不過,最近看到這篇文章,感覺其中值得學習的地方很多,因此也一併搬運+翻譯過來了。
當你panic!()
的時候到底發生了什麼?我最近花了很多時間來研究標準庫中與此相關的部分,結果發現答案相當複雜!我一直沒有找到能解釋清楚Rust panic這幅宏大畫卷的文件,因此我覺得這值得寫成一篇文章。
(不要臉的小插曲:我關注這個主題的原因是@Aaron1011為Miri實現了展開的支援。我一直都很期待在Miri中看到這樣的實現,但一直沒有時間親手去做。所以看到有人突然提交了PR,我真的很高興。經過了一輪輪的review,它在最近終於落地了。儘管還有一些地方比較粗糙,但基礎部分還是很牢固的。)
這篇文章的目的是記錄Rust panic方面的高層級結構和與此相關的介面。實際的展開機制完全是另一回事(並且我也沒有資格談論這個主題)。
注:這篇文章描述的panic機制是基於這個提交的(譯註:該提交位於2019年12月1日,相關版本號是1.41.0)。描述的許多介面都是libstd不穩定的內部細節,隨時都有可能發生更改。
高層結構
當試圖透過閱讀libstd的原始碼來弄清“panicking”的工作原理時,很容易迷失在迷宮之中。原始碼中有多層只有連結器才能拼湊出來的間接關係,有#[panic_handler]
屬性和“panic執行時”(由panic策略控制)和“panic鉤子”,並且在#[no_std]
上下文中進行panic
操作時需要完全不同的程式碼路徑……真是千頭萬緒。更糟糕的是,描述panic鉤子的RFC將其稱為“panic handler”,但這個術語後來又被重新使用了。
我認為最好從控制兩次間接跳轉的介面入手:
- libstd使用panic執行時來控制在panic資訊列印到stderr之後會發生什麼。它由panic策略決定:要麼終止(
-C panic=abort
),要麼展開(-C panic=unwind
)。(panic執行時還提供了catch_unwind
的實現,但我們這裡並不太需要關心這個) - libcore使用panic handler來實現:
- 程式碼生成插入的panic,例如算術溢位或越界陣列/切片索引所引起的panic
core::panic!
宏(這是libcore本身和#[no_std]
上下文中的panic!
)
譯註:這裡提到了libstd和libcore。std是我們平時使用的Rust標準庫,而core是
no_std
環境下的“核心庫”。與core對應的是alloc庫,其中包含Box
、String
等依賴堆記憶體分配的庫。可以簡單(但不準確)地理解為core就是不需要堆記憶體的Rust標準庫,這對沒有接觸過no_std
程式設計的讀者理解後文很有幫助。
這兩個介面都是透過extern
塊來實現的:libstd/libcore分別只是引入了一些它們委託的函式,然後在crate樹中的某個完全不同的地方,這些函式得到了實現。這種引入只在連結時解析,如果你在本地檢視程式碼的話,根本無法知道這些介面的實際實現在哪裡。難怪我一路迷路了好幾次。
譯註:“委託”這個概念在後文中還會出現很多次。委託這個概念在C#中很常見,不過這裡的委託似乎和C#中的委託型別並不相同。這裡的委託以我個人的理解應該是“程式碼執行的責任的分配”,或者說某個函式或操作將其功能的實現責任“委託”給了另一個位置或函式來處理。
在下文中,這兩個介面會經常出現;當感覺困惑時,你首先要檢查的是你是否混淆了“panic handler”和“panic執行時”(並且記住還有一個叫做panic鉤子的東西,我們之後會提到)。我經常遇到這種情況。
此外,core::panic!
和std::panic!
並不是一個東西;正如我們所看到的,它們的程式碼路徑截然不同:
-
libcore中的
core::panic!
做的事情很少。它基本上只是立即委託給panic handler。 -
libstd中的
std::panic!
(也就是Rust的“普通”panic!
宏)會觸發一個功能齊全的panic機制,並提供一個由使用者控制的panic鉤子。預設鉤子會把恐慌資訊列印到stderr。鉤子執行完成後,libstd將委託給panic執行時。libstd還提供了一個呼叫相同機制的panic handler,因此
core::panic!
也會在這裡結束。
現在讓我們更深入地瞭解一下這些東西吧。
Panic執行時
panic執行時的介面(在這個RFC中被引入)是一個簽名為__rust_start_panic(payload: usize) -> u32
的函式,它被libstd匯入,並在稍後被連結器解析。
其中的usize
引數實際上是一個*mut &mut dyn core::panic::BoxMeUp
——這是panic的“負載”(當panic被捕獲時的可用資訊)被傳遞的地方。BoxMeUp
是一個不穩定的內部實現細節,但從這個trait我們可以發現它實際上只是包裝了一個dyn Any + Send
,也就是catch_unwind
和thread::spawn
返回的panic負載型別。BoxMeUp::take_box
返回的是Box<dyn Any + Send>
,但是以原始指標的形式(因為在這個trait定義的上下文中Box
尚不可用);而BoxMeUp::get
只是借用了內容。
譯註:
catch_unwind
函式接受一個閉包,並可以“捕捉”其中發生的panic。在從外部呼叫Rust程式碼時catch_unwind
很有用(將Rust的panic展開和其他語言隔離開來),Rust程式的main
實際上也是在一個catch_unwind
裡被呼叫的。
Rust自帶這個介面的兩個實現:libpanic_unwind
(對應-C panic=unwind
,大多數平臺上的預設實現)和libpanic_abort
(對應-C panic=abort
)。
std::panic!
在panic執行時介面上,libstd在std::panicking
模組內實現了Rust預設的panic處理機制。
rust_panic_with_hook
rust_panic_with_hook
是幾乎所有程式都要經過的關鍵函式:
fn rust_panic_with_hook(
payload: &mut dyn BoxMeUp,
message: Option<&fmt::Arguments<'_>>,
file_line_col: &(&str, u32, u32),
) -> !
這個函式接受觸發panic的原始碼位置、一個可選的panic訊息(未格式化的fmt資料,參見fmt::Arguments的文件)和一個負載。
譯註:panic鉤子是Rust標準庫提供的一種錯誤處理機制。我們可以使用
std::panic::set_hook
來將一個Fn(&PanicInfo<'_>)
函式設定為“panic鉤子”,接下來當panic發生時,就會首先呼叫這個鉤子,然後再進行展開(或中斷)。
它的主要工作是呼叫當前的panic鉤子,無論它實際上是什麼。panic鉤子擁有一個PanicInfo
引數,所以我們為了呼叫它就需要panic程式碼位置、panic訊息的格式化資料和一個負載。這和rust_panic_with_hook
實際具有的引數相當匹配!file_line_col
和message
可以被直接用作組成PanicInfo
的前兩個元素;而payload
則透過BoxMeUp
的介面轉換為&(dyn Any + Send)
。
有趣的是,預設的panic鉤子是完全忽略message
的;你實際上看到的輸出是從payload
向下轉換到&str
或String
的(但也能工作)。按理來說,呼叫者應該確保格式化資訊message
(如果存在的話)給出相同的結果。(並且我們接下來討論的內容也確實能確保這一點)
最終,rust_panic_with_hook
將panic分配給當前的panic執行時。在這一步時,只有payload
引數還有後續的關聯——這很重要:message
(正如其'_
生命週期所示)可能含有生命週期較短的引用,但panic負載將會沿著堆疊向上傳播,因此這些負載必須是'static
的。'static
限制在這裡相當隱蔽,我過了好一會兒才意識到Any
早已暗示了'static
(並記住了dyn BoxMeUp
僅僅用於獲取一個Box<dyn Any + Send>
)。(譯註:Any
trait擁有'static
的生命週期約束。)
libstd pacnicking入口點
rust_panic_with_hook
是std::panicking
模組的一個私有函式;該模組在它的基礎上提供了三個入口點,以及一個繞過它的入口點:
-
begin_panic_handler
,預設的panic handler實現,支援了(我們馬上會看到)來自core::panic!
和內建程式碼(來自算術溢位、索引越界等)的panic。它的輸入是PanicInfo
,因此必須將它轉換為rust_panic_with_hook
的引數。有趣的是,儘管PanicInfo
的組成部分和rust_panic_with_hook
的引數很像,並且看起來可以直接透傳,但實際的實現並沒有這樣幹。libstd反而完全忽略了PanicInfo
的內容,並構建實際的負載(傳遞給rust_panic_with_hook
),以使其包含格式化後的message
。特別地,這意味著panic執行時和
no_std
的程式是無關的。它僅僅當libstd的panic handler實現被採用時才會起作用。(不過,即使在no_std
下,透過-C panic
設定的panic策略仍然會生效,因為它還是能影響到程式碼生成的過程。例如,當使用了-C panic=abort
時,程式碼會變得更簡單,因為它不需要支援展開了。) -
begin_panic_fmt
,支援了std::panic!
宏的格式化字串版本(也就是當你往這個宏裡傳入多個引數時使用的版本)。這個函式基本上只是把格式字串引數打包成一個PanicInfo
(並帶有一個假的負載),然後呼叫我們剛剛討論過的預設panic handler。(譯註:這裡提到的“假的負載”,是一個叫作NoPayload
的空結構體。) -
begin_panic
,支援了std::panic!
宏的單引數版本。有趣的是,這個入口點的程式碼路徑和前面兩個入口點非常地不同!特別地,這是唯一一個允許傳入任意負載的入口點。這個負載只是被轉換為Box<dyn Any + Send>
,使得其可以被傳遞給rust_panic_with_hook
,然後就沒有然後了。特別地,當panic鉤子檢視
PanicData
的message
欄位時,將無法在std::panic!("do panic")
中看到訊息;但在std::panic!("panic with data: {}", data)
中,它可以看到訊息。這是因為後者是透過begin_panic_fmt
傳遞的。這看起來挺令人吃驚的。(不過也請注意,PanicData::message()
方法還沒有穩定下來。)(譯註:本文翻譯時,Rust倉庫中已經搜尋不到PanicData
了。) -
rust_panic_without_hook
是個怪胎:這個入口點為resume_unwind
提供支援(譯註:resume_unwind
函式用於繼續向上傳播一個已經開始,且被catch_unwind
捕獲的恐慌),它實際上不會呼叫panic鉤子。相反,它立即向panic執行時發出委託。像begin_panic
一樣,它允許呼叫者指定任意負載。和begin_panic
不同的是,呼叫者需要自行負責對負載的裝箱和調整大小;rust_panic_without_hook
幾乎逐字轉發這些內容到panic執行時。
Panic Handler
std::panic!
的所有機制都很有用,但它們都依賴於透過Box
完成的堆記憶體分配,而這樣的條件並非總是具備的。為了給libcore一條引發panic的路,Rust引入了panic handler。正如我們所看到的,如果libstd可用,那麼它將會為core::panic!
提供一個連線其和libstd的panic機制的介面。
panic handler的介面是一個被libcore匯入的函式fn panic(info:&core::panic::PanicInfo)->!
,並且在稍後由連結器解析。PanicInfo
型別和panic鉤子中使用的一樣:它包括一個panic源位置,一條panic訊息,以及一個負載(dyn Any + Send
)。panic訊息以fmt::Arguments
的型別呈現,或者說一條還沒有被格式化的格式化字串及其引數。
core::panic!
在panic handler的介面之上,libcore提供了一套最小化的panic API。core::panic!
宏建立了一個fmt::Arguments
,並在稍後傳遞給panic handler。這裡並沒有實際的格式化發生,因為進行格式化可能需要進行堆記憶體分配;這也就是為什麼PanicInfo
攜帶的是一個“沒有被解釋”的格式化字串及其引數。
有趣的是,傳遞給panic handler的PanicInfo
的payload
欄位總是被設定為一個虛假的值。這就解釋了為什麼libstd的panic handler會忽略PanicInfo
中的負載(而是從訊息中重新構建一個新的負載),但這也使得我想知道為什麼這個欄位是panic handler API的一部分。這種做法的另一個結果是core::panic!("message")
和std::panic!("message")
(沒有格式化的變體)實際上會產生截然不同的panic:前者會變成fmt::Arguments
,透過panic handler介面傳遞。之後libstd會格式化它並得到一個String
負載。不過,後者直接將&str
作為負載,並且將PanicInfo
的message
欄位留空為None
(正如我們在上文已經講過的那樣)。
libcore的panic API中的一些元素是語言項,因為編譯器需要在生成程式碼期間插入對這些函式的呼叫:
panic
語言項在編譯器需要引發一個無需任何格式化的panic時被呼叫(例如算術溢位);這個語言項也是在幕後支援了單引數core::panic!
的函式。panic_bounds_check
語言項在陣列或切片的索引越界檢查失敗時被呼叫。它呼叫了和core::panic!
相同的方法,並進行了格式化。
譯註:語言項(lang item)是Rust中一個非常核心的概念,它們是編譯器內部特殊處理的功能或特性的標識。語言項機制允許開發者直接為編譯器提供某些必須的底層實現細節。語言項通常用於實現Rust標準庫中的核心功能,例如
Box
、Trait
物件等等。語言項不是普通的庫函式和特性,它們在編譯階段擁有特殊的意義,並由編譯器特殊識別和處理。
結語
我們已經走過了4層API,其中2層是透過函式匯入和連結器解析來間接實現的。這真是一場意義非凡的旅行!但是現在我們已經接近終點了。我希望你在這一路上沒有把自己panic了;)。(譯註:原句為“I hope you didn't panic yourself”,Don't Panic是《銀河系漫遊指南》中的一個梗,或者說其中引申出的一句俗語,詳情可以點選連結檢視。)
我提到的有些東西聽起來可能比較令人驚訝。事實證明,panic鉤子和panic handler的介面共享PanicInfo
結構體,這個結構體中都包括一個可選的、未格式化的message
和一個擦出了型別的payload
:
- panic鉤子總是能在
payload
中找到已經格式化的訊息,因此message
看起來對鉤子函式的作用並不大。事實上,即使payload
中包含一條訊息,message
也可能會是空的(例如std::panic!("message")
)。 - panic handler永遠不會真正收到一個有用處的
payload
,因此這個欄位對handler們的用處看起來並不大。
根據這個panic handle的RFC,看起來有計劃讓core::panic!
也支援任意有效載荷,但到目前(2019年)為止還沒有實現。不過,即使未來有了這樣的擴充套件,我認為我們也會有一個不變數,即當message
是Some
時,要麼payload
是假的(payload == &NoPayload
,負載是無用的),要麼payload
是一個格式化後的訊息(此時訊息是無用的)。我想知道會不會有一種讓兩個欄位都有用的情況——如果沒有的話,我們難道不能直接把這兩個變數合併成一個enum
嗎?可能有一些很好的理由來反對這個提議和支援現有設計,如果能把它們記錄在某處就太棒了:)。
還有很多內容可以講述,但在這裡,我邀請你根據我在文中提供的連結去看看原始碼。只要你記得高層次的結構,你應該就能理解那些程式碼。如果人們認為這些概述值得放在更持久的地方,我很樂於將這篇博文整理成某種形式的文件——儘管我不確定放在哪裡比較合適。如果你發現我寫的有任何錯誤,請告訴我!