併發模型比較

java人生發表於2018-09-19

Golang 的特色之一就是 goroutine ,使得程式設計師進行併發程式設計更加方便,適合用來進行伺服器程式設計。作為後端開發工程師,有必要了解併發程式設計面臨的場景和常見的解決方案。一般情況下,是怎樣做高併發的程式設計呢?有那些經典的模型呢?

一切始於 C10k C10k 就是 Client 10000,單機伺服器同時服務1萬個客戶端。當然,現在的業務面臨的是 C100k、C1000k 了。早期的伺服器是基於程式/執行緒模型,每新來一個連線,就分配一個程式(執行緒)去處理這個連線。而程式(執行緒)在作業系統中,佔有一定的資源。由於硬體的限制,程式(執行緒)的建立是有瓶頸的。另外程式(執行緒)的上下文切換也有成本:每次排程器排程執行緒,作業系統都要把執行緒的各種必要的資訊,如程式計數器、堆疊、暫存器、狀態等儲存起來。

CPU 運算遠遠快於 I/O 操作。一般而言,常見的網際網路應用(比如 Web)都是 I/O 密集型而非計算密集型。I/O 密集型是指,計算機 CPU 大量的時間都花在等待資料的輸入輸出,而不是計算。當 CPU 大部分時間都在等待 I/O 的時候,大部分計算資源都被浪費掉了。

顯然,簡單粗暴地開一個程式/執行緒去 handle 一個連線是不夠的。為了達到高併發,應該好好考慮一下 I/O 策略。同樣的硬體條件下,不同的設計產生的效果差別也會很大。在討論幾種 I/O 模型之前,先介紹一下同步/非同步、阻塞/非阻塞的概念,以及作業系統的知識。

參考:

The C10K problem

同步/非同步?阻塞/非阻塞? 同步,是呼叫者主動去檢視呼叫的狀態;非同步,則是被呼叫者來通知呼叫者。例如在 Web 應用裡,後端通過渲染模版的方式把 Web 頁面傳送給前端,是同步的方式。這裡前端是呼叫者,每一次請求資料,都要把整個頁面重新載入一次。而前端用 jQuery Ajex 向伺服器請求資料,則是非同步的,每次請求資料不需要把整個頁面重新載入,區域性重新整理即可。

阻塞和非阻塞的區別是呼叫後是否立即返回。 A 調完 B,就在呼叫處等待(阻塞),直到 B 方法返回才繼續執行剩下的程式碼,這就是阻塞呼叫。而非阻塞是 A 方法呼叫 B 方法,B 方法立即返回,A 可以繼續執行下面的程式碼,不會被該呼叫阻塞。當某個方法被阻塞了,該方法所在的執行緒會被掛起,被作業系統的排程器放到阻塞佇列,直到 A 等待的事件發生,才從阻塞態轉到就緒態。

Unix 下的 I/O 模型也有同步/非同步、阻塞/非阻塞的概念,可以檢視我做的筆記:UNIX 中的 I/O 模型

程式、執行緒、協程 程式 是系統進行資源分配的一個獨立單位。這些資源包括:使用者的地址空間,實現程式(執行緒)間同步和通訊的機制,已開啟的檔案和已申請到的I/O裝置,以及一張由核心程式維護的地址對映表。核心通過 程式控制塊 (PCB,process control block)來感知程式。

執行緒 是排程和分派的基本單位。核心通過 執行緒控制塊 (TCB,thread control block)來感知執行緒。

執行緒本身不擁有系統資源,而是僅有一點必不可少的、能保證獨立執行的資源,如TCB、程式計數器、區域性變數、狀態引數、返回地址等暫存器和堆疊。同一程式的所有執行緒具有相同的地址空間,執行緒可以訪問程式擁有的資源。多個執行緒可併發執行,一個程式含有若干個相對獨立的執行緒,但至少有一個執行緒。

執行緒的有不同的實現方式,分 核心支援執行緒 (KST,Kernel Supported Threads)和 使用者級執行緒 (UST, User Supported Threads)。核心級執行緒的 TCB 儲存在核心空間,其建立、阻塞、撤銷、切換等活動也都是在核心空間實現的。使用者級執行緒則是核心無關的,使用者級執行緒的實現在使用者空間,核心感知不到使用者執行緒的存在。使用者執行緒的排程演算法可以是程式專用的,不會被核心排程,但同時,使用者執行緒也無法利用多處理機的並行執行。而一個擁有多個使用者執行緒的程式,一旦有一個執行緒阻塞,該程式所有的執行緒都會被阻塞。核心的切換需要轉換到核心空間,而使用者執行緒不需要,所以前者開銷會更大。但使用者執行緒也需要核心的支援,一般是通過執行時系統或核心控制執行緒來連線一個核心執行緒,有 1:1、1:n、n:m 的不同實現。

在分時作業系統中,處理機的排程一般基於時間片的輪轉(RR, round robin),多個就緒執行緒排成佇列,輪流執行時間片。而為保證互動性和實時性,執行緒都是以搶佔的方式(Preemptive Mode)來獲得處理機。而搶佔方式的開銷是比較大的。有搶佔方式就有非搶佔方式(Nonpreemptiv Mode),在非搶佔式中,除非某正在執行的執行緒執行完畢、因系統呼叫(如 I/O 請求)發生阻塞或主動讓出處理器,不會被排程或暫停。

而 協程 (Coroutine)就是基於非搶佔式的排程來實現的。程式、執行緒是作業系統級別的概念,而協程是編譯器級別的,現在很多程式語言都支援協程,如 Erlang、Lua、Python、Golang。準確來說,協程只是一種使用者態的輕量執行緒。它執行在使用者空間,不受系統排程。它有自己的排程演算法。在上下文切換的時候,協程在使用者空間切換,而不是陷入核心做執行緒的切換,減少了開銷。簡單地理解,就是編譯器提供一套自己的執行時系統(而非核心)來做排程,做上下文的儲存和恢復,重新實現了一套“併發”機制。系統的併發是時間片的輪轉,單處理器互動執行不同的執行流,營造不同執行緒同時執行的感覺;而協程的併發,是單執行緒內控制權的輪轉。相比搶佔式排程,協程是主動讓權,實現協作。協程的優勢在於,相比回撥的方式,寫的非同步程式碼可讀性更強。缺點在於,因為是使用者級執行緒,利用不了多核機器的併發執行。

執行緒的出現,是為了分離程式的兩個功能:資源分配和系統排程。讓更細粒度、更輕量的執行緒來承擔排程,減輕排程帶來的開銷。但執行緒還是不夠輕量,因為排程是在核心空間進行的,每次執行緒切換都需要陷入核心,這個開銷還是不可忽視的。協程則是把排程邏輯在使用者空間裡實現,通過自己(編譯器執行時系統/程式設計師)模擬控制權的交接,來達到更加細粒度的控制。

參考:

《計算機作業系統》

併發模型

  1. 單進(線)程·迴圈處理請求 單程式和單執行緒其實沒有區別,因為一個程式至少有一個執行緒。迴圈處理請求應該是最初級的做法。當大量請求進來時,單執行緒一個一個處理請求,請求很容易就積壓起來,得不到響應。這是無併發的做法。

  2. 多程式 主程式監聽和管理連線,當有客戶請求的時候,fork 一個子程式來處理連線,父程式繼續等待其他客戶的請求。但是程式佔用伺服器資源是比較多的,伺服器負載會很高。

Apache 是多程式伺服器。有兩種模式:

Prefork MPM : 使用多個子程式,但每個子程式不包含多執行緒。每個程式只處理一個連線。在許多系統上它的速度和worker MPM一樣快,但是需要更多的記憶體。這種無執行緒的設計在某些性況下優於 worker MPM,因為它可在應用於不具備執行緒安全的第三方模組上(如 PHP3/4/5),且在不支援執行緒除錯的平臺上易於除錯,另外還具有比worker MPM更高的穩定性。

Worker MPM : 使用多個子程式,每個子程式中又有多個執行緒。每個執行緒處理一個請求,該MPM通常對高流量的伺服器是一個不錯的選擇。因為它比prefork MPM需要更少的記憶體且更具有伸縮性。

這種架構的最大的好處是隔離性,子程式萬一 crash 並不會影響到父程式。缺點就是對系統的負擔過重。

參考:

web伺服器apache架構與原理

  1. 多執行緒 和多程式的方式類似,只不過是替換成執行緒。主執行緒負責監聽、accept()連線,子執行緒(工作執行緒)負責處理業務邏輯和流的讀取。子執行緒阻塞,同一程式內的其他執行緒不會被阻塞。

缺點是:

會頻繁地建立、銷燬執行緒,這對系統也是個不小的開銷。這個問題可以用執行緒池來解決。執行緒池是預先建立一部分執行緒,由執行緒池管理器來負責排程執行緒,達到執行緒複用的效果,避免了反覆建立執行緒帶來的效能開銷,節省了系統的資源。

要處理同步的問題,當多個執行緒請求同一個資源時,需要用鎖之類的手段來保證執行緒安全。同步處理不好會影響資料的安全性,也會拉低效能。

一個執行緒的崩潰會導致整個程式的崩潰。

多執行緒的適用場景是:提高響應速度,讓IO和計算相互重疊,降低延時。雖然多執行緒不能提高絕對效能,但是可以提高平均響應效能。

這種其實是比較容易想到的,特別是對於剛剛學習多執行緒和作業系統的計算機學生而言。在請求量不高的時候,是足夠的。來多少連線開多少執行緒,就看伺服器的硬體效能能不能承受。但高併發並不是線性地堆砌硬體或加執行緒數就能達到的。100個執行緒也許能夠達到1000的併發,但10000的併發下,執行緒數乘以10也許就不行,比如執行緒排程帶來的開銷、同步成為了瓶頸。

  1. 單執行緒·回撥(callback)和事件輪詢 Nginx Nginx 採用的是多程式(單執行緒) & 多路IO複用模型:

Nginx 在啟動後,會有一個 master 程式和多個相互獨立的 worker 程式。

接收來自外界的訊號,向各 worker 程式傳送訊號,每個程式都有可能來處理這個連線

master 程式能監控 worker 程式的執行狀態,當 worker 程式退出後(異常情況下),會自動啟動新的 worker 程式。

主程式(master 程式)首先通過 socket() 來建立一個 sock 檔案描述符用來監聽,然後fork生成子程式(workers 程式),子程式將繼承父程式的 sockfd(socket 檔案描述符),之後子程式 accept() 後將建立已連線描述符(connected descriptor)),然後通過已連線描述符來與客戶端通訊。

存在驚群現象:當連線進來時,所有子程式都將收到通知並“爭著”與它建立連線。

Nginx 在 accept 上加一把互斥鎖來應對驚群現象。

在每個 worker 程式裡,Nginx 呼叫核心 epoll()函式來實現 I/O 的多路複用。

參考:

初步探索Nginx高併發原理

Node.js Node.js 也是單執行緒模型。Node.js中所有的邏輯都是事件的回撥函式,所以 Node.js始終在事件迴圈中,程式入口就是事件迴圈第一個事件的回撥函式。事件的回撥函式中可能會發出I/O請求或直接發射( emit )事件,執行完畢後返回事件迴圈。事件迴圈會檢查事件佇列中有沒有未處理的事件,直到程式結束。Node.js的事件迴圈對開發者不可見,由 libev 庫實現,libev 不斷檢查是否有活動的、可供檢測的事件監聽器,直到檢查不到時才退出事件迴圈,程式結束。

Node.js 單執行緒能夠實現非阻塞,是因為其底層實現有另一個執行緒在輪詢事件佇列,對於上層的開發者,只需考慮單執行緒,沒有許可權去開新的執行緒,也不需要考慮執行緒同步之類的問題。

這種機制的缺點是,會造成大量回撥函式的巢狀,程式碼可讀性不佳。因為沒有多執行緒,在多核的機器上,也沒辦法實現並行執行。

參考:

Node.js機制及原理理解初步

使用 libevent 和 libev 提高網路應用效能

  1. 協程 協程基於使用者空間的排程器,具體的排程演算法由具體的編譯器和開發者實現,相比多執行緒和事件回撥的方式,更加靈活可控。不同語言協程的排程方式也不一樣,python是在程式碼裡顯式地yield進行切換,golang 則是用go語法來開啟 goroutine,具體的排程由語言層面提供的執行時執行。

gorounte 的堆疊比較小,一般是幾k,可以動態增長。執行緒的堆疊空間在 Windows 下預設 2M,Linux 下預設 8M。這也是 goroutine 單機支援上萬併發的原因,因為它更廉價。

從堆疊的角度,程式擁有自己獨立的堆和棧,既不共享堆,亦不共享棧,程式由作業系統排程。執行緒擁有自己獨立的棧和共享的堆,共享堆,不共享棧,執行緒亦由作業系統排程(核心執行緒)。協程和執行緒一樣共享堆,不共享棧,協程由程式設計師在協程的程式碼裡顯示排程。

在使用 goroutine 的時候,可以把它當作輕量級的執行緒來用,和多程式、多執行緒方式一樣,主 goroutine 監聽,開啟多個工作 goroutine 處理連線。比起多執行緒的方式,優勢在於能開更多的 goroutine,來處理連線。

goroutine 的底層實現,關鍵在於三個基本物件上,G(goroutine),M(machine),P (process)。M:與核心執行緒連線,代表核心執行緒;P:代表M執行G所需要的資源,可以把它看做一個區域性的排程器,維護著一個goroutine佇列;G:代表一個goroutine,有自己的棧。M 和 G 的對映,可以類比作業系統核心執行緒與使用者執行緒的 m:n 模型。通過對 P 數量的控制,可以控制作業系統的併發度。

參考:

如何理解 Golang 中“不要通過共享記憶體來通訊,而應該通過通訊來共享記憶體”?

Golang原始碼探索(二) 協程的實現原理

Goroutine(協程)為何能處理大併發?

協程

Actor 和 CSP 模型 傳統的多執行緒程式設計,是用共享記憶體的方式來進行同步的。但當並行度變高,不確定性就增加了,需要用鎖等機制保證正確性,但鎖用得不好容易拉低效能。而且多執行緒程式設計也是比較困難的,不太符合人的思維習慣,很容易出錯,會產生死鎖。所以有一些新的程式設計模型來實現高併發,用訊息傳遞來代替共享記憶體和鎖。

於是就有了“Don’t communicate by sharing memory, share memory by communicating”(不要通過共享記憶體來通訊,而應該通過通訊來共享記憶體)的思想,Actor 和 CSP 就是兩種基於這種思想的併發程式設計模型,學術界已有諸多論文加以闡述。也就是說,這是有數學證明的,瞭解這兩種模型,能給高併發伺服器的開發很多有益的啟發。作為工程師,不一定要有理論創新,但要學會把理論成果用到自己的專案上面。

「Actor 模型的重點在於參與交流的實體,而 CSP 模型的重點在於用於交流的通道。」Java/Scala 有個庫 akka,就是 Actor 模型的實現。而 golang 的協程機制則是 CSP 模型。

「Actor 模型推崇的哲學是“一切皆是參與者(actor)”,這與物件導向程式設計的“一切皆是物件”類似。」「Actor模型=資料+行為+訊息。Actor模型內部的狀態由自己的行為維護,外部執行緒不能直接呼叫物件的行為,必須通過訊息才能激發行為,這樣就保證Actor內部資料只有被自己修改。」

我的理解是,在模型內部,對資料的處理始終是單執行緒的,所以無需要考慮執行緒安全,無需加鎖,外部可以是多執行緒,要運算元據需要向內部執行緒傳送訊息,內部執行緒一次只處理一次訊息,一個訊息代表一個處理資料的行為。內部執行緒和外部執行緒通過信箱(mailbox)來實現非同步的訊息機制。

CSP 與 Actor 類似,process(在 go 中則是 goroutine) 對應 acotor,也就是傳送訊息的實體。 channel 對應 mailbox,是傳遞訊息的載體。區別在與一個 actor 只有一個 mailbox,actor 和 mailbox 是耦合的。channel 是作為 first-class 獨立存在的(這在 golang 中很明顯),channel 是匿名的。mailbox 是非同步的,channel 一般是同步的(在 golang 裡,channel 有同步模式,也可以設定緩衝區大小實現非同步)。

參考:

actor併發模型&基於共享記憶體執行緒模型

為什麼Actor模型是高併發事務的終極解決方案?

如何深入淺出地解釋併發模型中的 CSP 模型?

併發程式設計:Actors模型和CSP模型

總結 高併發的關鍵在於實現非同步非阻塞,更加高效地利用 CPU。多執行緒可以達到非阻塞,但佔用資源多,切換開銷大。協程用棧的動態增長、使用者態的排程來避免多執行緒的兩個問題。事件驅動用單執行緒的方式,避免了佔用太多系統資源,不需要關心執行緒安全,但無法利用多核。具體要採用哪種模型,還是要看需求。模型或技術只是工具,條條大陸通羅馬。

比較優雅的還是 CSP 和 Actor 模型,因為能夠符合人的思維習慣,避免了鎖的使用。個人覺得加鎖和多執行緒的方式,很容易被濫用,這是一種從微觀出發和線性的思維方式,不夠高屋建瓴。不如用訊息通訊來的耦合性更低。

高併發程式設計很有必要性。一方面,很多應用都需要高併發支援,網路的使用者越來越多,業務場景會越來越複雜,需要有穩定和高效的伺服器支援。另一方面,現代的計算機效能都是比較高的,但如果軟體設計得不夠好,就不能夠把效能都給發揮出來。這就很浪費了。

在寫這篇文章的時候,我發現了很多有趣的開源原始碼和專案,值得進一步研究和閱讀,但時間有限,暫時沒有深入。接下來會繼續瞭解一下,然後更新一些文章:

libtask golang 作者之一 Russ Cox 實現的 C 語言協程庫,golang 的 goroutine 就參考了這個庫的實現: swtch.com/libtask/

libev 事件驅動程式設計框架:software.schmorp.de/pkg/libev.h…

akka scala 實現的 Actor 框架:akka.io/

這篇文章花了我快一週的時間,查了大量的資料,寫作難度比我想象中大很多,而且還寫得不好,引用了不少部落格的說法,還沒有辦法自己組織出比較好的語言來闡述問題。有些點可能還理解錯了,以後再改吧。不過好歹寫完了。至少對主流的併發程式設計有了個感性的理解,也算是對自己的一個交代。

update:函數語言程式設計 2018.01.01

最近了解到,函數語言程式設計也是一個可以用來解決併發問題的模型。

命令式語言和函式式語言的抽象不同。

指令式程式設計是對計算機硬體的抽象,關心的是解決問題的步驟。函數語言程式設計是對數學的抽象,把問題轉化為數學表示式。

函式性語言兩個特徵:資料不可變,不依賴儲存或檢索狀態的操;無副作用,用相同的輸入呼叫函式,總是返回相同的值。也因此,可以不依賴鎖來做併發程式設計。

還沒有學習函式式的語言,所以對函數語言程式設計如何做到併發不是很理解。但能感受到,函式式語言是一個值得探尋的領域。

有一句話“軟體的首要技術使命是管理複雜度。”(《程式碼大全》)。之所以存在這麼多抽象,一方面是要有效地解決問題,另一方面,也是為了降低程式設計師的心智負擔。程式設計模型其實就是程式設計師看待問題的方式。同樣解決問題,當然是選擇程式設計友好、符合人的思維習慣的程式設計模型比較好。“程式碼是寫給人看的,不是寫給機器看的”(SICP)。雖然機器一樣能執行,但最終的目的是為了解放人,讓人能把大部分精力花在刀刃上、花在創造性的工作上。

相關文章