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 的存在,但應用整體可能有較大的長尾請求。

在上圖中,有四個任務,分別是:

執行方式的不同會導致任務的耗時不同,

因此一般推薦通過 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 中,是怎麼導致相互影響的呢?筆者專門寫了個模擬程序來複現問題,代碼地址:

模擬程序內有兩個 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 爲了實現最大可能的低延時做了非常多細緻的優化,感興趣的讀者可以參考下面的文章來了解更多內容:

最後,希望讀者能夠通過本文的案例,意識到 Tokio 使用時可能存在的潛在問題,儘量把 CPU 等會堵塞 worker 線程的任務隔離出去,減少對 IO 型任務的影響。

擴展閱讀

關於 CeresDB

參考資料

[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