翻譯|在Rust中怎樣panic

Cinea發表於2024-04-16

本文原作者: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庫,其中包含BoxString等依賴堆記憶體分配的庫。可以簡單(但不準確)地理解為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_unwindthread::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_colmessage可以被直接用作組成PanicInfo的前兩個元素;而payload則透過BoxMeUp的介面轉換為&(dyn Any + Send)

有趣的是,預設的panic鉤子是完全忽略message的;你實際上看到的輸出是payload向下轉換到&strString(但也能工作)。按理來說,呼叫者應該確保格式化資訊message(如果存在的話)給出相同的結果。(並且我們接下來討論的內容也確實能確保這一點)

最終,rust_panic_with_hook將panic分配給當前的panic執行時。在這一步時,只有payload引數還有後續的關聯——這很重要:message(正如其'_生命週期所示)可能含有生命週期較短的引用,但panic負載將會沿著堆疊向上傳播,因此這些負載必須是'static的。'static限制在這裡相當隱蔽,我過了好一會兒才意識到Any早已暗示了'static(並記住了dyn BoxMeUp僅僅用於獲取一個Box<dyn Any + Send>)。(譯註:Anytrait擁有'static的生命週期約束。)

libstd pacnicking入口點

rust_panic_with_hookstd::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鉤子檢視PanicDatamessage欄位時,將無法在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 APIcore::panic!宏建立了一個fmt::Arguments,並在稍後傳遞給panic handler。這裡並沒有實際的格式化發生,因為進行格式化可能需要進行堆記憶體分配;這也就是為什麼PanicInfo攜帶的是一個“沒有被解釋”的格式化字串及其引數。

有趣的是,傳遞給panic handler的PanicInfopayload欄位總是被設定為一個虛假的值。這就解釋了為什麼libstd的panic handler會忽略PanicInfo中的負載(而是從訊息中重新構建一個新的負載),但這也使得我想知道為什麼這個欄位是panic handler API的一部分。這種做法的另一個結果是core::panic!("message")std::panic!("message")(沒有格式化的變體)實際上會產生截然不同的panic:前者會變成fmt::Arguments,透過panic handler介面傳遞。之後libstd會格式化它並得到一個String負載。不過,後者直接將&str作為負載,並且將PanicInfomessage欄位留空為None(正如我們在上文已經講過的那樣)。

libcore的panic API中的一些元素是語言項,因為編譯器需要在生成程式碼期間插入對這些函式的呼叫:

  • panic語言項在編譯器需要引發一個無需任何格式化的panic時被呼叫(例如算術溢位);這個語言項也是在幕後支援了單引數core::panic!的函式。
  • panic_bounds_check語言項在陣列或切片的索引越界檢查失敗時被呼叫。它呼叫了和core::panic!相同的方法,並進行了格式化。

譯註:語言項(lang item)是Rust中一個非常核心的概念,它們是編譯器內部特殊處理的功能或特性的標識。語言項機制允許開發者直接為編譯器提供某些必須的底層實現細節。語言項通常用於實現Rust標準庫中的核心功能,例如BoxTrait物件等等。語言項不是普通的庫函式和特性,它們在編譯階段擁有特殊的意義,並由編譯器特殊識別和處理。

結語

我們已經走過了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年)為止還沒有實現。不過,即使未來有了這樣的擴充套件,我認為我們也會有一個不變數,即當messageSome時,要麼payload是假的(payload == &NoPayload,負載是無用的),要麼payload是一個格式化後的訊息(此時訊息是無用的)。我想知道會不會有一種讓兩個欄位都有用的情況——如果沒有的話,我們難道不能直接把這兩個變數合併成一個enum嗎?可能有一些很好的理由來反對這個提議和支援現有設計,如果能把它們記錄在某處就太棒了:)。

還有很多內容可以講述,但在這裡,我邀請你根據我在文中提供的連結去看看原始碼。只要你記得高層次的結構,你應該就能理解那些程式碼。如果人們認為這些概述值得放在更持久的地方,我很樂於將這篇博文整理成某種形式的文件——儘管我不確定放在哪裡比較合適。如果你發現我寫的有任何錯誤,請告訴我!

相關文章