• <td id="ae6ms"><li id="ae6ms"></li></td>
  • <xmp id="ae6ms"><td id="ae6ms"></td><table id="ae6ms"></table>
  • <table id="ae6ms"></table>
  • <td id="ae6ms"></td>
    <td id="ae6ms"></td>
  • <table id="ae6ms"></table><table id="ae6ms"><td id="ae6ms"></td></table>
  • <td id="ae6ms"></td>
  • <table id="ae6ms"><li id="ae6ms"></li></table>
  • <table id="ae6ms"></table>
    西西軟件園多重安全檢測下載網站、值得信賴的軟件下載站!
    軟件
    軟件
    文章
    搜索

    首頁西西教程網站推廣 → 多線程服務器的常用編程模型

    多線程服務器的常用編程模型

    相關軟件相關文章發表評論 來源:本站原創時間:2010/2/13 12:26:23字體大?。?em class="fontsize">A-A+

    作者:陳碩點擊:736次評論:0次標簽: 服務器

    飛信2017V5.6.8860.0 官方正式版
    • 類型:聊天其它大?。?i>69.1M語言:中文 評分:9.6
    • 標簽:
    立即下載

    本文主要講我個人在多線程開發方面的一些粗淺經驗??偨Y了一兩種常用的線程模型,歸納了進程間通訊與線程同步的最佳實踐,以期用簡單規范的方式開發多線程程序。

    文中的“多線程服務器”是指運行在 Linux 操作系統上的獨占式網絡應用程序。硬件平臺為 Intel x64 系列的多核 CPU,單路或雙路 SMP 服務器(每臺機器一共擁有四個核或八個核,十幾 GB 內存),機器之間用百兆或千兆以太網連接。這大概是目前民用 PC 服務器的主流配置。

    本文不涉及 Windows 系統,不涉及人機交互界面(無論命令行或圖形);不考慮文件讀寫(往磁盤寫 log 除外),不考慮數據庫操作,不考慮 Web 應用;不考慮低端的單核主機或嵌入式系統,不考慮手持式設備,不考慮專門的網絡設備,不考慮高端的 >=32 核 Unix 主機;只考慮 TCP,不考慮 UDP,也不考慮除了局域網絡之外的其他數據收發方式(例如串并口、USB口、數據采集板卡、實時控制等)。

    有了以上這么多限制,那么我將要談的“網絡應用程序”的基本功能可以歸納為“收到數據,算一算,再發出去”。在這個簡化了的模型里,似乎看不出用多線程的必要,單線程應該也能做得很好。“為什么需要寫多線程程序”這個問題容易引發口水戰,我放到另一篇博客里討論。請允許我先假定“多線程編程”這一背景。

    “服務器”這個詞有時指程序,有時指進程,有時指硬件(無論虛擬的或真實的),請注意按上下文區分。另外,本文不考慮虛擬化的場景,當我說“兩個進程不在同一臺機器上”,指的是邏輯上不在同一個操作系統里運行,雖然物理上可能位于同一機器虛擬出來的兩臺“虛擬機”上。

    本文假定讀者已經有多線程編程的知識與經驗,這不是一篇入門教程。

    本文承蒙 Milo Yip 先生審讀,在此深表謝意。當然,文中任何錯誤責任均在我。

    1 進程與線程
    “進程/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 所說,按狀態機的思路去寫程序是最高效的,這正好也是下一節展示的編程模型。

    2 典型的單線程服務器編程模型
    UNP3e 對此有很好的總結(第 6 章:IO 模型,第 30 章:客戶端/服務器設計范式),這里不再贅述。據我了解,在高性能的網絡程序中,使用得最為廣泛的恐怕要數“non-blocking IO + IO multiplexing”這種模型,即 Reactor 模式,我知道的有:

    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):(代碼僅為示意,沒有完整考慮各種情況)

    01 while (!done)

    02 {

    03 int timeout_ms = max(1000, getNextTimedCallback());

    04 int retval = ::poll(fds, nfds, timeout_ms);

    05 if (retval < 0) {

    06 處理錯誤

    07 } else {

    08 處理到期的 timers

    09 if (retval > 0) {

    10 處理 IO 事件

    11 }

    12 }

    13 }

    當然,select(2)/poll(2) 有很多不足,Linux 下可替換為 epoll,其他操作系統也有對應的高性能替代品(搜 c10k problem)。

    Reactor 模型的優點很明顯,編程簡單,效率也不錯。不僅網絡讀寫可以用,連接的建立(connect/accept)甚至 DNS 解析都可以用非阻塞方式進行,以提高并發度和吞吐量 (throughput)。對于 IO 密集的應用是個不錯的選擇,Lighttpd 即是這樣,它內部的 fdevent 結構十分精妙,值得學習。(這里且不考慮用阻塞 IO 這種次優的方案。)

    當然,實現一個優質的 Reactor 不是那么容易,我也沒有用過坊間開源的庫,這里就不推薦了。

    3 典型的多線程服務器的線程模型
    這方面我能找到的文獻不多,大概有這么幾種:

    1. 每個請求創建一個線程,使用阻塞式 IO 操作。在 Java 1.4 引入 NIO 之前,這是 Java 網絡編程的推薦做法??上炜s性不佳。

    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

    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):

    1 blocking_queue<boost::function<void()> > taskQueue; // 線程安全的阻塞隊列

    2

    3 void worker_thread()

    4 {

    5 while (!quit) {

    6 boost::function<void()> task = taskQueue.take(); // this blocks

    7 task(); // 在產品代碼中需要考慮異常處理

    8 }

    9 }

    用這種方式實現線程池特別容易:

    1 // 啟動容量為 N 的線程池:

    2 int N = num_of_computing_threads;

    3 for (int i = 0; i < N; ++i) {

    4 create_thread(&worker_thread); // 偽代碼:啟動線程

    5 }

    使用起來也很簡單:

    1 boost::function<void()> task = boost::bind(&Foo::calc, this);

    2 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。

    event loop 用作 non-blocking IO 和定時器。
    thread pool 用來做計算,具體可以是任務隊列或消費者-生產者隊列。
    以這種方式寫服務器程序,需要一個優質的基于 Reactor 模式的網絡庫來支撐,我只用過 in-house 的產品,無從比較并推薦市面上常見的 C++ 網絡庫,抱歉。

    程序里具體用幾個 loop、線程池的大小等參數需要根據應用來設定,基本的原則是“阻抗匹配”,使得 CPU 和 IO 都能高效地運作,具體的考慮點容我以后再談。

    這里沒有談線程的退出,留待下一篇 blog“多線程編程反模式”探討。

    此外,程序里或許還有個別執行特殊任務的線程,比如 logging,這對應用程序來說基本是不可見的,但是在分配資源(CPU 和 IO)的時候要算進去,以免高估了系統的容量。

    4 進程間通信與線程間通信
    Linux 下進程間通信 (IPC) 的方式數不勝數,光 UNPv2 列出的就有:pipe、FIFO、POSIX 消息隊列、共享內存、信號 (signals) 等等,更不必說 Sockets 了。同步原語 (synchronization primitives) 也很多,互斥器 (mutex)、條件變量 (condition variable)、讀寫鎖 (reader-writer lock)、文件鎖 (Record locking)、信號量 (Semaphore) 等等。

    如何選擇呢?根據我的個人經驗,貴精不貴多,認真挑選三四樣東西就能完全滿足我的工作需要,而且每樣我都能用得很熟,,不容易犯錯。

    5 進程間通信
    進程間通信我首選 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 等等,留作以后分析吧。

    6 線程間同步
    線程同步的四項原則,按重要性排列:

    1. 首要原則是盡量最低限度地共享對象,減少需要同步的場合。一個對象能不暴露給別的線程就不要暴露;如果要暴露,優先考慮 immutable 對象;實在不行才暴露可修改的對象,并用同步措施來充分保護它。

    2. 其次是使用高級的并發編程構件,如 TaskQueue、Producer-Consumer Queue、CountDownLatch 等等;

    3. 最后不得已必須使用底層同步原語 (primitives) 時,只用非遞歸的互斥器和條件變量,偶爾用一用讀寫鎖;

    4. 不自己編寫 lock-free 代碼,不去憑空猜測“哪種做法性能會更好”,比如 spin lock vs. mutex。

    前面兩條很容易理解,這里著重講一下第 3 條:底層同步原語的使用。

    互斥器 (mutex)
    互斥器 (mutex) 恐怕是使用得最多的同步原語,粗略地說,它保護了臨界區,一個時刻最多只能有一個線程在臨界區內活動。(請注意,我談的是 pthreads 里的 mutex,不是 Windows 里的重量級跨進程 Mutex。)單獨使用 mutex 時,我們主要為了保護共享數據。我個人的原則是:

    用 RAII 手法封裝 mutex 的創建、銷毀、加鎖、解鎖這四個操作。
    只用非遞歸的 mutex(即不可重入的 mutex)。
    不手工調用 lock() 和 unlock() 函數,一切交給棧上的 Guard 對象的構造和析構函數負責,Guard 對象的生命期正好等于臨界區(分析對象在什么時候析構是 C++ 程序員的基本功)。這樣我們保證在同一個函數里加鎖和解鎖,避免在 foo() 里加鎖,然后跑到 bar() 里解鎖。
    在每次構造 Guard 對象的時候,思考一路上(調用棧上)已經持有的鎖,防止因加鎖順序不同而導致死鎖 (deadlock)。由于 Guard 對象是棧上對象,看函數調用棧就能分析用鎖的情況,非常便利。
    次要原則有:

    不使用跨進程的 mutex,進程間通信只用 TCP sockets。
    加鎖解鎖在同一個線程,線程 a 不能去 unlock 線程 b 已經鎖住的 mutex。(RAII 自動保證)
    別忘了解鎖。(RAII 自動保證)
    不重復解鎖。(RAII 自動保證)
    必要的時候可以考慮用 PTHREAD_MUTEX_ERRORCHECK 來排錯
    用 RAII 封裝這幾個操作是通行的做法,這幾乎是 C++ 的標準實踐,后面我會給出具體的代碼示例,相信大家都已經寫過或用過類似的代碼了。Java 里的 synchronized 語句和 C# 的 using 語句也有類似的效果,即保證鎖的生效期間等于一個作用域,不會因異常而忘記解鎖。

    Mutex 恐怕是最簡單的同步原語,安裝上面的幾條原則,幾乎不可能用錯。我自己從來沒有違背過這些原則,編碼時出現問題都很快能招到并修復。

    跑題:非遞歸的 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 可能會隱藏代碼里的一些問題。典型情況是你以為拿到一個鎖就能修改對象了,沒想到外層代碼已經拿到了鎖,正在修改(或讀?。┩粋對象呢。具體的例子:

    01 std::vector<Foo> foos;

    02 MutexLock mutex;

    03

    04 void post(const Foo& f)

    05 {

    06 MutexLockGuard lock(mutex);

    07 foos.push_back(f);

    08 }

    09

    10 void traverse()

    11 {

    12 MutexLockGuard lock(mutex);

    13 for (auto it = foos.begin(); it != foos.end(); ++it) { // 用了 0x 新寫法

    14 it->doit();

    15 }

    16 }

    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,不加鎖,把原來的函數體搬過來。

    就像這樣:

    01 void post(const Foo& f)

    02 {

    03 MutexLockGuard lock(mutex);

    04 postWithLockHold(f); // 不用擔心開銷,編譯器會自動內聯的

    05 }

    06

    07 // 引入這個函數是為了體現代碼作者的意圖,盡管 push_back 通??梢允謩觾嚷?/p>

    08 void postWithLockHold(const Foo& f)

    09 {

    10 foos.push_back(f);

    11 }

    這有可能出現兩個問題(感謝水木網友 ilovecpp 提出):a) 誤用了加鎖版本,死鎖了。b) 誤用了不加鎖版本,數據損壞了。

    對于 a),仿造前面的辦法能比較容易地排錯。對于 b),如果 pthreads 提供 isLocked() 就好辦,可以寫成:

    1 void postWithLockHold(const Foo& f)

    2 {

    3 assert(mutex.isLocked()); // 目前只是一個愿望

    4 // ...

    5 }

    另外,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 循環中

    寫成代碼是:

    01 MutexLock mutex;

    02 Condition cond(mutex);

    03 std::deque<int> queue;

    04

    05 int dequeue()

    06 {

    07 MutexLockGuard lock(mutex);

    08 while (queue.empty()) { // 必須用循環;必須在判斷之后再 wait()

    09 cond.wait(); // 這一步會原子地 unlock mutex 并進入 blocking,不會與 enqueue 死鎖

    10 }

    11 assert(!queue.empty());

    12 int top = queue.front();

    13 queue.pop_front();

    14 return top;

    15 }

    對于 signal/broadcast 端:

    1. 不一定要在 mutex 已上鎖的情況下調用 signal (理論上)

    2. 在 signal 之前一般要修改布爾表達式

    3. 修改布爾表達式通常要用 mutex 保護(至少用作 full memory barrier)

    寫成代碼是:

    1 void enqueue(int x)

    2 {

    3 MutexLockGuard lock(mutex);

    4 queue.push_back(x);

    5 cond.notify();

    6 }

    上面的 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
    本節把前面用到的 MutexLock、MutexLockGuard、Condition classes 的代碼列出來,前面兩個 classes 沒多大難度,后面那個有點意思。

    MutexLock 封裝臨界區(Critical secion),這是一個簡單的資源類,用 RAII 手法 [CCS:13]封裝互斥器的創建與銷毀。臨界區在 Windows 上是 CRITICAL_SECTION,是可重入的;在 Linux 下是 pthread_mutex_t,默認是不可重入的。MutexLock 一般是別的 class 的數據成員。

    MutexLockGuard 封裝臨界區的進入和退出,即加鎖和解鎖。MutexLockGuard 一般是個棧上對象,它的作用域剛好等于臨界區域。

    這兩個 classes 應該能在紙上默寫出來,沒有太多需要解釋的:

    01 #include <pthread.h>

    02 #include <boost/noncopyable.hpp>

    03

    04 class MutexLock : boost::noncopyable

    05 {

    06 public:

    07 MutexLock() // 為了節省版面,單行函數都沒有正確縮進

    08 { pthread_mutex_init(&mutex_, NULL); }

    09

    10 ~MutexLock()

    11 { pthread_mutex_destroy(&mutex_); }

    12

    13 void lock() // 程序一般不主動調用

    14 { pthread_mutex_lock(&mutex_); }

    15

    16 void unlock() // 程序一般不主動調用

    17 { pthread_mutex_unlock(&mutex_); }

    18

    19 pthread_mutex_t* getPthreadMutex() // 僅供 Condition 調用,嚴禁自己調用

    20 { return &mutex_; }

    21

    22 private:

    23 pthread_mutex_t mutex_;

    24 };

    25

    26 class MutexLockGuard : boost::noncopyable

    27 {

    28 public:

    29 explicit MutexLockGuard(MutexLock& mutex) : mutex_(mutex)

    30 { mutex_.lock(); }

    31

    32 ~MutexLockGuard()

    33 { mutex_.unlock(); }

    34

    35 private:

    36 MutexLock& mutex_;

    37 };

    38

    39 #define MutexLockGuard(x) static_assert(false, "missing mutex guard var name")

    注意代碼的最后一行定義了一個宏,這個宏的作用是防止程序里出現如下錯誤:

    1 void doit()

    2 {

    3 MutexLockGuard(mutex); // 沒有變量名,產生一個臨時對象又馬上銷毀了,沒有鎖住臨界區

    4 // 正確寫法是 MutexLockGuard lock(mutex);

    5

    6 // 臨界區

    7 }

    這里 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,請參觀其同步原語的龐雜設計:

    Concept 有四種 Lockable, TimedLockable, SharedLockable, UpgradeLockable.
    Lock 有五六種: lock_guard, unique_lock, shared_lock, upgrade_lock, upgrade_to_unique_lock, scoped_try_lock.
    Mutex 有七種:mutex, try_mutex, timed_mutex, recursive_mutex, recursive_try_mutex, recursive_timed_mutex, shared_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 這個術語了。

    01 class Condition : boost::noncopyable

    02 {

    03 public:

    04 Condition(MutexLock& mutex) : mutex_(mutex)

    05 { pthread_cond_init(&pcond_, NULL); }

    06

    07 ~Condition()

    08 { pthread_cond_destroy(&pcond_); }

    09

    10 void wait()

    11 { pthread_cond_wait(&pcond_, mutex_.getPthreadMutex()); }

    12

    13 void notify()

    14 { pthread_cond_signal(&pcond_); }

    15

    16 void notifyAll()

    17 { pthread_cond_broadcast(&pcond_); }

    18

    19 private:

    20 MutexLock& mutex_;

    21 pthread_cond_t pcond_;

    22 };

    如果一個 class 要包含 MutexLock 和 Condition,請注意它們的聲明順序和初始化順序,mutex_ 應先于 condition_ 構造,并作為后者的構造參數:

    01 class CountDownLatch

    02 {

    03 public:

    04 CountDownLatch(int count)

    05 : count_(count),

    06 mutex_(),

    07 condition_(mutex_)

    08 { }

    09

    10 private:

    11 int count_;

    12 MutexLock mutex_; // 順序很重要

    13 Condition condition_;

    14 };

    請允許我再次強調,雖然本節花了大量篇幅介紹如何正確使用 mutex 和 condition variable,但并不代表我鼓勵到處使用它們。這兩者都是非常底層的同步原語,主要用來實現更高級的并發編程工具,一個多線程程序里如果大量使用 mutex 和 condition variable 來同步,基本跟用鉛筆刀鋸大樹(孟巖語)沒啥區別。

    在程序里使用 pthreads 庫有一個額外的好處:分析工具認得它們,懂得其語意。線程分析工具如 Intel Thread Checker 和 Valgrind-Helgrind 等能識別 pthreads 調用,并依據 happens-before 關系 [Lamport 1978] 分析程序有無 data race。

    線程安全的 Singleton 實現
    研究 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 就行:

    01 #include <pthread.h>

    02

    03 template<typename T>

    04 class Singleton : boost::noncopyable

    05 {

    06 public:

    07 static T& instance()

    08 {

    09 pthread_once(&ponce_, &Singleton::init);

    10 return *value_;

    11 }

    12

    13 static void init()

    14 {

    15 value_ = new T();

    16 }

    17

    18 private:

    19 static pthread_once_t ponce_;

    20 static T* value_;

    21 };

    22

    23 template<typename T>

    24 pthread_once_t Singleton<T>::ponce_ = PTHREAD_ONCE_INIT;

    25

    26 template<typename T>

    27 T* Singleton<T>::value_ = NULL;

    上面這個 Singleton 沒有任何花哨的技巧,用 pthread_once_t 來保證 lazy-initialization 的線程安全。使用方法也很簡單:

    Foo& foo = Singleton<Foo>::instance();

    當然,這個 Singleton 沒有考慮對象的銷毀,在服務器程序里,這不是一個問題,因為當程序退出的時候自然就釋放所有資源了(前提是程序里不使用不能由操作系統自動關閉的資源,比如跨進程的 Mutex)。另外,這個 Singleton 只能調用默認構造函數,如果用戶想要指定 T 的構造方式,我們可以用模板特化 (template specialization) 技術來提供一個定制點,這需要引入另一層間接。

    歸納
    進程間通信首選 TCP sockets
    線程同步的四項原則
    使用互斥器的條件變量的慣用手法 (idiom),關鍵是 RAII
    用好這幾樣東西,基本上能應付多線程服務端開發的各種場合,只是或許有人會覺得性能沒有發揮到極致。我認為,先把程序寫正確了,再考慮性能優化,這在多線程下任然成立。讓一個正確的程序變快,遠比“讓一個快的程序變正確”容易得多。

    7 總結
    在現代的多核計算背景下,線程是不可避免的。盡管一定程度上可以通過 framework 來屏蔽,讓你感覺像是在寫單線程程序,比如 Java Servlet。了解 under the hood 發生了什么對于編寫這種程序也會有幫助。

    多線程編程是一項重要的個人技能,不能因為它難就本能地排斥,現在的軟件開發比起 10 年 20 年前已經難了不知道多少倍。掌握多線程編程,才能更理智地選擇用還是不用多線程,因為你能預估多線程實現的難度與收益,在一開始做出正確的選擇。要知道把一個單線程程序改成多線程的,往往比重頭實現一個多線程的程序更難。

    掌握同步原語和它們的適用場合時多線程編程的基本功。以我的經驗,熟練使用文中提到的同步原語,就能比較容易地編寫線程安全的程序。本文沒有考慮 signal 對多線程編程的影響,Unix 的 signal 在多線程下的行為比較復雜,一般要靠底層的網絡庫 (如 Reactor) 加以屏蔽,避免干擾上層應用程序的開發。

    通篇來看,“效率”并不是我的主要考慮點,a) TCP 不是效率最高的 IPC,b) 我提倡正確加鎖而不是自己編寫 lock-free 算法(使用原子操作除外)。在程序的復雜度和性能之前取得平衡,并經考慮未來兩三年擴容的可能(無論是 CPU 變快、核數變多,還是機器數量增加,網絡升級)。下一篇“多線程編程的反模式”會考察伸縮性方面的常見錯誤,我認為在分布式系統中,伸縮性 (scalability) 比單機的性能優化更值得投入精力。

    這篇文章記錄了我目前對多線程編程的理解,用文中介紹的手法,我能解決自己面臨的全部多線程編程任務。如果文章的觀點與您不合,比如您使用了我沒有推薦使用的技術或手法(共享內存、信號量等等),只要您理由充分,但行無妨。

    這篇文章本來還有兩節“多線程編程的反模式”與“多線程的應用場景”,考慮到字數已經超過一萬了,且聽下回分解吧 :-)

    后文預覽:Sleep 反模式
    我認為 sleep 只能出現在測試代碼中,比如寫單元測試的時候。(涉及時間的單元測試不那么好寫,短的如一兩秒鐘可以用 sleep,長的如一小時一天得想其他辦法,比如把算法提出來并把時間注入進去。)產品代碼中線程的等待可分為兩種:一種是無所事事的時候(要么等在 select/poll/epoll 上。要么等在 condition variable 上,等待 BlockingQueue /CountDownLatch 亦可歸入此類),一種是等著進入臨界區(等在 mutex 上)以便繼續處理。在程序的正常執行中,如果需要等待一段時間,應該往 event loop 里注冊一個 timer,然后在 timer 的回調函數里接著干活,因為線程是個珍貴的共享資源,不能輕易浪費。如果多線程的安全性和效率要靠代碼主動調用 sleep 來保證,這是設計出了問題。等待一個事件發生,正確的做法是用 select 或 condition variable 或(更理想地)高層同步工具。當然,在 GUI 編程中會有主動讓出 CPU 的做法,比如調用 sleep(0) 來實現 yield。

    2 0 0
    (請您對文章做出評價)

      飛信
      (17)飛信
      西西軟件園提供各平臺飛信官方下載,雖然微信是目前市場上最流行的交流討論軟件,但是飛信也有它自己獨有的特色功能,融合語音短信等多種通信方式,覆蓋三種不同形態完全實時準實時和非實時的客戶通信需求,實現互聯網和移動網間的無縫通信服務。飛信不但可以免費從給手機發短信,而且不受任何限制,能夠隨時隨地與好友開始語聊,并享受超低語聊資費。...更多>>
      QQ2017
      (24)QQ2017
      酷炫界面隨心而動,與眾不同。全新皮膚引擎,輕松上傳美圖作為皮膚,體驗屬于自己的視覺盛宴。專業高品質的界面構想,為您帶來無與倫比的視覺享受。下載正式版免費下載已經免費提供給廣大騰訊用戶進行下載了,如果你還是在使用舊版本,那么你已經了,快來下載正式版來體驗下新版本帶給你的快樂吧正式版新增劃詞搜索功能,邊聊邊搜更輕松,新增會員超級表情功能,聊天更有生動有趣最新版官方下載是在系列版本的基礎上,全新設計與定...更多>>
      • QQ20178.9.20026 官方最新版

        02-08 / 60.2M

        推薦理由:qq2017最新版官方下載,QQ2017全新設計回歸本源設計,讓您的目光停留在您所關注的內容上,大大提升了溝通的
      • QQ2014最新體驗版6.6.13074 官方安

        11-11 / 55.8M

        推薦理由:騰訊體驗中心本次推出的QQ性能體驗版3.0,通過改造基礎架構和框架,針對關鍵功能深度優化,重點提升了啟動,
      • iPhoneQQ2016v6.5.9 正式版

        11-04 / 178M

        推薦理由:QQ手機版,致力于更完美的移動社交、娛樂與生活體驗——樂在溝通15年,聊天歡樂8億人!QQ2016foriPhone全新
      • QQ2014 for WP84.3 官方最新版

        06-10 / 20M

        推薦理由:QQ for WP8正式發布,視頻功能更上一層樓,新增視頻時切換到后臺、切換到后置攝像頭、本方和對方畫面切換等
      • QQ輕聊版for windowsv7.9(14305) 官

        12-02 / 46.5M

        推薦理由:QQ輕聊版for windows是騰訊為pc端客戶推出的一款精簡版qq,用過手機qq輕聊版的用戶對輕聊版應該都有個大概的
      • 手機QQ2017 for Androidv6.6.9 官方

        02-14 / 37.8M

        推薦理由:全新的手機QQ2017版正式發布。qq2017新版本在保留原有功能基礎上,加入2套趣味表情,并對UI進行簡化設計,去
      即時通訊軟件
      (33)即時通訊軟件
      即時通訊是一個終端服務,允許兩人或多人使用網路即時的傳遞文字訊息檔案語音與視頻交流。即時通訊按使用用途分為企業即時通訊和網站即時通訊,根據裝載的對象又可分為手機即時通訊和即時通訊,手機即時通訊代表是短信,網站視頻即時通訊。在網際網路上頗受用戶歡迎的即時通訊服務包含信鴿人人桌面一說語音飛信企業飛信這些服務有賴于許多想法更久的與普遍的線上聊天媒介,如一樣知名...更多>>
      • 信鴿v3.0.5 官方最新版

        09-10 / 18.5M

        推薦理由:信鴿是一款專為各種組織移動溝通設計的完全免費的即時通訊工具。信鴿支持批量導入組織結構,按分組展開顯示
      • 群英ccv4.5.2.24060 官方免費版

        08-17 / 19.3M

        推薦理由:群英cc是一款專業的企業即時通訊軟件,它不僅是企業統一通訊的消息平臺,更是企業在線辦公軟件(SAAS)的融
      • 騰訊通RTX 2015 客戶端正式版

        01-21 / 23.3M

        推薦理由:RTX騰訊通終于更新到2013版本了,界面更好看,但是好像很多東西有待各位嘗試看看,建議小幅度測試后再升級。
      • 飛信2017V5.6.8860.0 官方正式版

        12-29 / 69.1M

        推薦理由:免費短信,隨時發送,無縫溝通新體驗。飛信是中國移動推出融合語音(IVR)、GPRS、短信等多種方式的通信服務
      • 康福中國6.11.529 最新簡體中文版

        04-21 / 10.5M

        推薦理由:目前CamfrogVideoChat的全球注冊用戶已達到3億,全球同時在線聊友人數超過300萬,它具備QQ、MSN、UC等聊天軟
      • 阿里旺旺買家版2016v9.06.01 官方正

        11-23 / 50.3M

        推薦理由:淘寶天貓上面的東西很多,我們需要購買裝上旺旺買家版,可以跟店主進行溝通,這是阿里旺旺2016買家版本,針對

      相關評論

      閱讀本文后您有什么感想? 已有人給出評價!

      • 8 喜歡喜歡
      • 3 頂
      • 1 難過難過
      • 5 囧
      • 3 圍觀圍觀
      • 2 無聊無聊

      熱門評論

      最新評論

      發表評論 查看所有評論(0)

      昵稱:
      表情: 高興 可 汗 我不要 害羞 好 下下下 送花 屎 親親
      字數: 0/500 (您的評論需要經過審核才能顯示)
      女人让男人桶30分钟免费视频,女人张开腿让男人桶个爽,一进一出又大又粗爽视频
    • <td id="ae6ms"><li id="ae6ms"></li></td>
    • <xmp id="ae6ms"><td id="ae6ms"></td><table id="ae6ms"></table>
    • <table id="ae6ms"></table>
    • <td id="ae6ms"></td>
      <td id="ae6ms"></td>
    • <table id="ae6ms"></table><table id="ae6ms"><td id="ae6ms"></td></table>
    • <td id="ae6ms"></td>
    • <table id="ae6ms"><li id="ae6ms"></li></table>
    • <table id="ae6ms"></table>