併發模型比較

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 就參考了這個庫的實現: https://swtch.com/libtask/

libev 事件驅動編程框架:http://software.schmorp.de/pkg/libev.html

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

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

update:函數式編程
2018.01.01

最近瞭解到,函數式編程也是一個可以用來解決併發問題的模型。

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

命令式編程是對計算機硬件的抽象,關心的是解決問題的步驟。函數式編程是對數學的抽象,把問題轉化爲數學表達式。

函數性語言兩個特徵:數據不可變,不依賴保存或檢索狀態的操;無副作用,用相同的輸入調用函數,總是返回相同的值。也因此,可以不依賴鎖來做併發編程。

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

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

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章