原文地址:http://www.cnblogs.com/Solstice/archive/2010/02/12/multithreaded_server.htmlhtml
多線程服務器的經常使用編程模型nginx
陳碩 (giantchen_AT_gmail)程序員
Blog.csdn.net/Solstice算法
2010 Feb 12數據庫
本文 PDF 版下載: http://files.cppblog.com/Solstice/multithreaded_server.pdf編程
本文主要講我我的在多線程開發方面的一些粗淺經驗。總結了一兩種經常使用的線程模型,概括了進程間通信與線程同步的最佳實踐,以期用簡單規範的方式開發多線程程序。數組
文中的「多線程服務器」是指運行在 Linux 操做系統上的獨佔式網絡應用程序。硬件平臺爲 Intel x64 系列的多核 CPU,單路或雙路 SMP 服務器(每臺機器一共擁有四個核或八個核,十幾 GB 內存),機器之間用百兆或千兆以太網鏈接。這大概是目前民用 PC 服務器的主流配置。安全
本文不涉及 Windows 系統,不涉及人機交互界面(不管命令行或圖形);不考慮文件讀寫(往磁盤寫 log 除外),不考慮數據庫操做,不考慮 Web 應用;不考慮低端的單核主機或嵌入式系統,不考慮手持式設備,不考慮專門的網絡設備,不考慮高端的 >=32 核 Unix 主機;只考慮 TCP,不考慮 UDP,也不考慮除了局域網絡以外的其餘數據收發方式(例如串並口、USB口、數據採集板卡、實時控制等)。性能優化
有了以上這麼多限制,那麼我將要談的「網絡應用程序」的基本功能能夠概括爲「收到數據,算一算,再發出去」。在這個簡化了的模型裏,彷佛看不出用多線程的必要,單線程應該也能作得很好。「爲何須要寫多線程程序」這個問題容易引起口水戰,我放到另外一篇博客裏討論。請容許我先假定「多線程編程」這一背景。服務器
「服務器」這個詞有時指程序,有時指進程,有時指硬件(不管虛擬的或真實的),請注意按上下文區分。另外,本文不考慮虛擬化的場景,當我說「兩個進程不在同一臺機器上」,指的是邏輯上不在同一個操做系統裏運行,雖然物理上可能位於同一機器虛擬出來的兩臺「虛擬機」上。
本文假定讀者已經有多線程編程的知識與經驗,這不是一篇入門教程。
本文承蒙 Milo Yip 先生審讀,在此深表謝意。固然,文中任何錯誤責任均在我。
目 錄
封裝 MutexLock、MutexLockGuard 和 Condition 11
「進程/process」是操做裏最重要的兩個概念之一(另外一個是文件),粗略地講,一個進程是「內存中正在運行的程序」。本文的進程指的是 Linux 操做系統經過 fork() 系統調用產生的那個東西,或者 Windows 下 CreateProcess() 的產物,不是 Erlang 裏的那種輕量級進程。
每一個進程有本身獨立的地址空間 (address space),「在同一個進程」仍是「不在同一個進程」是系統功能劃分的重要決策點。Erlang 書把「進程」比喻爲「人」,我以爲十分精當,爲咱們提供了一個思考的框架。
每一個人有本身的記憶 (memory),人與人經過談話(消息傳遞)來交流,談話既能夠是面談(同一臺服務器),也能夠在電話裏談(不一樣的服務器,有網絡通訊)。面談和電話談的區別在於,面談能夠當即知道對方死否死了(crash, SIGCHLD),而電話談只能經過週期性的心跳來判斷對方是否還活着。
有了這些比喻,設計分佈式系統時能夠採起「角色扮演」,團隊裏的幾我的各自扮演一個進程,人的角色由進程的代碼決定(管登錄的、管消息分發的、管買賣的等等)。每一個人有本身的記憶,但不知作別人的記憶,要想知作別人的見解,只能經過交談。(暫不考慮共享內存這種 IPC。)而後就能夠思考容錯(萬一有人忽然死了)、擴容(新人中途加進來)、負載均衡(把 a 的活兒挪給 b 作)、退休(a 要修復 bug,先別給他派新活兒,等他作完手上的事情就把他重啓)等等各類場景,十分便利。
「線程」這個概念大概是在 1993 年之後才慢慢流行起來的,距今不過十餘年,比不得有 40 年光輝歷史的 Unix 操做系統。線程的出現給 Unix 添了很多亂,不少 C 庫函數(strtok(), ctime())不是線程安全的,須要從新定義;signal 的語意也大爲複雜化。據我所知,最先支持多線程編程的(民用)操做系統是 Solaris 2.2 和 Windows NT 3.1,它們均發佈於 1993 年。隨後在 1995 年,POSIX threads 標準確立。
線程的特色是共享地址空間,從而能夠高效地共享數據。一臺機器上的多個進程能高效地共享代碼段(操做系統能夠映射爲一樣的物理內存),但不能共享數據。若是多個進程大量共享內存,等因而把多進程程序當成多線程來寫,掩耳盜鈴。
「多線程」的價值,我認爲是爲了更好地發揮對稱多路處理 (SMP) 的效能。在 SMP 以前,多線程沒有多大價值。Alan Cox 說過 A computer is a state machine. Threads are for people who can't program state machines. (計算機是一臺狀態機。線程是給那些不能編寫狀態機程序的人準備的。)若是隻有一個執行單元,一個 CPU,那麼確實如 Alan Cox 所說,按狀態機的思路去寫程序是最高效的,這正好也是下一節展現的編程模型。
UNP3e 對此有很好的總結(第 6 章:IO 模型,第 30 章:客戶端/服務器設計範式),這裏再也不贅述。據我瞭解,在高性能的網絡程序中,使用得最爲普遍的恐怕要數「non-blocking IO + IO multiplexing」這種模型,即 Reactor 模式,我知道的有:
相反,boost::asio 和 Windows I/O Completion Ports 實現了 Proactor 模式,應用面彷佛要窄一些。固然,ACE 也實現了 Proactor 模式,不表。
在「non-blocking IO + IO multiplexing」這種模型下,程序的基本結構是一個事件循環 (event loop):(代碼僅爲示意,沒有完整考慮各類狀況)
1
2
3
4
5
6
7
8
9
10
11
12
13
|
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 不是那麼容易,我也沒有用過坊間開源的庫,這裏就不推薦了。
這方面我能找到的文獻很少,大概有這麼幾種:
1. 每一個請求建立一個線程,使用阻塞式 IO 操做。在 Java 1.4 引入 NIO 以前,這是 Java 網絡編程的推薦作法。惋惜伸縮性不佳。
2. 使用線程池,一樣使用阻塞式 IO 操做。與 1 相比,這是提升性能的措施。
3. 使用 non-blocking IO + IO multiplexing。即 Java NIO 的方式。
4. Leader/Follower 等高級模式
在默認狀況下,我會使用第 3 種,即 non-blocking IO + one loop per thread 模式。
http://pod.tst.eu/http://cvs.schmorp.de/libev/ev.pod#THREADS_AND_COROUTINES
此種模型下,程序裏的每一個 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):
1
2
3
4
5
6
7
8
9
|
blocking_queue<boost::function<
void
()> > taskQueue;
// 線程安全的阻塞隊列
void
worker_thread()
{
while
(!quit) {
boost::function<
void
()> task = taskQueue.take();
// this blocks
task();
// 在產品代碼中須要考慮異常處理
}
}
|
用這種方式實現線程池特別容易:
1
2
3
4
5
|
// 啓動容量爲 N 的線程池:
int
N = num_of_computing_threads;
for
(
int
i = 0; i < N; ++i) {
create_thread(&worker_thread);
// 僞代碼:啓動線程
}
|
使用起來也很簡單:
1
2
|
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<T> 是多線程編程的利器,它的實現可參照 Java 5 util.concurrent 裏的 (Array|Linked)BlockingQueue,一般 C++ 能夠用 deque 來作底層的容器。Java 5 裏的代碼可讀性很高,代碼的基本結構和教科書一致(1 個 mutex,2 個 condition variables),健壯性要高得多。若是不想本身實現,用現成的庫更好。(我沒有用過免費的庫,這裏就不亂推薦了,有興趣的同窗能夠試試 Intel Threading Building Blocks 裏的 concurrent_queue<T>。)
總結起來,我推薦的多線程服務端編程模式爲:event loop per thread + thread pool。
以這種方式寫服務器程序,須要一個優質的基於 Reactor 模式的網絡庫來支撐,我只用過 in-house 的產品,無從比較並推薦市面上常見的 C++ 網絡庫,抱歉。
程序裏具體用幾個 loop、線程池的大小等參數須要根據應用來設定,基本的原則是「阻抗匹配」,使得 CPU 和 IO 都能高效地運做,具體的考慮點容我之後再談。
這裏沒有談線程的退出,留待下一篇 blog「多線程編程反模式」探討。
此外,程序裏或許還有個別執行特殊任務的線程,好比 logging,這對應用程序來講基本是不可見的,可是在分配資源(CPU 和 IO)的時候要算進去,以避免高估了系統的容量。
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 之類的上層通訊協議,這超過了本文的範圍。另外,除了點對點的通訊以外,應用級的廣播協議也是很是有用的,能夠方便地構建可觀可控的分佈式系統。
本文不具體講 Reactor 方式下的網絡編程,其實這裏邊有不少值得注意的地方,好比帶 back off 的 retry connecting,用優先隊列來組織 timer 等等,留做之後分析吧。
線程同步的四項原則,按重要性排列:
1. 首要原則是儘可能最低限度地共享對象,減小須要同步的場合。一個對象能不暴露給別的線程就不要暴露;若是要暴露,優先考慮 immutable 對象;實在不行才暴露可修改的對象,並用同步措施來充分保護它。
2. 其次是使用高級的併發編程構件,如 TaskQueue、Producer-Consumer Queue、CountDownLatch 等等;
3. 最後不得已必須使用底層同步原語 (primitives) 時,只用非遞歸的互斥器和條件變量,偶爾用一用讀寫鎖;
4. 不本身編寫 lock-free 代碼,不去憑空猜想「哪一種作法性能會更好」,好比 spin lock vs. mutex。
前面兩條很容易理解,這裏着重講一下第 3 條:底層同步原語的使用。
互斥器 (mutex) 恐怕是使用得最多的同步原語,粗略地說,它保護了臨界區,一個時刻最多隻能有一個線程在臨界區內活動。(請注意,我談的是 pthreads 裏的 mutex,不是 Windows 裏的重量級跨進程 Mutex。)單獨使用 mutex 時,咱們主要爲了保護共享數據。我我的的原則是:
次要原則有:
用 RAII 封裝這幾個操做是通行的作法,這幾乎是 C++ 的標準實踐,後面我會給出具體的代碼示例,相信你們都已經寫過或用過相似的代碼了。Java 裏的 synchronized 語句和 C# 的 using 語句也有相似的效果,即保證鎖的生效期間等於一個做用域,不會因異常而忘記解鎖。
Mutex 恐怕是最簡單的同步原語,安裝上面的幾條原則,幾乎不可能用錯。我本身歷來沒有違背過這些原則,編碼時出現問題都很快能招到並修復。
談談我堅持使用非遞歸的互斥器的我的想法。
Mutex 分爲遞歸 (recursive) 和非遞歸(non-recursive)兩種,這是 POSIX 的叫法,另外的名字是可重入 (Reentrant) 與非可重入。這兩種 mutex 做爲線程間 (inter-thread) 的同步工具時沒有區別,它們的唯一區別在於:同一個線程能夠重複對 recursive mutex 加鎖,可是不能重複對 non-recursive mutex 加鎖。
首選非遞歸 mutex,絕對不是爲了性能,而是爲了體現設計意圖。non-recursive 和 recursive 的性能差異其實不大,由於少用一個計數器,前者略快一點點而已。在同一個線程裏屢次對 non-recursive mutex 加鎖會馬上致使死鎖,我認爲這是它的優勢,能幫助咱們思考代碼對鎖的期求,而且及早(在編碼階段)發現問題。
毫無疑問 recursive mutex 使用起來要方便一些,由於不用考慮一個線程會本身把本身給鎖死了,我猜這也是 Java 和 Windows 默認提供 recursive mutex 的緣由。(Java 語言自帶的 intrinsic lock 是可重入的,它的 concurrent 庫裏提供 ReentrantLock,Windows 的 CRITICAL_SECTION 也是可重入的。彷佛它們都不提供輕量級的 non-recursive mutex。)
正由於它方便,recursive mutex 可能會隱藏代碼裏的一些問題。典型狀況是你覺得拿到一個鎖就能修改對象了,沒想到外層代碼已經拿到了鎖,正在修改(或讀取)同一個對象呢。具體的例子:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
|
std::vector<Foo> foos;
MutexLock mutex;
void
post(
const
Foo& f)
{
MutexLockGuard lock(mutex);
foos.push_back(f);
}
void
traverse()
{
MutexLockGuard lock(mutex);
for
(
auto
it = foos.begin(); it != foos.end(); ++it) {
// 用了 0x 新寫法
it->doit();
}
}
|
post() 加鎖,而後修改 foos 對象; traverse() 加鎖,而後遍歷 foos 數組。未來有一天,Foo::doit() 間接調用了 post() (這在邏輯上是錯誤的),那麼會頗有戲劇性的:
1. Mutex 是非遞歸的,因而死鎖了。
2. Mutex 是遞歸的,因爲 push_back 可能(但不老是)致使 vector 迭代器失效,程序偶爾會 crash。
這時候就能體現 non-recursive 的優越性:把程序的邏輯錯誤暴露出來。死鎖比較容易 debug,把各個線程的調用棧打出來((gdb) thread apply all bt),只要每一個函數不是特別長,很容易看出來是怎麼死的。(另外一方面支持了函數不要寫過長。)或者能夠用 PTHREAD_MUTEX_ERRORCHECK 一會兒就能找到錯誤(前提是 MutexLock 帶 debug 選項。)
程序反正要死,不如死得有意義一點,讓驗屍官的日子好過些。
若是一個函數既可能在已加鎖的狀況下調用,又可能在未加鎖的狀況下調用,那麼就拆成兩個函數:
1. 跟原來的函數同名,函數加鎖,轉而調用第 2 個函數。
2. 給函數名加上後綴 WithLockHold,不加鎖,把原來的函數體搬過來。
就像這樣:
1
2
3
4
5
6
7
8
9
10
11
|
void
post(
const
Foo& f)
{
MutexLockGuard lock(mutex);
postWithLockHold(f);
// 不用擔憂開銷,編譯器會自動內聯的
}
// 引入這個函數是爲了體現代碼做者的意圖,儘管 push_back 一般能夠手動內聯
void
postWithLockHold(
const
Foo& f)
{
foos.push_back(f);
}
|
這有可能出現兩個問題(感謝水木網友 ilovecpp 提出):a) 誤用了加鎖版本,死鎖了。b) 誤用了不加鎖版本,數據損壞了。
對於 a),仿造前面的辦法能比較容易地排錯。對於 b),若是 pthreads 提供 isLocked() 就好辦,能夠寫成:
1
2
3
4
5
|
void
postWithLockHold(
const
Foo& f)
{
assert
(mutex.isLocked());
// 目前只是一個願望
// ...
}
|
另外,WithLockHold 這個顯眼的後綴也讓程序中的誤用容易暴露出來。
C++ 沒有 annotation,不能像 Java 那樣給 method 或 field 標上 @GuardedBy 註解,須要程序員本身當心在乎。雖然這裏的辦法不能一勞永逸地解決所有多線程錯誤,但能幫上一點是一點了。
我尚未遇到過須要使用 recursive mutex 的狀況,我想未來遇到了均可以藉助 wrapper 改用 non-recursive mutex,代碼只會更清晰。
=== 回到正題 ===
本文這裏只談了 mutex 自己的正確使用,在 C++ 裏多線程編程還會遇到其餘不少 race condition,請參考拙做《當析構函數遇到多線程——C++ 中線程安全的對象回調》
http://blog.csdn.net/Solstice/archive/2010/01/22/5238671.aspx 。請注意這裏的 class 命名與那篇文章有所不一樣。我如今認爲 MutexLock 和 MutexLockGuard 是更好的名稱。
性能註腳:Linux 的 pthreads mutex 採用 futex 實現,沒必要每次加鎖解鎖都陷入系統調用,效率不錯。Windows 的 CRITICAL_SECTION 也是相似。
條件變量 (condition variable) 顧名思義是一個或多個線程等待某個布爾表達式爲真,即等待別的線程「喚醒」它。條件變量的學名叫管程 (monitor)。Java Object 內置的 wait(), notify(), notifyAll() 便是條件變量(它們以容易用錯著稱)。條件變量只有一種正確使用的方式,對於 wait() 端:
1. 必須與 mutex 一塊兒使用,該布爾表達式的讀寫需受此 mutex 保護
2. 在 mutex 已上鎖的時候才能調用 wait()
3. 把判斷布爾條件和 wait() 放到 while 循環中
寫成代碼是:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
|
MutexLock mutex;
Condition cond(mutex);
std::deque<
int
> queue;
int
dequeue()
{
MutexLockGuard lock(mutex);
while
(queue.empty()) {
// 必須用循環;必須在判斷以後再 wait()
cond.wait();
// 這一步會原子地 unlock mutex 並進入 blocking,不會與 enqueue 死鎖
}
assert
(!queue.empty());
int
top = queue.front();
queue.pop_front();
return
top;
}
|
對於 signal/broadcast 端:
1. 不必定要在 mutex 已上鎖的狀況下調用 signal (理論上)
2. 在 signal 以前通常要修改布爾表達式
3. 修改布爾表達式一般要用 mutex 保護(至少用做 full memory barrier)
寫成代碼是:
1
2
3
4
5
6
|
void
enqueue(
int
x)
{
MutexLockGuard lock(mutex);
queue.push_back(x);
cond.notify();
}
|
上面的 dequeue/enqueue 實際上實現了一個簡單的 unbounded BlockingQueue。
條件變量是很是底層的同步原語,不多直接使用,通常都是用它來實現高層的同步措施,如 BlockingQueue 或 CountDownLatch。
讀寫鎖 (Reader-Writer lock),讀寫鎖是個優秀的抽象,它明確區分了 read 和 write 兩種行爲。須要注意的是,reader lock 是可重入的,writer lock 是不可重入(包括不可提高 reader lock)的。這正是我說它「優秀」的主要緣由。
遇到併發讀寫,若是條件合適,我會用《借 shared_ptr 實現線程安全的 copy-on-write》http://blog.csdn.net/Solstice/archive/2008/11/22/3351751.aspx 介紹的辦法,而不用讀寫鎖。固然這不是絕對的。
信號量 (Semaphore),我沒有遇到過須要使用信號量的狀況,無從談及我的經驗。
說一句大逆不道的話,若是程序裏須要解決如「哲學家就餐」之類的複雜 IPC 問題,我認爲應該首先考察幾個設計,爲何線程之間會有如此複雜的資源爭搶(一個線程要同時搶到兩個資源,一個資源能夠被兩個線程爭奪)?能不能把「想吃飯」這個事情專門交給一個爲各位哲學家分派餐具的線程來作,而後每一個哲學家等在一個簡單的 condition variable 上,到時間了有人通知他去吃飯?從哲學上說,教科書上的解決方案是平權,每一個哲學家有本身的線程,本身去拿筷子;我寧願用集權的方式,用一個線程專門管餐具的分配,讓其餘哲學家線程拿個號等在食堂門口好了。這樣不損失多少效率,卻讓程序簡單不少。雖然 Windows 的 WaitForMultipleObjects 讓這個問題 trivial 化,在 Linux 下正確模擬 WaitForMultipleObjects 不是普通程序員該乾的。
本節把前面用到的 MutexLock、MutexLockGuard、Condition classes 的代碼列出來,前面兩個 classes 沒多大難度,後面那個有點意思。
MutexLock 封裝臨界區(Critical secion),這是一個簡單的資源類,用 RAII 手法 [CCS:13]封裝互斥器的建立與銷燬。臨界區在 Windows 上是 CRITICAL_SECTION,是可重入的;在 Linux 下是 pthread_mutex_t,默認是不可重入的。MutexLock 通常是別的 class 的數據成員。
MutexLockGuard 封裝臨界區的進入和退出,即加鎖和解鎖。MutexLockGuard 通常是個棧上對象,它的做用域恰好等於臨界區域。
這兩個 classes 應該能在紙上默寫出來,沒有太多須要解釋的:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
|
#include <pthread.h>
#include <boost/noncopyable.hpp>
class
MutexLock : boost::noncopyable
{
public
:
MutexLock()
// 爲了節省版面,單行函數都沒有正確縮進
{ pthread_mutex_init(&mutex_, NULL); }
~MutexLock()
{ pthread_mutex_destroy(&mutex_); }
void
lock()
// 程序通常不主動調用
{ pthread_mutex_lock(&mutex_); }
void
unlock()
// 程序通常不主動調用
{ pthread_mutex_unlock(&mutex_); }
pthread_mutex_t* getPthreadMutex()
// 僅供 Condition 調用,嚴禁本身調用
{
return
&mutex_; }
private
:
pthread_mutex_t mutex_;
};
class
MutexLockGuard : boost::noncopyable
{
public
:
explicit
MutexLockGuard(MutexLock& mutex) : mutex_(mutex)
{ mutex_.lock(); }
~MutexLockGuard()
{ mutex_.unlock(); }
private
:
MutexLock& mutex_;
};
#define MutexLockGuard(x) static_assert(false, "missing mutex guard var name")
|
注意代碼的最後一行定義了一個宏,這個宏的做用是防止程序裏出現以下錯誤:
1
2
3
4
5
6
7
|
void
doit()
{
MutexLockGuard(mutex);
// 沒有變量名,產生一個臨時對象又立刻銷燬了,沒有鎖住臨界區
// 正確寫法是 MutexLockGuard lock(mutex);
// 臨界區
}
|
這裏 MutexLock 沒有提供 trylock() 函數,由於我沒有用過它,我想不出何時程序須要「試着去鎖一鎖」,或許我寫過的代碼太簡單了。
我見過有人把 MutexLockGuard 寫成 template,我沒有這麼作是由於它的模板類型參數只有 MutexLock 一種可能,沒有必要隨意增長靈活性,因而我人肉把模板具現化 (instantiate) 了。此外一種更激進的寫法是,把 lock/unlock 放到 private 區,而後把 Guard 設爲 MutexLock 的 friend,我認爲在註釋裏告知程序員便可,另外 check-in 以前的 code review 也很容易發現誤用的狀況 (grep getPthreadMutex)。
這段代碼沒有達到工業強度:a) Mutex 建立爲 PTHREAD_MUTEX_DEFAULT 類型,而不是咱們預想的 PTHREAD_MUTEX_NORMAL 類型(實際上這兩者極可能是等同的),嚴格的作法是用 mutexattr 來顯示指定 mutex 的類型。b) 沒有檢查返回值。這裏不能用 assert 檢查返回值,由於 assert 在 release build 裏是空語句。咱們檢查返回值的意義在於防止 ENOMEM 之類的資源不足狀況,這通常只可能在負載很重的產品程序中出現。一旦出現這種錯誤,程序必須馬上清理現場並主動退出,不然會莫名其妙地崩潰,給過後調查形成困難。這裏咱們須要 non-debug 的 assert,或許 google-glog 的 CHECK() 是個不錯的思路。
以上兩點改進留做練習。
Condition class 的實現有點意思。
Pthreads condition variable 容許在 wait() 的時候指定 mutex,可是我想不出什麼理由一個 condition variable 會和不一樣的 mutex 配合使用。Java 的 intrinsic condition 和 Conditon class 都不支持這麼作,所以我以爲能夠放棄這一靈活性,老老實實一對一好了。相反 boost::thread 的 condition_varianle 是在 wait 的時候指定 mutex,請參觀其同步原語的龐雜設計:
恕我愚鈍,見到 boost::thread 這樣如 Rube Goldberg Machine 同樣「靈活」的庫我只得三揖繞道而行。這些 class 名字也很無厘頭,爲何不老老實實用 reader_writer_lock 這樣的通俗名字呢?非得增長精神負擔,本身發明新名字。我不肯爲這樣的靈活性付出代價,寧願本身作幾個簡簡單單的一看就明白的 classes 來用,這種簡單的幾行代碼的輪子造造也無妨。提供靈活性當然是本事,然而在不須要靈活性的地方把代碼寫死,更須要大智慧。
下面這個 Condition 簡單地封裝了 pthread cond var,用起來也容易,見本節前面的例子。這裏我用 notify/notifyAll 做爲函數名,由於 signal 有別的含義,C++ 裏的 signal/slot,C 裏的 signal handler 等等。就別 overload 這個術語了。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
|
class
Condition : boost::noncopyable
{
public
:
Condition(MutexLock& mutex) : mutex_(mutex)
{ pthread_cond_init(&pcond_, NULL); }
~Condition()
{ pthread_cond_destroy(&pcond_); }
void
wait()
{ pthread_cond_wait(&pcond_, mutex_.getPthreadMutex()); }
void
notify()
{ pthread_cond_signal(&pcond_); }
void
notifyAll()
{ pthread_cond_broadcast(&pcond_); }
private
:
MutexLock& mutex_;
pthread_cond_t pcond_;
};
|
若是一個 class 要包含 MutexLock 和 Condition,請注意它們的聲明順序和初始化順序,mutex_ 應先於 condition_ 構造,並做爲後者的構造參數:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
|
class
CountDownLatch
{
public
:
CountDownLatch(
int
count)
: count_(count),
mutex_(),
condition_(mutex_)
{ }
private
:
int
count_;
MutexLock mutex_;
// 順序很重要
Condition condition_;
};
|
請容許我再次強調,雖然本節花了大量篇幅介紹如何正確使用 mutex 和 condition variable,但並不表明我鼓勵處處使用它們。這二者都是很是底層的同步原語,主要用來實現更高級的併發編程工具,一個多線程程序裏若是大量使用 mutex 和 condition variable 來同步,基本跟用鉛筆刀鋸大樹(孟巖語)沒啥區別。
在程序裏使用 pthreads 庫有一個額外的好處:分析工具認得它們,懂得其語意。線程分析工具如 Intel Thread Checker 和 Valgrind-Helgrind 等能識別 pthreads 調用,並依據 happens-before 關係 [Lamport 1978] 分析程序有無 data race。
研究 Signleton 的線程安全實現的歷史你會發現不少有意思的事情,一度人們認爲 Double checked locking 是王道,兼顧了效率與正確性。後來有神牛指出因爲亂序執行的影響,DCL 是靠不住的。(這個又讓我想起了 SQL 注入,十年前用字符串拼接出 SQL 語句是 Web 開發的通行作法,直到有一天有人利用這個漏洞越權得到並修改網站數據,人們才幡然醒悟,趕忙修補。)Java 開發者還算幸運,能夠藉助內部靜態類的裝載來實現。C++ 就比較慘,要麼次次鎖,要麼 eager initialize、或者動用 memory barrier 這樣的大殺器( http://www.aristeia.com/Papers/DDJ_Jul_Aug_2004_revised.pdf )。接下來 Java 5 修訂了內存模型,並加強了 volatile 的語義,這下 DCL (with volatile) 又是安全的了。然而 C++ 的內存模型還在修訂中,C++ 的 volatile 目前還不能(未來也難說)保證 DCL 的正確性(只在 VS2005+ 上有效)。
其實沒那麼麻煩,在實踐中用 pthread once 就行:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
|
#include <pthread.h>
template
<
typename
T>
class
Singleton : boost::noncopyable
{
public
:
static
T& instance()
{
pthread_once(&ponce_, &Singleton::init);
return
*value_;
}
static
void
init()
{
value_ =
new
T();
}
private
:
static
pthread_once_t ponce_;
static
T* value_;
};
template
<
typename
T>
pthread_once_t Singleton<T>::ponce_ = PTHREAD_ONCE_INIT;
template
<
typename
T>
T* Singleton<T>::value_ = NULL;
|
上面這個 Singleton 沒有任何花哨的技巧,用 pthread_once_t 來保證 lazy-initialization 的線程安全。使用方法也很簡單:
Foo& foo = Singleton<Foo>::instance();
固然,這個 Singleton 沒有考慮對象的銷燬,在服務器程序裏,這不是一個問題,由於當程序退出的時候天然就釋放全部資源了(前提是程序裏不使用不能由操做系統自動關閉的資源,好比跨進程的 Mutex)。另外,這個 Singleton 只能調用默認構造函數,若是用戶想要指定 T 的構造方式,咱們能夠用模板特化 (template specialization) 技術來提供一個定製點,這須要引入另外一層間接。
用好這幾樣東西,基本上能應付多線程服務端開發的各類場合,只是或許有人會以爲性能沒有發揮到極致。我認爲,先把程序寫正確了,再考慮性能優化,這在多線程下任然成立。讓一個正確的程序變快,遠比「讓一個快的程序變正確」容易得多。
在現代的多核計算背景下,線程是不可避免的。儘管必定程度上能夠經過 framework 來屏蔽,讓你感受像是在寫單線程程序,好比 Java Servlet。瞭解 under the hood 發生了什麼對於編寫這種程序也會有幫助。
多線程編程是一項重要的我的技能,不能由於它難就本能地排斥,如今的軟件開發比起 10 年 20 年前已經難了不知道多少倍。掌握多線程編程,才能更理智地選擇用仍是不用多線程,由於你能預估多線程實現的難度與收益,在一開始作出正確的選擇。要知道把一個單線程程序改爲多線程的,每每比重頭實現一個多線程的程序更難。
掌握同步原語和它們的適用場合時多線程編程的基本功。以個人經驗,熟練使用文中提到的同步原語,就能比較容易地編寫線程安全的程序。本文沒有考慮 signal 對多線程編程的影響,Unix 的 signal 在多線程下的行爲比較複雜,通常要靠底層的網絡庫 (如 Reactor) 加以屏蔽,避免干擾上層應用程序的開發。
通篇來看,「效率」並非個人主要考慮點,a) TCP 不是效率最高的 IPC,b) 我提倡正確加鎖而不是本身編寫 lock-free 算法(使用原子操做除外)。在程序的複雜度和性能以前取得平衡,並經考慮將來兩三年擴容的可能(不管是 CPU 變快、核數變多,仍是機器數量增長,網絡升級)。下一篇「多線程編程的反模式」會考察伸縮性方面的常見錯誤,我認爲在分佈式系統中,伸縮性 (scalability) 比單機的性能優化更值得投入精力。
這篇文章記錄了我目前對多線程編程的理解,用文中介紹的手法,我能解決本身面臨的所有多線程編程任務。若是文章的觀點與您不合,好比您使用了我沒有推薦使用的技術或手法(共享內存、信號量等等),只要您理由充分,但行無妨。
這篇文章原本還有兩節「多線程編程的反模式」與「多線程的應用場景」,考慮到字數已經超過一萬了,且聽下回分解吧 :-)
我認爲 sleep 只能出如今測試代碼中,好比寫單元測試的時候。(涉及時間的單元測試不那麼好寫,短的如一兩秒鐘能夠用 sleep,長的如一小時一天得想其餘辦法,好比把算法提出來並把時間注入進去。)產品代碼中線程的等待可分爲兩種:一種是無所事事的時候(要麼等在 select/poll/epoll 上。要麼等在 condition variable 上,等待 BlockingQueue /CountDownLatch 亦可納入此類),一種是等着進入臨界區(等在 mutex 上)以便繼續處理。在程序的正常執行中,若是須要等待一段時間,應該往 event loop 裏註冊一個 timer,而後在 timer 的回調函數裏接着幹活,由於線程是個珍貴的共享資源,不能輕易浪費。若是多線程的安全性和效率要靠代碼主動調用 sleep 來保證,這是設計出了問題。等待一個事件發生,正確的作法是用 select 或 condition variable 或(更理想地)高層同步工具。固然,在 GUI 編程中會有主動讓出 CPU 的作法,好比調用 sleep(0) 來實現 yield。