使用Rustlang的Async Tokio執行時處理CPU密集型任務

banq發表於2022-01-17

Rust 內建了對非同步 ( async) 程式設計模型的支援,類似於 JavaScript 等語言。
要充分利用多核和非同步 I/O,必須使用執行時,雖然 Rust 社群有多種替代方案,但 Tokio 是事實上的標準。
CPU 密集型計算定義:以消耗大量 CPU 用於儲存重組、預先計算各種索引或直接回答客戶端查詢的方式處理資料。
這些計算通常被分解成許多獨立的塊,稱為“任務”,然後並行執行以利用現代 CPU 中可用的許多核心。
確定何時執行哪些任務通常由稱為“任務排程程式”的東西完成,它將任務對映到可用的硬體核心/作業系統執行緒。
當在 Rust 生態系統中尋找任務排程器時,就像InfluxDB IOx 和 DataFusion 所做的那樣,你自然會選擇 Tokio,它看起來很不錯:
  1. 您已經擁有 Tokio(沒有新的依賴項)。
  2. Tokio 實現了一個複雜的工作竊取排程程式
  3. Tokio 有效地內建了對延續 ( async/ await) 的語言支援,以及許多相對成熟的流、非同步鎖定、通道、取消等庫。
  4. Tokio 以在整個 Rust 生態系統中經過良好測試和大量使用而聞名。
  5. Tokio 通常會在同一個執行器執行緒上執行任務和未來,這對於快取區域性性非常有用。
  6. Tokio 有據可查,積極維護,並且一直在變得更好。

但是:
舊版本的 Tokio 文件(例如1.10.0)著名的告誡:
“如果您的程式碼受 CPU 限制,並且您希望限制用於執行它的執行緒數,您應該在另一個執行緒池(例如 Rayon)上執行它。”

這個宣告在廣泛的 Rust 社群中都引起了嚴重的混亂。多人讀到它的意思是Runtime永遠不應該將 Tokio 用於 CPU 密集型任務,關鍵實際上是同一個Runtime 例項(同一個執行緒池)不應該同時用於 I/O 和 CPU。
順便說一句,Tokio 文件建議將Rayon用於 CPU 密集型任務。Rayon 是許多應用程式的絕佳選擇,但它不支援 . async,因此如果您的程式碼必須執行任何 I/O,您將​​不得不跨越痛苦的同步/非同步邊界。我還發現對映一個基於拉取的執行模型具有挑戰性,其中任務必須等待所有輸入準備好才能執行到 Rayon。
 
智者說,“使用 Tokio 進行 CPU 密集型工作會增加你的請求尾部延遲,這是不可接受的。” 
請求尾部會影響是否活著的檢查指標,對於使用容器編排系統(Kubernetes)部署的系統來說很重要,因為 Tokio 正在有效地充分利用您的 CPU 來處理大量資料處理任務,那麼 Kubernetes 將無法獲得所需的“一切正常”響應並終止您的程式。
這種推理得出了一個經典結論,即由於尾部延遲很關鍵,因此您不能將 Tokio 用於 CPU 繁重的任務。
然而,正如 Tokio 文件所建議的那樣,在 CPU 完全飽和的同時避免被 Kubernetes 和朋友攻擊,真正重要的是使用一個單獨的執行緒池——一個用於“延遲很重要”的任務,例如響應/health,另一個用於 CPU繁重的任務。這些執行緒池的最佳執行緒數因您的需要而異,這是另一篇單獨文章的好主題。
也許透過將 TokioRuntime視為一個複雜的執行緒池,使用不同Runtime例項的想法可能看起來更可口。
 

如何將 Tokio 用於 CPU 密集型任務?
這是我們如何在 InfluxDB IOx 中的單獨 Tokio Runtime 上執行任務的簡化版本。(完整版本可以在我們的 repo中找到,並且有額外的乾淨關閉和加入邏輯。)

pub struct DedicatedExecutor {
    state: Arc<Mutex<State>>,               
}                      
                       
/// Runs futures (and any `tasks` that are `tokio::task::spawned` by           
/// them) on a separate Tokio Executor             
struct State {                
    /// Channel for requests -- the dedicated executor takes requests          
    /// from here and runs them.                   
    requests: Option<std::sync::mpsc::Sender<Task>>,             
                       
    /// Thread which has a different Tokio runtime
    /// installed and spawns tasks there               
    thread: Option<std::thread::JoinHandle<()>>,                 
}            
 
impl DedicatedExecutor {                    
    /// Creates a new `DedicatedExecutor` with a dedicated Tokio               
    /// executor that is separate from the threadpool created via              
    /// `[tokio::main]`.                
    pub fn new(thread_name: &str, num_threads: usize) -> Self {         
 let thread_name = thread_name.to_string();               
                       
 let (tx, rx) = std::sync::mpsc::channel::<Task>();              
                       
 let thread = std::thread::spawn(move || { 
     // Create a new Runtime to run tasks                            
     let runtime = Tokio::runtime::Builder::new_multi_thread()          
  .enable_all()               
  .thread_name(&thread_name)                
  .worker_threads(num_threads)
  // Lower OS priority of worker threads to prioritize main runtime                     
  .on_thread_start(move || set_current_thread_priority_low())       
  .build()                    
  .expect("Creating Tokio runtime");               
                       
  // Pull task requests off the channel and send them to the executor                    
  runtime.block_on(async move {             
  while let Ok(task) = rx.recv() {                                 
      Tokio::task::spawn(async move {              
   task.run().await;                 
      });              
  }        
                      
 let state = State {                 
     requests: Some(tx),             
     thread: Some(thread),                  
 };                    
                       
 Self {                
     state: Arc::new(Mutex::new(state)),           
 }                     
    }            


此程式碼建立一個 new std::thread,它建立一個單獨的多執行緒 TokioRuntime來執行任務,然後從Channel讀取任務並將spawn它們傳送到 new Runtime上。
注意:新執行緒是關鍵。如果您嘗試Runtime在主執行緒或 Tokio 建立的執行緒之一上建立一個新執行緒,您將收到錯誤訊息,因為已經安裝了一個Runtime。
這是向第二個Runtime傳送任務的相應程式碼。

impl DedicatedExecutor {
 
/// Runs the specified Future (and any tasks it spawns) on the
/// `DedicatedExecutor`.
pub fn spawn<T>(&self, task: T) -> Job<T::Output>
where
T: Future + Send + 'static,
T::Output: Send + 'static,
{
let (tx, rx) = tokio::sync::oneshot::channel();

let fut = Box::pin(async move {
let task_output = task.await;
tx.send(task_output).ok()
});
let mut state = self.state.lock();
let task = Task {
fut,
};

if let Some(requests) = &mut state.requests {
// would fail if someone has started shutdown
requests.send(task).ok();
} else {
warn!("tried to schedule task on an executor that was shutdown");
}

Job { rx, cancel }
}
}

上面的程式碼使用了一個Future被呼叫的包裝器Job,它處理將結果從專用執行器傳輸回主執行器,如下所示:

#[pin_project(PinnedDrop)]
pub struct Job<T> {
#[pin]
rx: Receiver<T>,
}

impl<T> Future for Job<T> {
type Output = Result<T, Error>;

fn poll(
self: Pin<&mut Self>,
cx: &mut std::task::Context<'_>,
) -> std::task::Poll<Self::Output> {
let this = self.project();
this.rx.poll(cx)
}
}


可以在這個Github gist中找到所有程式碼。

相關文章