muduo庫學習之常用程式設計模型04——常用程式設計模型與程序間通訊方式的選擇
技術標籤:多執行緒服務端程式設計C++UNIX環境高階程式設計
東陽的學習筆記
文章目錄
1. 單執行緒伺服器常用地程式設計模型
常用的單執行緒程式設計模型見https://blog.csdn.net/qq_22473333/article/details/112910686
在高效能的網路程式中,使用得最為廣泛的恐怕要數“non-blocking IO + IO multiplexing
l lighttpd,單執行緒伺服器。(nginx 估計與之類似,待查)
-
libevent/libev
-
ACE,Poco C++ libraries(QT 待查)
-
Java NIO (Selector/SelectableChannel), Apache Mina, Netty (Java)
-
POE (Perl)
-
Twisted (Python)
相反,boost::asio 和 Windows I/O Completion Ports 實現了 Proactor 模式,應用面似乎要窄一些。當然,ACE 也實現了 Proactor 模式,不表。
在“non-blocking IO + IO multiplexing”這種模型下,程式的基本結構是一個事件迴圈 (event loop):(程式碼僅為示意,沒有完整考慮各種情況)
while (!done)
{
int timeout_ms = max(1000, getNextTimedCallback());
int retval = ::poll(fds, nfds, timeout_ms);
if (retval < 0) {
// 處理錯誤
} else {
// 處理到期的 timers
if (retval > 0) {
// 處理 IO 事件
}
}
}
當然,select(2)/poll(2) 有很多不足,Linux 下可替換為 epoll,其他作業系統也有對應的高效能替代品(搜 c10k problem)。
Reactor
模型的優點很明顯,程式設計簡單,效率也不錯。不僅網路讀寫可以用,連線的建立(connect/accept)甚至 DNS 解析都可以用非阻塞方式進行,以提高併發度和吞吐量 (throughput)。對於 IO 密集的應用是個不錯的選擇,Lighttpd
即是這樣,它內部的 fdevent 結構十分精妙,值得學習。(這裡且不考慮用阻塞 IO 這種次優的方案。)
當然,實現一個優質的 Reactor 不是那麼容易,我也沒有用過坊間開源的庫,這裡就不推薦了。
2. 典型的多執行緒伺服器的執行緒模型
這方面我能找到的文獻不多,大概有這麼幾種:
-
每個請求建立一個執行緒,使用阻塞式 IO 操作。在 Java 1.4 引入 NIO 之前,這是 Java 網路程式設計的推薦做法。可惜伸縮性不佳。
-
使用執行緒池,同樣使用阻塞式 IO 操作。與 1 相比,這是提高效能的措施。
-
使用 non-blocking IO + IO multiplexing。即 Java NIO 的方式。
-
Leader/Follower 等高階模式
在預設情況下,我會使用第 3 種,即 non-blocking IO + one loop per thread 模式。
http://pod.tst.eu/http://cvs.schmorp.de/libev/ev.pod#THREADS_AND_COROUTINES
One loop per thread
此種模型下,程式裡的每個 IO 執行緒有一個 event loop
(或者叫 Reactor),用於處理讀寫和定時事件(無論週期性的還是單次的),程式碼框架跟第 2 節一樣。
這種方式的好處是:
-
執行緒數目基本固定,可以在程式啟動的時候設定,不會頻繁建立與銷燬。
-
可以很方便地線上程間調配負載。
event loop
代表了執行緒的主迴圈,需要讓哪個執行緒幹活,就把 timer 或 IO channel (TCP connection) 註冊到那個執行緒的 loop 裡即可。
- 對實時性有要求的 connection 可以單獨用一個執行緒;
- 資料量大的 connection 可以獨佔一個執行緒
- 把資料處理任務分攤到另幾個執行緒中;
- 其他次要的輔助性 connections 可以共享一個執行緒。
對於 non-trivial 的服務端程式,一般會採用 non-blocking IO + IO multiplexing,每個 connection/acceptor 都會註冊到某個 Reactor 上,程式裡有多個 Reactor,每個執行緒至多有一個 Reactor。
多執行緒程式對 Reactor 提出了更高的要求,那就是執行緒安全
。要允許一個執行緒往別的執行緒的 loop 裡塞東西,這個 loop 必須得是執行緒安全的。
執行緒池
不過,對於沒有 IO 光有計算任務的執行緒,使用 event loop 有點浪費,我會用有一種補充方案,即用 blocking queue
實現的任務佇列(TaskQueue):
blocking_queue<boost::function<void()> > taskQueue; // 執行緒安全的阻塞佇列
void worker_thread()
{
while (!quit) {
boost::function<void()> task = taskQueue.take(); // this blocks
task(); // 在產品程式碼中需要考慮異常處理
}
}
用這種方式實現執行緒池特別容易:
啟動容量為 N 的執行緒池:
int N = num_of_computing_threads;
for (int i = 0; i < N; ++i) {
create_thread(&worker_thread); // 虛擬碼:啟動執行緒
}
使用起來也很簡單:
boost::function<void()> task = boost::bind(&Foo::calc, this);
taskQueue.post(task);
上面十幾行程式碼就實現了一個簡單的固定數目的執行緒池,功能大概相當於 Java 5 的 ThreadPoolExecutor 的某種“配置”。當然,在真實的專案中,這些程式碼都應該封裝到一個 class 中,而不是使用全域性物件。
另外需要注意一點:Foo 物件的生命期,我的另一篇部落格《當解構函式遇到多執行緒——C++ 中執行緒安全的物件回撥》詳細討論了這個問題
http://blog.csdn.net/Solstice/archive/2010/01/22/5238671.aspx
除了任務佇列,還可以用 blocking_queue<T>
實現資料的消費者-生產者佇列,即 T 的是資料型別而非函式物件,queue 的消費者(s)從中拿到資料進行處理。這樣做比 task queue 更加 specific 一些。
blocking_queue 是多執行緒程式設計的利器,它的實現可參照 Java 5 util.concurrent 裡的 (Array|Linked)BlockingQueue,通常 C++ 可以用 deque 來做底層的容器。Java 5 裡的程式碼可讀性很高,程式碼的基本結構和教科書一致(1 個 mutex,2 個 condition variables),健壯性要高得多。如果不想自己實現,用現成的庫更好。(我沒有用過免費的庫,這裡就不亂推薦了,有興趣的同學可以試試 Intel Threading Building Blocks
裡的 concurrent_queue。)
歸納
總結起來,我推薦的多執行緒服務端程式設計模式為:event loop per thread
+ thread pool
。
- event loop 用作 non-blocking IO 和定時器。
- thread pool 用來做計算,具體可以是任務佇列或消費者-生產者佇列。
以這種方式寫伺服器程式,需要一個優質的基於 Reactor 模式的網路庫來支撐,我只用過 in-house 的產品,無從比較並推薦市面上常見的 C++ 網路庫,抱歉。
程式裡具體用幾個 loop、執行緒池的大小等引數需要根據應用來設定,基本的原則是“阻抗匹配”,使得 CPU 和 IO 都能高效地運作,具體的考慮點容我以後再談。
這裡沒有談執行緒的退出,留待下一篇 blog“多執行緒程式設計反模式”探討。
此外,程式裡或許還有個別執行特殊任務的執行緒,比如 logging
,這對應用程式來說基本是不可見的,但是在分配資源(CPU 和 IO)的時候要算進去,以免高估了系統的容量。
3. 程序間通訊與執行緒間通訊
Linux 下程序間通訊 (IPC) 的方式數不勝數,光 UNPv2 列出的就有:pipe、FIFO、POSIX 訊息佇列、共享記憶體、訊號 (signals) 等等,更不必說 Sockets 了。同步原語 (synchronization primitives) 也很多,互斥器 (mutex)、條件變數 (condition variable)、讀寫鎖 (reader-writer lock)、檔案鎖 (Record locking)、訊號量 (Semaphore) 等等。
如何選擇呢?根據我的個人經驗,貴精不貴多,認真挑選三四樣東西就能完全滿足我的工作需要,而且每樣我都能用得很熟,,不容易犯錯。
程序間通訊
程序間通訊我首選 Sockets(主要指 TCP,我沒有用過 UDP,也不考慮 Unix domain 協議),其最大的好處在於:可以跨主機,具有伸縮性。反正都是多程序了,如果一臺機器處理能力不夠,很自然地就能用多臺機器來處理。把程序分散到同一區域網的多臺機器上,程式改改 host:port 配置就能繼續用。相反,前面列出的其他 IPC 都不能跨機器(比如共享記憶體效率最高,但再怎麼著也不能高效地共享兩臺機器的記憶體),限制了 scalability。
在程式設計上,TCP sockets 和 pipe 都是一個檔案描述符,用來收發位元組流,都可以 read/write/fcntl/select/poll 等。不同的是,TCP 是雙向的,pipe 是單向的 (Linux),程序間雙向通訊還得開兩個檔案描述符,不方便;而且程序要有父子關係才能用 pipe,這些都限制了 pipe 的使用。在收發位元組流這一通訊模型下,沒有比 sockets/TCP 更自然的 IPC 了。當然,pipe 也有一個經典應用場景,那就是寫 Reactor/Selector 時用來非同步喚醒 select (或等價的 poll/epoll) 呼叫(Sun JVM 在 Linux 就是這麼做的)。
TCP port 是由一個程序獨佔,且作業系統會自動回收(listening port 和已建立連線的 TCP socket 都是檔案描述符,在程序結束時作業系統會關閉所有檔案描述符)。這說明,即使程式意外退出,也不會給系統留下垃圾,程式重啟之後能比較容易地恢復,而不需要重啟作業系統(用跨程序的 mutex 就有這個風險)。還有一個好處,既然 port 是獨佔的,那麼可以防止程式重複啟動(後面那個程序搶不到 port,自然就沒法工作了),造成意料之外的結果。
兩個程序通過 TCP 通訊,如果一個崩潰了,作業系統會關閉連線,這樣另一個程序幾乎立刻就能感知,可以快速 failover。當然,應用層的心跳也是必不可少的,我以後在講服務端的日期與時間處理的時候還會談到心跳協議的設計。
與其他 IPC 相比,TCP 協議的一個自然好處是可記錄可重現
,tcpdump/Wireshark 是解決兩個程序間協議/狀態爭端的好幫手。
另外,如果網路庫帶“連線重試”功能的話,我們可以不要求系統裡的程序以特定的順序啟動,任何一個程序都能單獨重啟,這對開發牢靠的分散式系統意義重大。
使用 TCP 這種位元組流 (byte stream) 方式通訊,會有 marshal/unmarshal 的開銷,這要求我們選用合適的訊息格式,準確地說是 wire format。這將是我下一篇 blog 的主題,目前我推薦 Google Protocol Buffers
。
有人或許會說,具體問題具體分析,如果兩個程序在同一臺機器,就用共享記憶體,否則就用 TCP,比如 MS SQL Server 就同時支援這兩種通訊方式。我問,是否值得為那麼一點效能提升而讓程式碼的複雜度大大增加呢?TCP 是位元組流協議,只能順序讀取,有寫緩衝;共享記憶體是訊息協議,a 程序填好一塊記憶體讓 b 程序來讀,基本是“停等”方式。要把這兩種方式揉到一個程式裡,需要建一個抽象層,封裝兩種 IPC。這會帶來不透明性,並且增加測試的複雜度,而且萬一通訊的某一方崩潰,狀態 reconcile 也會比 sockets 麻煩。為我所不取。再說了,你捨得讓幾萬塊買來的 SQL Server 和你的程式分享機器資源嗎?產品裡的資料庫伺服器往往是獨立的高配置伺服器,一般不會同時執行其他佔資源的程式。
TCP 本身是個資料流協議,除了直接使用它來通訊,還可以在此之上構建 RPC/REST/SOAP 之類的上層通訊協議,這超過了本文的範圍。另外,除了點對點的通訊之外,應用級的廣播協議也是非常有用的,可以方便地構建可觀可控的分散式系統。