rust 語言基礎學習: rust 併發編程初探

併發和並行

很多人分不清併發和並行的概念,所以學習在 Rust 異步編程之前,首先弄清楚清楚 併發(Concurrence)  和 並行(parallel)  的區別。

我們經常在相關操作系統的書裏面聽到:

Golang 創始人之一的 Rob Pike,對此有很精闢很直觀的解釋:

Concurrency is about dealing with lots of things at once. Parallelism is about doing lots of things at once.

併發是一種同時處理很多事情的能力,並行是一種同時執行很多事情的手段。

我們把要做的事情放在多個線程中,或者多個異步任務中處理,這是併發的能力。在多核多 CPU 的機器上同時運行這些線程或者異步任務,是並行的手段。可以說,併發是爲並行賦能。當我們具備了併發的能力,並行就是水到渠成的事情。

Erlang 之父 Joe Armstrong,用一張圖片解釋了併發與並行的區別:

上圖很直觀的體現了:

💡 當然,我們還可以對比下串行:只有一個隊列且僅使用一臺咖啡機,前面哪個人接咖啡時突然發呆了幾分鐘,後面的人就只能等他結束才能繼續接。可能有疑問了,從圖片來看,併發也存在這個問題啊,前面的人發呆了幾分鐘不接咖啡怎麼辦?很簡單,另外一個隊列的人把他推開就行了,自己隊友不能在背後開槍,但是其它隊的可以:)

併發 (concurrency):指在同一時刻只能有一條指令執行,但多個進程指令被快速的輪換執行,使得在宏觀上具有多個進程同時執行的效果,但在微觀上並不是同時執行的,只是把時間分成若干段,使多個進程快速交替的執行處理。

並行 (parallel):指在同一時刻,有多條指令在多個處理器上同時執行。所以無論從微觀還是從宏觀來看,二者都是一起執行,同時處理。

當有多個線程在操作時,如果系統只有一個 CPU,則它根本不可能真正同時進行一個以上的線程,它只能把 CPU 運行時間劃分成若干個時間段,再將時間段分配給各個線程執行,在一個時間段的線程代碼運行時,其它線程處於掛起狀態,這種方式我們稱之爲併發(Concurrent)。

當系統有一個以上 CPU 時,則線程的操作有可能非併發。當一個 CPU 執行一個線程時,另一個 CPU 可以執行另一個線程,兩個線程互不搶佔 CPU 資源,可以同時進行,這種方式我們稱之爲並行(Parallel)。

先給出一個結論:併發和並行都是對 “多任務” 處理的描述,其中併發是輪流執行(處理),傾向於處理能力,比如併發量,而並行是同時執行(處理),傾向於處理手段,比如任務併發。

併發編程模型

我們知道各個語言的實現不同,所以導致各個語言的併發模型各不相同。當我們用某種語言編寫、編譯好一個程序之後,該程序在運行起來之後會佔用一個進程。在這個進程內,可以由進程開闢出一些線程,這個線程是操作系統級別的。而在語言內部,程序員調用該語言創建的線程則是編程語言級別的。而這兩者是否是一一對應,則要看該語言的內部實現:

總之,Rust 經過權衡取捨後,最終選擇了同時提供多線程和 async/await 兩種併發編程模型:

  1. 多線程在標準庫中得到了實現,直接調用底層操作系統 API,實現和使用簡單。適合於量小的併發需求。

  2. async/await 實現起來較爲複雜,但 Rust 經過語言特性 + 標準庫 + 三方庫的方式實現和封裝,讓開發者能夠不用關心底層實現邏輯,適用於量大的併發和異步 IO。

Async 異步編程:

異步編程就是一個併發編程模型,異步編程允許我們同時併發運行大量的任務,卻僅僅需要幾個甚至一個 OS 線程或 CPU 核心,現代化的異步編程在使用體驗上跟同步編程也幾無區別。

目前已經有諸多語言都通過 async 的方式提供了異步編程 ,但 Rust 在實現上有所區別:

Async 異步與多線程的選型:

雖然 async 和多線程都可以實現併發編程,後者甚至還能通過線程池來增強併發能力,但是這兩個方式並不互通,從一個方式切換成另一個需要大量的代碼重構工作,因此掌握二者的區別和適用範圍,然後提前選型相當重要。

💡 async 底層也是基於線程實現。但是它基於線程封裝了一個運行時,可以將多個任務映射到少量線程上。其實就是將大量併發的 IO 密集事件丟到少量線程中,並通過事件來進行高效通信。

代價就是這樣做會增大 Rust 程序的運行時(運行時是那些會被打包到所有程序可執行文件中的 Rust 代碼),造成編譯出的二進制可執行文件體積顯著增大。

用一個簡單的例子說明兩者的區別:比如我們想要下載兩個文件。我們可以一個一個的 download(串行方式),但顯然這樣不是最快的。此時我們會很自然地想到使用多線程並行來下載:

多線程並行編程:

fn download_two_files() {
    // 創建兩個新線程執行任務
    let thread_one = thread::spawn(|| download("URL1"));
    let thread_two = thread::spawn(|| download("URL2"));
    // 等待兩個線程的完成
    thread_one.join().expect("thread one panic");
    thread_two.join().expect("thread two panic");
}

如果每次你只需要下載一兩個文件,這樣做沒有任何問題。但問題在於,當此你需要同時下載成百上千個文件的時候,一個下載任務就耗費一個線程,線程本身的資源消耗會被急速放大(線程還是太重了)。此時你就可以考慮使用 async

async 異步編程:

async fn get_two_sites_async() {
    // 創建兩個不同的`future`
    // 你可以把`future`理解爲未來某個時刻會被執行的計劃任務
    // 當兩個`future`被同時執行後,它們將併發的去下載目標頁面
    let future_one = download_async("URL1");
    let future_two = download_async("URL2");
    // 同時運行兩個`future`,直至完成
    join!(future_one, future_two);
}

💡 Async 相比多線程模型,在此時展現出的是在並行量不變的情況下,減少了創建和切換線程的花銷。

總結

併發和並行都是對 “多任務” 處理的描述,其中併發是輪流處理,而並行是同時處理。併發編程代表程序的不同部分相互獨立的執行,而並行編程代表程序不同部分於同時執行。在併發編程模型上,Rust 中由於語言設計理念、安全、性能的多方面考慮,並沒有採用 Go 語言大道至簡的方式,而是選擇了多線程與 async/await 相結合,優點是可控性更強、性能更高,缺點是複雜度並不低,當然這也是系統級語言的應有選擇:使用複雜度換取可控性和性能。

事實上,async 和多線程並不是二選一,在同一應用中,經常可以同時使用這兩者。雖然 async 和多線程都可以實現併發編程,後者甚至還能通過線程池來增強併發能力,但是這兩個方式並不互通,從一個方式切換成另一個需要大量的代碼重構工作,因此提前爲自己的項目選擇適合的併發模型就變得至關重要。

總之,async異步適合 IO 密集,多線程適合 CPU 密集。簡單總結下選用規則:

  1. 有大量 IO 任務需要併發運行時,選 async 模型

  2. 有部分 IO 任務需要併發運行時,選多線程,如果想要降低線程創建和銷燬的開銷,可以使用線程池

  3. 有大量 CPU 密集任務需要並行運行時,例如並行計算,選多線程模型,且讓線程數等於或者稍大於 CPU 核心數

  4. 無所謂時,統一選多線程

參考

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/P_egl9eBJBKp3J9TQ3TKZw