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,用一張圖片解釋了併發與並行的區別:
上圖很直觀的體現了:
-
併發 (Concurrent) 是多個隊列使用同一個咖啡機,然後兩個隊列輪換着使用,最終每個人都能接到咖啡
-
並行 (Parallel) 是每個隊列都擁有一個咖啡機,同時有多個人在接咖啡,最終也是每個人都能接到咖啡,效率更高。
💡 當然,我們還可以對比下串行:只有一個隊列且僅使用一臺咖啡機,前面哪個人接咖啡時突然發呆了幾分鐘,後面的人就只能等他結束才能繼續接。可能有疑問了,從圖片來看,併發也存在這個問題啊,前面的人發呆了幾分鐘不接咖啡怎麼辦?很簡單,另外一個隊列的人把他推開就行了,自己隊友不能在背後開槍,但是其它隊的可以:)
併發 (concurrency):指在同一時刻只能有一條指令執行,但多個進程指令被快速的輪換執行,使得在宏觀上具有多個進程同時執行的效果,但在微觀上並不是同時執行的,只是把時間分成若干段,使多個進程快速交替的執行處理。
並行 (parallel):指在同一時刻,有多條指令在多個處理器上同時執行。所以無論從微觀還是從宏觀來看,二者都是一起執行,同時處理。
當有多個線程在操作時,如果系統只有一個 CPU,則它根本不可能真正同時進行一個以上的線程,它只能把 CPU 運行時間劃分成若干個時間段,再將時間段分配給各個線程執行,在一個時間段的線程代碼運行時,其它線程處於掛起狀態,這種方式我們稱之爲併發(Concurrent)。
當系統有一個以上 CPU 時,則線程的操作有可能非併發。當一個 CPU 執行一個線程時,另一個 CPU 可以執行另一個線程,兩個線程互不搶佔 CPU 資源,可以同時進行,這種方式我們稱之爲並行(Parallel)。
先給出一個結論:併發和並行都是對 “多任務” 處理的描述,其中併發是輪流執行(處理),傾向於處理能力,比如併發量,而並行是同時執行(處理),傾向於處理手段,比如任務併發。
併發編程模型
我們知道各個語言的實現不同,所以導致各個語言的併發模型各不相同。當我們用某種語言編寫、編譯好一個程序之後,該程序在運行起來之後會佔用一個進程。在這個進程內,可以由進程開闢出一些線程,這個線程是操作系統級別的。而在語言內部,程序員調用該語言創建的線程則是編程語言級別的。而這兩者是否是一一對應,則要看該語言的內部實現:
-
OS 原生線程:Rust 語言是直接調用操作系統提供的 API,
最終程序內的線程數和該程序佔用的操作系統線程數相等,可以使用線程池提升性能。
-
協程 (Coroutines) :類似 Go 語言編寫的
程序內部的 M 個線程最後會以某種映射方式使用 N 個操作系統線程去運行。
-
事件驅動 (Event driven):事件驅動常常跟回調( Callback ) 一起使用,這種模型性能相當的好,但最大的問題就是存在回調地獄的風險。
-
Actor 模型:基於消息傳遞,對分解成的小塊進行併發計算。是 Erlang 語言殺手鐧。
-
async/await 模型:該模型性能高,還能支持底層編程,同時又像線程和協程那樣無需過多的改變編程模型,但有得必有失,
async
模型的問題就是內部實現機制過於複雜。
總之,Rust 經過權衡取捨後,最終選擇了同時提供多線程和 async/await
兩種併發編程模型:
-
多線程在標準庫中得到了實現,直接調用底層操作系統 API,實現和使用簡單。適合於量小的併發需求。
-
async/await 實現起來較爲複雜,但 Rust 經過語言特性 + 標準庫 + 三方庫的方式實現和封裝,讓開發者能夠不用關心底層實現邏輯,適用於量大的併發和異步 IO。
Async 異步編程:
異步編程就是一個併發編程模型,異步編程允許我們同時併發運行大量的任務,卻僅僅需要幾個甚至一個 OS 線程或 CPU 核心,現代化的異步編程在使用體驗上跟同步編程也幾無區別。
目前已經有諸多語言都通過 async
的方式提供了異步編程 ,但 Rust
在實現上有所區別:
-
Future 在 Rust 中是惰性的,只有在被輪詢 (
poll
) 時纔會運行, 因此丟棄一個future
會阻止它未來再被運行, 你可以將Future
理解爲一個在未來某個時間點被調度執行的任務。 -
Async 在 Rust 中使用開銷是零, 意味着只有你能看到的代碼 (自己的代碼) 纔有性能損耗,你看不到的(
async
內部實現) 都沒有性能損耗,例如,你可以無需分配任何堆內存、也無需任何動態分發來使用async
,這對於熱點路徑的性能有非常大的好處,正是得益於此,Rust 的異步編程性能纔會這麼高。 -
Rust 沒有內置異步調用所必須的運行時,但是無需擔心,Rust 社區生態中已經提供了非常優異的運行時實現,例如大明星
tokio
-
運行時同時支持單線程和多線程,這兩者擁有各自的優缺點, 稍後會講
Async 異步與多線程的選型:
雖然 async
和多線程都可以實現併發編程,後者甚至還能通過線程池來增強併發能力,但是這兩個方式並不互通,從一個方式切換成另一個需要大量的代碼重構工作,因此掌握二者的區別和適用範圍,然後提前選型相當重要。
-
對於
CPU密集型
任務,例如並行計算,使用多線程編程更有優勢。這是因爲這種密集任務往往會讓所在的線程長時間滿負荷運行,同時你所創建的線程數應該等於 CPU 核心數,充分利用 CPU 的並行能力。此時不需要頻繁創建和切換進程,因爲任何線程切換都會帶來性能損耗,所以你可以將線程綁定到 CPU 核心上來減少線程上下文切換。 -
而對於
IO密集型
任務,例如 web 服務器、數據庫連接等等網絡服務,使用異步編程更有優勢。因爲這些任務絕大部分時間都處於等待狀態,如果使用多線程,那線程大量時間會處於空閒狀態,再加上線程上下文切換的高昂代價,會損失大量性能。而使用async
,既可以有效的降低CPU
和內存的負擔,又可以讓大量的任務併發的運行,一個任務一旦處於IO
或者其他等待 (阻塞) 狀態,就會被立刻切走並執行另一個任務,而這裏的任務切換的性能開銷要遠遠低於使用多線程時的線程上下文切換。
💡
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 密集。簡單總結下選用規則:
-
有大量
IO
任務需要併發運行時,選async
模型 -
有部分
IO
任務需要併發運行時,選多線程,如果想要降低線程創建和銷燬的開銷,可以使用線程池 -
有大量
CPU
密集任務需要並行運行時,例如並行計算,選多線程模型,且讓線程數等於或者稍大於CPU
核心數 -
無所謂時,統一選多線程
參考
-
https://course.rs/advance/concurrency-with-threads/concurrency-parallelism.html
-
https://kaisery.github.io/trpl-zh-cn/ch16-00-concurrency.html
-
https://github.com/rustlang-cn/async-book/blob/master/async/getting-started.md
-
https://hardocs.com/d/rustprimer/concurrency-parallel-thread/thread.html
-
https://huangjj27.github.io/async-book/01_getting_started/02_why_async.html
-
https://juejin.cn/post/7156971883827560456
本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源:https://mp.weixin.qq.com/s/P_egl9eBJBKp3J9TQ3TKZw