deadlock本應該是執行時期的問題。這就是讓它們如此煩人的原因。你的程式碼編譯乾淨,測試通過,然後它在生產環境中卡住,因為處理程序A在等待處理程序B,而處理程序B在等待處理程序A。
會話型別扭轉了這一點。它們將處理程序之間的通訊protocol編碼到型別系統本身中。某些類別的deadlock不再是執行時期的意外,而開始成為編譯器錯誤。你字面意義上無法編寫會導致deadlock的程式碼。
會話型別實際上是什麼
會話型別是通訊通道的型別規範。通道不再是一個你從中讀取和寫入的無型別管道,而是一個攜帶型別的會話型別化通道,該型別描述了允許在其上執行的確切操作序列。
傳送一個整數,然後接收一個字串,然後關閉。編譯器在每一步追蹤這個序列。偏離它,你會得到一個型別錯誤,而不是執行時deadlock。
這個想法來自程序演算,並在Haskell、Scala、OCaml和Rust中有實作。Rust的所有權系統和仿射型別使其成為一個特別自然的契合,但這個概念是語言無關的。
訊息傳遞deadlock實際上如何發生
考慮兩個需要交換資料的處理程序。一個常見的錯誤看起來像這樣:
// Process A
tx1.send(data_a)?;
let result_a = rx2.recv()?;
// Process B
tx2.send(data_b)?;
let result_b = rx1.recv()?;
兩個處理程序都試圖先傳送。如果通道 buffer已滿,兩者都會在傳送時阻塞。兩者都永遠達不到接收。這是一個經典的通訊不匹配deadlock。
你可能會想「別這麼幹就行了」。但在一個擁有數十個通道、條件邏輯和六個月後會被重構的程式碼的真實系統中,這種模式不斷潛入。型別檢查器對你的傳送和接收序列是否連貫沒有意見。
會話型別如何讓deadlock無法表示
核心技巧是會話型別在每次操作後都會改變。通道沒有靜態型別。它有一個型別,在你使用它之後變成其他東西。
以下是在Rust中演示這個想法的最小實作:
use std::marker::PhantomData;
struct Send<T, Next>(PhantomData<(T, Next)>);
struct Recv<T, Next>(PhantomData<(T, Next)>);
struct Close;
struct Chan<P>(PhantomData<P>);
impl<P> Chan<P> {
fn new() -> Self {
Chan(PhantomData)
}
}
impl Chan<Close> {
fn close(self) {}
}
impl<T, Next> Chan<Send<T, Next>> {
fn send(self, value: T) -> Chan<Next> {
drop(value);
Chan(PhantomData)
}
}
impl<T: Default, Next> Chan<Recv<T, Next>> {
fn recv(self) -> (T, Chan<Next>) {
(T::default(), Chan(PhantomData))
}
}
型別為Chan<Send<i32, Recv<String, Close>>>的通道意味著:你必須傳送一個i32,然後你將擁有一個可以接收String的通道,然後你將擁有一個只能關閉的通道。
send方法消耗舊通道並返回一個更新型別的新通道。因為Rust的所有權系統確保self被消耗,你不能再次使用舊通道。編譯器不會讓你傳送兩次,或亂序接收,或忘記關閉。
對於我們的deadlock場景,你用互補型別定義兩個端點:
type Client = Send<i32, Recv<String, Close>>;
type Server = Recv<i32, Send<String, Close>>;
fn client(ch: Chan<Client>) {
let ch = ch.send(42);
let (msg, ch) = ch.recv();
println!("{}", msg);
ch.close();
}
fn server(ch: Chan<Server>) {
let (req, ch) = ch.recv();
println!("{}", req);
let ch = ch.send("hello".to_string());
ch.close();
}
處理程序A先傳送再接收。處理程序B先接收再傳送。protocol型別強制執行這個順序。
如果有人將處理程序B重構為先傳送,編譯器會立即拒絕:
// This will NOT compile:
fn bad_server(ch: Chan<Server>) {
// error: no method named `send` found for struct `Chan<Recv<...>>`
let ch = ch.send("hello".to_string());
}
型別系統說這個通道處於接收狀態。你不能傳送。deadlock變得無法表達。
理論變得複雜的地方:分支和遞迴
真正的protocol不是線性序列。它們有選擇。伺服器可能提供認證或註冊。會話型別用內部和外部選擇型別來處理這個。
struct Credentials;
struct Token;
struct UserInfo;
struct Account;
enum AuthProtocol {
Login(Send<Credentials, Recv<Token, Close>>),
Register(Send<UserInfo, Recv<Account, Close>>),
}
客戶端提供一個選擇,伺服器選擇一個。兩個端點必須在選擇上達成一致,否則型別不匹配。
遞迴protocol,比如一個循環回到選單的持續連線,需要遞迴型別定義。這是大多數語言掙扎的地方。Rust支援這個,但會變得冗長。
還有多方問題。上面的會話型別是二元的:兩個端點。如果三個或更多處理程序協調,你需要多方會話型別,這明顯更複雜且成熟的實作更少。
你應該知道的權衡
會話型別消除了一類錯誤,但它們不能消除所有deadlock。每個處理程序都在等待外部資源的全域deadlock,或者處理程序在沒有進展的情況下空轉的活結,仍然可能發生。會話型別專門針對通訊不匹配。
它們還引入了編譯時開銷。來自深層型別堆疊的錯誤訊息可能令人費解。一個簡單的通道型別錯誤可能會在編譯器輸出中擴展為50行嵌套的泛型。Rust生態系統在這方面有所改進,但調試會話型別不匹配仍然是一項需要習得的技能。
動態拓撲是另一個痛點。會話型別在通訊圖是靜態且編譯時已知的情況下效果最好。如果你基於執行時資料生成通道,比如一個參與者數量可變的聊天室,會話型別會變得難以應用。
今天如何嘗試這個
如果你想在Rust中試驗會話型別,session_types crate提供了基於原始理論的成熟實作。對於更符合人體工學的API,sesh提供了一種替代方法。
在Haskell中,session-types透過Haskell的型別級編程提供類似的保證。對於更接近工業用途的東西,看看帶有生成客戶端存根的Protocol Buffers。雖然嚴格意義上不是會話型別,但生成的程式碼強制執行相同的原則:protocol是外部定義的,編譯器檢查你的使用是否符合它。
如果你正在構建一個具有固定通訊處理程序集的分散式系統,從繪製通訊圖開始。為每條訊息畫箭頭。如果圖複雜到讓你擔心不匹配,那就是會話型別發揮作用的時候。
FAQ
會話型別能防止所有deadlock嗎?
不能。它們防止由通訊不匹配引起的deadlock,比如兩個處理程序都在等待傳送。它們不能防止資源deadlock、活結或涉及外部系統的deadlock。
會話型別和state machine有什麼區別?
會話型別是編碼在型別系統中的state machine。狀態轉換由編譯器在每次通道操作上強制執行,而不是在執行時檢查。
會話型別在生產中使用嗎?
二元會話型別在研究系統和專門領域中使用。多方會話型別仍然主要是學術性的。這些概念影響了現代API設計,包括生成的gRPC客戶端和Rust的型別狀態模式。
我可以不用Rust使用會話型別嗎?
可以。Haskell、Scala和OCaml都有會話型別庫。即使在沒有仿射型別的語言中,你也可以用線性型別檢查器或執行時斷言來近似這個模式。
deadlock現在是一個型別問題
deadlock在成為型別問題之前是執行時問題。會話型別不是銀彈,但對於具有良好定義protocol的傳訊息系統,它們將整個類別的缺陷從「在生產中捕獲」轉移到「在編譯時捕獲」。下次你勾勒新服務架構時,這是一個值得考慮的權衡。