天天看點

Rust的Future、GO的Goroutine、Linux的Epoll高并發背後的殊途同歸

今天我們繼續高并發的話題,在上次的部落格中我們有提到,Rust的Future機制非常有助于程式員按照更為自然、簡潔的邏輯去設計系統,我們必須要知道高并發系統的關鍵在于立交橋的分流與導流構造而非信号燈的限流。是以把精力放在設計鎖、互斥系這些信号系統上是非常事倍功半的。

從機制上來講Rust從函數式語言借鑒而來的Future機制是先進的,而且從親身教小孩程式設計的時候筆者意外發現,對于沒有任何程式設計經驗的人來說,他們學習async/await的成本,要比了解層層回調的機制要低得多。程式員在學習Future的難度大,其實完全是因為之前的曆史包袱太重了。

為什麼說Future更像自然語言

在以下這段代碼中,網絡連接配接socket、請求發送request、響應接收response三個對象全部都是future類型的,也就是在代碼執行之後不會被執行也沒有值僅有占位的意義,當未來執行後才會有值傳回,and_then方法其實是在future對象執行成功後才會被調用的方法,比如read_to_end這行代碼就是在request對象執行成功後,調用​​

read_to_end方法對讀取結果。

use futures::Future;

use tokio_core::reactor::Core;

use tokio_core::net::TcpStream;

fn main() {

let mut core = Core::new().unwrap();

let addr = "127.0.0.1:8080".to_socket_addrs().unwrap().next().unwrap();

let socket = TcpStream::connect(&addr, &core.handle());

let request = socket.and_then(|socket|{

tokio_core::io::write_all(socket, "Hello World".as_bytes())

});

let response = request.and_then(|(socket, _)| {

tokio_core::io::read_to_end(socket, Vec::new())

});

let (_, data) =

core.run

(response).unwrap();

println!("{}", String::from_utf8_lossy(&data));

}

而想象一下如果是傳統程式設計所采用的方式,需要在網絡連接配接完成後調用請求發送的回調函數,然後再請求發送的響應處理方法中再注冊接收請求的回調函數,複雜不說還容易出錯。

而future機制精髓之處在于,整個過程是通過core.run(response).unwrap();這行代碼運作起來的,也就是說開發人員隻需要關心最終的結果就可以了。從建立網絡連接配接開始的調用鍊交給計算機去幫你完成,最終的效率反而還會更高。

并發中的poll模式到底是什麼意思?

筆者看到不少部落客在介紹Rust的Future等異步程式設計架構時都提到了Rust的Future采用poll模式,不過到底什麼是poll模式卻大多語焉不詳。

筆者還是這樣的觀點,程式員群體之是以覺得future機制難以了解,其關鍵在于思維模式被計算機的各種回調機制給束縛住了,而忘記了最簡單直接的方式。在解決這個問題之前我們先來問一個問題,假如讓我們自己設計一個類似于goroutine之類事件高度管理器,應該如何入手?

最直接也是最容易想到的方案就是事件循環,定期周遊整個事件隊列,把狀态是ready的事件通知給對應的處理程式,這也是之前mfc和linux的select的方案,這實際上也就是select方案;另外一種做法是在事件中斷處理程式中直接拿到處理程式的句柄,不再周遊整個事件隊列,而是直接在中斷處理響應中把通知發給對應處理程序,這就是Poll模式。

多路複用是另一種機制,這種機制可以監視多個描述符,一旦某個描述符就緒(一般是讀就緒或者寫就緒),能夠通知程式進行相應的讀寫操作。筆者在前文《這位創造了Github冠軍項目的老男人,堪稱10倍程式員本尊》中曾經介紹過Tdengine的定時器,其中就有這種多路複用的思想。由于作業系統timer的處理程式還不支援epoll的多路複用,是以每注冊一個timer就必須要啟動一個線程進行處理,資源浪費嚴重,是以Tdengine自己實作了一個多路複用的timer,可以做到一個線程同時處理多個timer,這些細節上的精巧設計也是Tdengine封神的原因之一。

Epoll的代價-少量連接配接場景不适用

當然epoll還有一個性能提升的關鍵點,那就是使用紅黑樹做為事件隊列的存儲模型,我們在上文《

用了十年竟然都不對,Java、Rust、Go主流程式設計語言的哈希表比較

》中曾經提到過,紅黑樹是一種解決哈希碰撞時比較好的退化選擇,不過這也給epoll機制帶來了一些适用場景的限制,如果連接配接總數本身就不高的情況下,那麼epoll可能還不如select高效。其原因同時也在《

》中說明了,由于紅黑樹在記憶體中也是散列的狀态,這就會造成連續存儲的資料在總長度較小的情況下獲得比紅黑樹更好的性能,具體這裡就不加贅述了。

ET還是LT如何觸發又是個選擇

Epoll的觸發又分為水準觸發和垂直觸發兩種模式,具體介紹如下:

LT(level triggered)水準觸發,是預設的工作方式,顧名思義,也就是即使狀态不變也可能模式通知的模式,同時支援阻塞和非阻塞兩種方案.在這種做法中,

核心

通知注冊的程序一個有任務已經就緒,不過這種模式下就算程序不作任何操作,核心還是會繼續通知,是以這種模式屬于唐僧式的模式,雖然唠叨但出BUG的可能性要小一點。

ET (edge-triggered),垂直觸發,也就是當且僅當有任務狀态發生變化時才會被觸發,屬于高速工作方式。在ET模式下僅當有事件從未就緒變為就緒時,核心才會觸發通知。但是核心的通知隻會發出一次,也就是說如果事件一直沒有程序處理,核心也不會發送第二次通知。

其實從代碼來看ET和LT的差别不多,具體如下:

if (epi->

event.events

& EPOLLONESHOT)

epi->

event.events

&= EP_PRIVATE_BITS;

else if (!(epi->

event.events

& EPOLLET)) { //如果是是LT模式,目前事件會被重新放到epoll的就緒隊列。

list_add_tail(&epi->rdllink, &ep->rdllist);

ep_pm_stay_awake(epi);

}

可以看到LT模式從不會丢棄事件,隻要隊列裡還有資料能夠讀到,就會不斷的發起通知,屬于鍊式反應的一種,效率低點但不容易出錯,而ET隻在則隻在新事件到來時才會發起通知,效率高但也容易出BUG。當然如果socketfd事件與處理線程之間是一對多的關系,也就是說一個socketfd隻對應一個線程,那倒也還好說。但由于在很多高并發的場景下,很多socketfd是由多個程序同時監控的,是以這又會造成一個驚群的問題。

正如前文所說,多路複用機制也允許多個程序(線程)在等待同一個事件的到來,當這個 fd(socket)的事件發生的時候,這些睡眠的程序(線程)就會被同時喚醒,去處理這個事件,這和一大群魚,争搶一個魚食的現象非常類似,是以也就被稱為"驚群"現象。

由于大量的程序計算資源被浪費在被搶食的過程中,實際上卻沒做任何有意義的工作,是以"驚群"效率低下,而且在魚群搶食的過程中,會造成系統短暫的吞吐能力下降。對于流量分布極不均衡的系統來說,驚群的影響很大。

不過在LT模式下,通知是鍊式的,是以驚群難以避免,ET模式下效率雖多,但如果有一個程序出現問題,則很有可能造成難以察覺的BUG,高并發系統絕對是個說起來容易,做起來難的設計。