「別阻塞執行器」規則(don't-block-the-executor)
在一座靠互相禮讓輪流通行的單線橋上,一個把車停在中間、跑去吃午餐的駕駛,會把後面所有人凍住。在一個協作式排程的非同步執行時期裡,工作者執行緒就是那座橋。「別阻塞執行器」規則說:在非同步任務內部,絕不要做任何會阻塞作業系統執行緒的操作,因為你被阻塞的期間並沒有讓出,於是共用那條工作者執行緒的每一個其他任務都跟你一起被凍住。
回想非同步任務是協作式排程的:工作者執行緒把一個任務推進到它撞上 await,再轉去下一個任務。這只有在「等待」是靠暫停(讓出執行緒)而非靠阻塞它來完成時才行得通。一個阻塞呼叫——std::fs::read()(同步檔案 I/O)、std::thread::sleep()、一個橫跨工作期間握著的同步互斥鎖、或一段冗長的 CPU 密集迴圈——並不會暫停任務;它把整條作業系統執行緒停在呼叫裡面。執行器收不回那條執行緒,於是排在它上面的其他任務全都無法推進。在單執行緒執行時期上,這可能讓整個程式死結;在多執行緒上,它餓死一部分產能、把尾端延遲拖垮。解法很明確:改用會讓出的非同步對應版本(tokio::fs、tokio::time::sleep、非同步互斥鎖),或把真正阻塞/CPU 吃重的工作推到專用的阻塞執行緒池(spawn_blocking),讓它無法卡住非同步工作者。
這是新手最常見的非同步自爆,正因為阻塞呼叫照樣能編譯、且在輕負載下往往看起來能動——然後在並行下、當許多任務堆積在那個卡住的任務後面時轟然崩潰。要內化的心法是:在非同步程式碼裡,「等待」必須意味著 await,絕不能是「阻塞」。如果一個函式不懂非同步、又可能等待或讓 CPU 空轉超過幾微秒,它就不該直接待在執行器的執行緒上。
錯:async fn f() { std::thread::sleep(Duration::from_secs(1)); } // 阻塞了工作者執行緒,凍住同處的任務。對:tokio::time::sleep(Duration::from_secs(1)).await; // 會讓出。CPU 吃重?spawn_blocking(|| crunch()).await。
同步 sleep 阻塞執行緒;非同步 sleep 讓出它。在並行把問題暴露出來之前,這差別是看不見的。
非同步任務裡的阻塞程式碼照樣能編譯、也可能通過輕量測試——這正是它危險之處。在單執行緒執行時期上它能讓整個程式死結;在多執行緒上它默默摧毀吞吐量與尾端延遲。