Tokio 任務調度原理分析
Future 是 Rust 中實現異步的基礎,代表一個異步執行的計算任務,與其他語言不同的是,這個計算並不會自動在後臺執行,需要主動去調用其 poll 方法。Tokio 是社區內使用最爲廣泛的異步運行時,它內部採用各種措施來保證 Future 被公平、及時的調度執行。但是由於 Future 的執行是協作式,因此在一些場景中會不可避免的出現 Future 被餓死的情況。
下文就將結合筆者在開發 CeresDB 時遇到的一個問題,來分析 Tokio 調度時可能產生的問題,作者水平有限,不足之處請讀者指出。
問題背景
CeresDB 是一個面向雲原生打造的高性能時序數據庫,存儲引擎採用的是類 LSM 架構,數據先寫在 memtable 中,達到一定閾值後 flush 到底層(例如:S3),爲了防止小文件過多,後臺還會有專門的線程來做合併。
在生產環境中,筆者發現一個比較詭異的問題,每次當表的合併請求加劇時,表的 flush 耗時就會飆升,flush 與合併之間並沒有什麼關係,而且他們都運行在不同的線程池中,爲什麼會造成這種影響呢?
原理分析
爲了調查清楚出現問題的原因,我們需要了解 Tokio 任務調度的機制,Tokio 本身是一個基於事件驅動的運行時,用戶通過 spawn
來提交任務,之後 Tokio 的調度器來決定怎麼執行,最常用的是多線程版本的調度器 [1],它會在固定的線程池中分派任務,每個線程都有一個 local run queue,簡單來說,每個 worker 線程啓動時會進入一個 loop,來依次執行 run queue 中的任務。如果沒有一定的策略,這種調度方式很容易出現不均衡的情況,Tokio 使用 work steal 來解決,當某個 worker 線程的 run queue 沒有任務時,它會嘗試從其他 worker 線程的 local queue 中 “偷” 任務來執行。
在上面的描述中,任務時最小的調度單元,對應代碼中就是 await
點,Tokio 只有在運行到 await
點時才能夠被重新調度,這是由於 future 的執行其實是個狀態機的執行,例如:
async move {
fut_one.await;
fut_two.await;
}
上面的 async 代碼塊在執行時會被轉化成類似如下形式:
// The `Future` type generated by our `async { ... }` block
struct AsyncFuture {
fut_one: FutOne,
fut_two: FutTwo,
state: State,
}
// List of states our `async` block can be in
enum State {
AwaitingFutOne,
AwaitingFutTwo,
Done,
}
impl Future for AsyncFuture {
type Output = ();
fn poll(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<()> {
loop {
match self.state {
State::AwaitingFutOne => match self.fut_one.poll(..) {
Poll::Ready(()) => self.state = State::AwaitingFutTwo,
Poll::Pending => return Poll::Pending,
}
State::AwaitingFutTwo => match self.fut_two.poll(..) {
Poll::Ready(()) => self.state = State::Done,
Poll::Pending => return Poll::Pending,
}
State::Done => return Poll::Ready(()),
}
}
}
}
在我們通過 AsyncFuture.await
調用時,相當於執行了 AsyncFuture::pool
方法,可以看到,只有狀態切換(返回 Pending
或 Ready
) 時,執行的控制權纔會重新交給 worker 線程,如果 fut_one.poll()
中包括堵塞性的 API,那麼 worker 線程就會一直卡在這個任務中。此時這個 worker 對應的 run queue 上的任務很可能得不到及時調度,儘管有 work steal 的存在,但應用整體可能有較大的長尾請求。
在上圖中,有四個任務,分別是:
-
Task0、Task1 是混合型的,裏面既有 IO 型任務,又有 CPU 型任務
-
Task2、Task3 是單純的 CPU 型任務
執行方式的不同會導致任務的耗時不同,
-
圖一方式,把 CPU 型與 IO 型任務混合在一個線程執行,那麼最差情況下 Task0、Task1 的耗時都是 35ms
-
圖二方式,把 CPU 型與 IO 型任務區分開,分兩個 runtime 去執行,在這種情況下,Task0、Task1 的耗時都是 20ms
因此一般推薦通過 spawn_blocking
來執行可能需要長時間執行的任務,這樣來保證 worker 線程能夠儘快的獲取控制權。
有了上面的知識,再來嘗試分析本文一開始提出的問題,flush 與合併操作的具體內容可以用如下僞代碼表示:
async fn flush() {
let input = memtable.scan();
let processed = expensive_cpu_task();
write_to_s3(processed).await;
}
async fn compact() {
let input = read_from_s3().await;
let processed = expensive_cpu_task(input);
write_to_s3(processed).await;
}
runtime1.block_on(flush);
runtime2.block_on(compact);
可以看到,flush 與 compact 均存在上面說的問題, expensive_cpu_task
可能會卡主 worker 線程,進而影響讀寫 s3 的耗時, s3 的客戶端用的是 object_store[2],它內部使用 reqwest[3] 來進行 HTTP 通信。
如果 flush 和 compact 運行在一個 runtime 內,基本上就不需要額外解釋了,但是這兩個運行在不同的 runtime 中,是怎麼導致相互影響的呢?筆者專門寫了個模擬程序來複現問題,代碼地址:
- https://github.com/jiacai2050/tokio-debug
模擬程序內有兩個 runtime,一個來模擬 IO 場景,一個來模擬 CPU 場景,所有請求按說都只需要 50ms 即可返回,由於 CPU 場景有堵塞操作,所以實際的耗時會更久,IO 場景中沒有堵塞操作,按說都應該在 50ms 左右返回,但多次運行中,均會有一兩個任務耗時在 1s 上下,而且主要集中在 io-5、io-6 這兩個請求上。
[2023-08-06T02:58:49.679Z INFO foo] io-5 begin
[2023-08-06T02:58:49.871Z TRACE reqwest::connect::verbose] 93ec0822 write (vectored): b"GET /io-5 HTTP/1.1\r\naccept: */*\r\nhost: 127.0.0.1:8080\r\n\r\n"
[2023-08-06T02:58:50.694Z TRACE reqwest::connect::verbose] 93ec0822 read: b"HTTP/1.1 200 OK\r\nDate: Sun, 06 Aug 2023 02:58:49 GMT\r\nContent-Length: 14\r\nContent-Type: text/plain; charset=utf-8\r\n\r\nHello, \"/io-5\""
[2023-08-06T02:58:50.694Z INFO foo] io-5 cost:1.015695346s
上面截取了一次運行日誌,可以看到 io-5
這個請求從開始到真正發起 HTTP 請求,已經消耗了 192ms(871-679),從發起 HTTP 請求到得到響應,經過了 823ms,正常來說只需要 50ms 的請求,怎麼會耗時將近 1s 呢?
給人的感覺像是 reqwest 實現的連接池出了問題,導致 IO 線程裏面的請求在等待 cpu 線程裏面的連接,進而導致了 IO 任務耗時的增加。通過在構造 reqwest 的 Client 時設置 pool_max_idle_per_host
爲 0 來關閉連接複用後,IO 線程的任務耗時恢復正常。
筆者在這裏 [4] 向社區提交了這個 issue,但還沒有得到任何答覆,所以根本原因還不清楚。不過,通過這個按理,筆者對 Tokio 如何調度任務有了更深入的瞭解,這有點像 Node.js,絕不能阻塞調度線程。而且在 CeresDB 中,我們是通過添加一個專用運行時來隔離 CPU 和 IO 任務,而不是禁用鏈接池來解決這個問題,感興趣的讀者可以參考 PR #907[5]。
總結
上面通過一個 CeresDB 中的生產問題,用通俗易懂的語言來介紹了 Tokio 的調度原理,真實的情況當然要更加複雜,Tokio 爲了實現最大可能的低延時做了非常多細緻的優化,感興趣的讀者可以參考下面的文章來了解更多內容:
-
Making the Tokio scheduler 10x faster[6]
-
Task scheduler 源碼解讀 [7]
-
走進 Tokio 的異步世界 [8]
最後,希望讀者能夠通過本文的案例,意識到 Tokio 使用時可能存在的潛在問題,儘量把 CPU 等會堵塞 worker 線程的任務隔離出去,減少對 IO 型任務的影響。
擴展閱讀
-
Making the Tokio scheduler 10x faster[9]
-
One bad task can halt all executor progress forever #4730[10]
-
2023 Rust China Conf -- CeresDB Rust 生產實踐 [11]
關於 CeresDB
-
GitHub 倉庫: https://github.com/CeresDB/ceresdb
-
CeresDB 文檔:https://docs.ceresdb.io
-
最新發布的版本: https://github.com/CeresDB/ceresdb/releases/tag/v1.2.6
參考資料
[1]
多線程版本的調度器: https://docs.rs/tokio/latest/tokio/runtime/index.html#multi-thread-scheduler
[2]
object_store: https://docs.rs/object_store/latest/object_store/
[3]
reqwest: https://docs.rs/reqwest/latest/reqwest/
[4]
在這裏: https://github.com/seanmonstar/reqwest/discussions/1935
[5]
PR #907: https://github.com/CeresDB/ceresdb/pull/907/files
[6]
Making the Tokio scheduler 10x faster: https://tokio.rs/blog/2019-10-scheduler
[7]
Task scheduler 源碼解讀: https://tony612.github.io/tokio-internals/03_task_scheduler.html
[8]
走進 Tokio 的異步世界: https://xie.infoq.cn/article/5694ce615d1095cf6e1a5d0ae
[9]
Making the Tokio scheduler 10x faster: https://tokio.rs/blog/2019-10-scheduler
[10]
One bad task can halt all executor progress forever #4730: https://github.com/tokio-rs/tokio/issues/4730
[11]
2023 Rust China Conf -- CeresDB Rust 生產實踐: https://github.com/CeresDB/community/blob/main/slides/20230617-Rust-China-Conf.pptx
本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源:https://mp.weixin.qq.com/s/OBhKoThJD7UK0x_BZhn8DQ