置頂隨筆
內容簡介
本書主要講述采用現代 C++ 在 x86-64 Linux 上編寫多線程 TCP 網絡服務程序的主流常規技術,重點講解一種適應性較強的多線程服務器的編程模型,即 one loop per thread。這是在 Linux 下以 native 語言編寫用戶態高性能網絡程序最成熟的模式,掌握之后可順利地開發各類常見的服務端網絡應用程序。本書以 muduo 網絡庫為例,講解這種編程模型的使用方法及注意事項。
本書的宗旨是貴精不貴多。掌握兩種基本的同步原語就可以滿足各種多線程同步的功能需求,還能寫出更易用的同步設施。掌握一種進程間通信方式和一種多線程網絡編程模型就足以應對日常開發任務,編寫運行于公司內網環境的分布式服務統。
基本信息
出版社:電子工業出版社
頁數:xvi+600
定價:人民幣89元
ISBN:9787121192821
豆瓣及網上書店預訂
豆瓣:http://book.douban.com/subject/20471211/
互動:http://product.china-pub.com/3021861
亞馬遜:http://www.amazon.cn/dp/B00AYS2KL0
當當:http://product.dangdang.com/product.aspx?product_id=23162953
京東:http://book.360buy.com/11163782.html
試讀樣章
前言與目錄:https://chenshuo-public.s3.amazonaws.com/pdf/preamble.pdf
第1章:線程安全的對象生命期管理:https://chenshuo-public.s3.amazonaws.com/pdf/chap1.pdf
第6章:muduo網絡庫簡介:https://chenshuo-public.s3.amazonaws.com/pdf/chap6.pdf
附錄:https://chenshuo-public.s3.amazonaws.com/pdf/appendix.pdf
樣章合集下載:http://vdisk.weibo.com/s/mtupb 共150頁,包括第 11.5 節。
前言(節選)
本書主要講述采用現代 C++ 在 x86-64 Linux 上編寫多線程 TCP 網絡服務程序的主流常規技術,這也是我對過去 5 年編寫生產環境下的多線程服務端程序的經驗總結。本書重點講解多線程網絡服務器的一種 IO 模型,即 one loop per thread。這是一種適應性較強的模型,也是 Linux 下以 native 語言編寫用戶態高性能網絡程序最成熟的模式, 掌握之后可順利地開發各類常見的服務端網絡應用程序。本書以 muduo 網絡庫為例,講解這種編程模型的使用方法及注意事項。
muduo 是一個基于非阻塞 IO 和事件驅動的現代 C++ 網絡庫,原生支持 one loop per thread 這種 IO 模型。muduo 適合開發 Linux 下的面向業務的多線程服務端網絡應用程序,其中“面向業務的網絡編程”的定義見附錄 A。 “現代 C++”指的不是 C++11 新標準,而是 2005 年 TR1 發布之后的 C++ 語言和庫。 與傳統 C++ 相比,現代 C++ 的變化主要有兩方面:資源管理(見第 1 章)與事件回調(見第 449 頁)。
本書不是多線程編程教程,也不是網絡編程教程,更不是 C++ 教程。讀者應該已經大致讀過《UNIX 環境高級編程》、《UNIX 網絡編程》、《C++ Primer》或與之內容相近的書籍。本書不談 C++11,因為目前(2012 年)主流的 Linux 服務端發行版的 g++ 版本都還停留在 4.4,C++11 進入實用尚需一段時日。
本書適用的硬件環境是主流 x86-64 服務器,多路多核 CPU、幾十 GB 內存、千兆以太網互聯。除了第 5 章講診斷日志之外,本書不涉及文件 IO。
本書分為四大部分,第 1 部分“C++ 多線程系統編程”考察多線程下的對象生命期管理、線程同步方法、多線程與 C++ 的結合、高效的多線程日志等。第 2 部分“muduo 網絡庫”介紹使用現成的非阻塞網絡庫編寫網絡應用程序的方法,以及 muduo 的設計與實現。第 3 部分“工程實踐經驗談”介紹分布式系統的工程化開發方法和 C++ 在工程實踐中的功能特性取舍。第 4 部分“附錄”分享網絡編程和 C++ 語言的學習經驗。
本書的宗旨是貴精不貴多。掌握兩種基本的同步原語就可以滿足各種多線程同步的功能需求,還能寫出更易用的同步設施。掌握一種進程間通信方式和一種多線程網絡編程模型就足以應對日常開發任務,編寫運行于公司內網環境的分布式服務系統。(本書不涉及分布式存儲系統,也不涉及 UDP。)
術語與排版范例
本書大量使用英文術語,甚至有少量英文引文。設計模式的名字一律用英文,例如 Observer、Reactor、Singleton。在中文術語不夠突出時,也會使用英文,例如 class、heap、event loop、STL algorithm 等。注意幾個中文 C++ 術語:對象實體(instance) 、函數重載決議(resolution) 、模板具現化(instantiation) 、覆寫(override)虛函數、提領(dereference)指針。本書中的英語可數名詞一般不用復數形式,例如兩個 class,6 個 syscall;但有時會用 (s) 強調中文名詞是復數。fd 是文件描述符(file descriptor)的縮寫。“CPU 數目”一般指的是核(core)的數目。用諸如§11.5 表示本書第 11.5 節,L42 表示上下文中出現的第 42 行代碼。[JCP]、[CC2e] 等是參考文獻,見書末清單。
代碼
本書的示例代碼以開源項目的形式發布在 GitHub 上,
地址是 http://github.com/chenshuo/recipes/ 和 http://github.com/chenshuo/muduo/ 。本書配套頁面提供全部源代碼打包下載,正文中出現的類似 recipes/thread 的路徑是壓縮包內的相對路徑,讀者不難找到其對應的 GitHub URL。
本書假定讀者熟悉 diff -u 命令的輸出格式,用于表示代碼的改動。
本書正文中出現的代碼有時為了照顧排版而略有改寫,例如改變縮進規則,去掉單行條件語句前后的花括號等。就編程風格而論,應以電子版代碼為準。
聯系方式
郵箱:giantchen_at_gmail.com
主頁:http://chenshuo.com/book (正文和腳注中出現的 URL 可從這里找到。 )
微博:http://weibo.com/giantchen
博客:http://blog.csdn.net/Solstice
代碼:http://github.com/chenshuo
陳碩
中國•香港
2014年12月3日
《網絡編程實戰》是一門以講解實例為主的課程,每一節都講一兩個網絡編程的例子程序,課程偏重 Linux 服務端 TCP 網絡編程。
本課程要求聽課人員已經讀過《Unix 網絡編程》,能寫簡單的 TCP echo 服務。
課程地址:http://boolan.com/course/4
配套頁面:http://chenshuo.com/pnp
2013年11月1日
《Linux 多線程服務端編程:使用 muduo C++ 網絡庫》 電子版已在京東上市銷售。
購買地址:http://e.jd.com/30149978.html
閱讀效果:
PC

iPad

目前京東的閱讀器沒有切白邊功能,值得改進。
2013年10月11日
本文首發于酷殼網 http://coolshell.cn/articles/10478.html
先說說程序員(應屆生)面試的一般過程,一輪面試(面對一到兩個面試官)一般是四、五十分鐘,面試官會問兩三個編程問題(通常是兩大一小),因此留給每個編程題的時間只有 20 分鐘。這 20 分鐘不光是寫代碼,還要跟面試官討論你的答案并解答提問,比如面試官拿過你的答案紙,問某一行代碼如果修改會有什么后果。因此真正留給在紙上或白板上寫代碼的時間也就 10 分鐘上下。本文給出了一個能用 10 分鐘時間在紙上寫出來且不會有錯的 String class,強調正確性及易實現(白板上寫也不會錯),不強調效率與功能完備。
本文的配套代碼位于 https://github.com/chenshuo/recipes/blob/master/string/StringTrivial.h。
全文:https://chenshuo.googlecode.com/files/CppEngineering.pdf
2013年10月10日
讀者來信用黑色,我的回答用藍色。經過整理,接近對話體。
> 陳碩,你好,
>
> 閱讀了你的書,很有收獲。
> 但是沒有在moduo的源代碼里面找到實現線程模型11的例子。即one thread per loop + thread pool。
> 謝謝。
書第 173 頁圖 6-14 下面的第一段話,具體改動方法參考前一頁的 diff。
> 謝謝。
>
> 另外TcpConnection和Channel的生命周期管理有點問題。
> TcpConnection如果已經被回收了,其包含的Channel也已經被回收了。而這個時候在Channel::handleEvent()里面檢查tied_和tie_是危險的。因為其內存已經被回收了。
>
> 如果用戶保證TcpConnection被回收之后,不會再用Channel的裸指針,則沒有必要在TcpConnection::connectEstablished()中call tie().
TcpConnection 回收之前,會調用 connectDestroyed,其中調用 channel_->remove();,這樣就不可能再會有 Channel::handleEvent() 被調用了。
tie() 的作用是防止 Channel::handleEvent() 運行期間其 owner 對象析構,導致 Channel 本身被銷毀。
> > TcpConnection 回收之前,會調用 connectDestroyed,其中調用 channel_->remove();,這樣就不可能再會有 Channel::handleEvent() 被調用了。
> 這個時候會不會有race condition?假設現在有兩個active channels,處理頭一個的時候回收TcpConnection,而第二個channel剛好對應這個connection。
這時你沒有辦法強制銷毀 TcpConnection,只能降低其引用計數,所以不會有問題。你可以寫段代碼試試。
> 另外底層的poller OS api是否保證unregister channel之后一定不會再有這個channel的事件,會清空內核的已經就緒的事件隊列?
跟內核沒關系,Poller class 在 unregister channel 之后就不可能調用其 handleEvent() 成員函數。
> 那EPollPoller::fillActiveChannels()的改一改,“assert(it != channels_.end());”不再適用了,而且每次都個event都要查一次map。效率會有問題。
assert() 只有在 debug build 才執行,不會影響效率。
再說每個 event 都要涉及 read/write 等系統調用,開銷比“查一次 map”大得多,優化這里是無用功。
> 但這個assert()不是invalid了嗎?你可能之前在unregister channel的時候已經從map里面remove掉了它。
這個 assert 是有效的,你再想想。
> > tie() 的作用是防止 Channel::handleEvent() 運行期間其 owner 對象析構,導致 Channel 本身被銷毀。
> 這個也不太make sense。仍然有race conditon。在Channel::handleEvent()擁有guard鎖定ownner之前,Channel::handleEvent()需要檢查其tied_。
你再想想,tie 的作用是防止調用 handleEvent() 期間對象銷毀(比如調用 closeCallback 期間),不是也不可能防止調用 handleEvent() 之前對象銷毀。
> 恩,是的。整個TcpConnection, Channel, EventLoop都是一個thread里面run的。
2013年10月6日
推薦《Linux 多線程服務器端編程》
賴勇浩(http://laiyonghao.com)
最近,有一位朋友因為工作需要,需要從網游的客戶端編程轉向服務器端編程,找我推薦一本書。我推薦了《Linux 多線程服務器端編程——使用 muduo C++ 網絡庫》給他,他在網上書店看了以后問我為什么推薦這么厚一本書給他,正好這本書我已經早就看完了,一直也想寫篇“書評”,就在這里多扯幾句。其實實在算不上書評,原因有二:一是讀書的時候囫圇吞棗,理解不夠深刻,不深刻自然不能評;二是這幾年雖然在 Linux 下寫服務器端的網絡程序,但很少用多線程,也很少用 C++,書里談的東西,算是不熟悉的領域,自然也不能亂評。所以今天這篇,應當是推薦,是為陳碩老師背書。
繼續閱讀:
http://blog.csdn.net/gzlaiyonghao/article/details/10366863
http://book.douban.com/review/6249351/
2013年9月9日
TL;DR 如果你能一眼看出 https://gist.github.com/chenshuo/6430925 中的那 8 個 Waiter classes 哪些是對的哪些是錯的,本文就不必看了。
前幾天,我發了一條微博 http://weibo.com/1701018393/A7FrW7ZVd ,質疑某本書對 Pthreads 條件變量的封裝是錯的,因為它沒有把 mutex 的 lock()/unlock() 函數暴露出來,導致無法實用。后來大家討論的分歧是這個 cond class 是不是通用的條件變量封裝,還是只是一個特殊的“事件等待器”。作為事件等待器,其實現也是錯的,因為存在丟失事件的可能,可以算是初學者使用條件變量的典型錯誤。
本文的代碼位于 recipes/thread/test/Waiter_test.cc,這里提到的某書的版本相當于 Waiter1 class。
我在拙作《Linux 多線程服務端編程:使用 muduo C++ 網絡庫》第 2.2 節總結了條件變量的使用要點:
條件變量只有一種正確使用的方式,幾乎不可能用錯。對于 wait 端:
1. 必須與 mutex 一起使用,該布爾表達式的讀寫需受此 mutex 保護。
2. 在 mutex 已上鎖的時候才能調用 wait()。
3. 把判斷布爾條件和 wait() 放到 while 循環中。
對于 signal/broadcast 端:
1. 不一定要在 mutex 已上鎖的情況下調用 signal (理論上)。
2. 在 signal 之前一般要修改布爾表達式。
3. 修改布爾表達式通常要用 mutex 保護(至少用作 full memory barrier)。
4. 注意區分 signal 與 broadcast:“broadcast 通常用于表明狀態變化,signal 通常用于表示資源可用。(broadcast should generally be used to indicate state change rather than resource availability。)”
如果用條件變量來實現一個“事件等待器/Waiter”,正確的做法是怎樣的?我的最終答案見 WaiterInMuduo class。“事件等待器”的一種用途是程序啟動時等待初始化完成,也可以直接用 muduo::CountDownLatch 到達相同的目的,將初值設為 1 即可。
以下根據微博上的討論過程給出幾個正確或錯誤的版本,博大家一笑。只要記住 Pthread 的條件變量是邊沿觸發(edge trigger),即 signal()/broadcast() 只會喚醒已經等在 wait() 上的線程(s),我們在編碼時必須要考慮 signal() 早于 wait() 的可能,那么就很容易判斷以下各個版本的正誤了。代碼見 recipes/thread/test/Waiter_test.cc。
版本一:錯誤。某書上的原始版,有丟失事件的可能。

版本二:錯誤。lock() 之后再 signal(),同樣有丟失事件的可能。

版本三:錯誤。引入了 bool signaled_; 條件,但沒有正確處理 spurious wakeup。
版本四五六:正確。僅限 single waiter 使用。
版本七:最佳。可供 multiple waiters 使用。
版本八:錯誤。存在 data race,且有丟失事件的可能。理由見 http://stackoverflow.com/questions/4544234/calling-pthread-cond-signal-without-locking-mutex
總結:使用條件變量,調用 signal() 的時候無法知道是否已經有線程等待在 wait() 上。因此一般總是要先修改“條件”,使其為 true,再調用 signal();這樣 wait 線程先檢查“條件”,只有當條件不成立時才去 wait(),避免了丟事件的可能。換言之,通過使用“條件”,將邊沿觸發(edge trigger)改為電平觸發(level trigger)。這里“修改條件”和“檢查條件”都必須在 mutex 保護下進行,而且這個 mutex 必須用于配合 wait()。
思考題:如果用兩個 mutex,一個用于保護“條件”,另一個專門用于和 cond 配合 wait(),會出現什么情況?
最后注明一點,http://stackoverflow.com/questions/6419117/signal-and-unlock-order 這篇帖子里對 spurious wakeup 的解釋是錯的,spurious wakeup 指的是一次 signal() 調用喚醒兩個或以上 wait()ing 的線程,或者沒有調用 signal() 卻有線程從 wait() 返回。manpage 里對 Pthreads 系列函數的介紹非常到位,值得細讀。
2013年8月18日
最近花了兩天時間用 muduo 部分實現了 memcached 服務器協議,代碼位于 examples/memcached/server,能通過 memcached 的大部分測試用例(incr/decr 還沒有實現)。
這不是 memcached 的替代品(它沒有實現LRU和超時功能,也沒有實現二進制協議,更沒有自己管理內存),而是一個網絡編程的示例(代碼只有 1000 行,比 memcached 小很多),展示 muduo 風格的事件驅動編程,以及將來性能優化的試驗品(換句話說,現在這個版本完全沒有在性能上做出任何努力)。讀過 memcached 代碼的人可以對比這兩種編程風格的區別,memcached 的 read/write 操作穿插于正常邏輯處理,而 muduo 的網絡數據讀寫是由庫完成,應用程序只關心消息收發,目前二者的基本 get/set 操作的性能相當。
現在 muduo 的 inspector 內置了 gperftools 的遠程 profiling 功能,memcached-debug 展示了其用法。
為什么不必優化 set 操作(含 set/add/update/append/prepend/cas 等)的性能?
1. 比例。既然是 memcache,那么 get:set 的比例很高,10:1 甚至更高,因此優化的重心應該是 get 而非 set。
假設 memcached 能處理 100k QPS,再假設這些操作都是 set(其實應該不到 10% 是 set),再假設所有的 set 都是串行執行的(沒有并發),那么每次 set 的 CPU 時間不應該超過 10 us(含服務器本地的網絡代碼運行時間,但不含網絡延遲)。而實際上一次 set 的 CPU 時間最多是 2~3 us (用 memcached-footprint 程序測得),根本不值得優化。
2. 網絡帶寬。假設一次 set 操作的 key + value 的長度是 1k bytes,TCP 的有效載荷帶寬按110MB/s估算,那么1kB數據在千兆網上的慣性延遲是 9us(傳輸延遲是幾十上百微秒,與此無關),也就是說服務器的網卡收到這 1kB 數據需要花 9us 時間(從第一個字節到達到服務器到收完最后一個字節),那么在 set 耗時 2~3 us 的情況下再去優化它是做無用功。
3. 產生“需要更新的數據”的成本遠大于 memcached set 的開銷。memcached 需要更新,往往是將已寫入數據庫的新數據放到 memcached 中,那么寫數據庫的開銷遠遠大于 memcached set 的開銷,優化 set 對提升系統整體性能沒意義。
2013年8月12日
寫C++程序的幾個陋習:class 名以大寫 C 開頭,例如 CDate;成員變量以 m_ 開頭;變量采用匈牙利命名法;不知道何時禁用 copy-ctor/assign operator。前三個可能是從MFC那里傳下來的,當時C++、class、OO是新玩意兒,要與 C struct 區分,現在還這么做就土了。C++的成員變量可用特殊命名格式,加下劃線后綴即可(加下劃線前綴是錯的)。但在 Java 里不必模仿 C++ 的這種成員變量命名方式,IDE 可以讓成員變量以不同的顏色顯示,與局部變量區分,根本無需特殊命名。寫程序就怕把以前的編程經驗不加區分地應用到新語言中,寫成四不像,不地道。
知道禁用 copy-ctor/assign operator 是 C++ 程序員的試金石。在看到一個開源項目時,我一般會先查看其 RAII handle class 是否禁用了 copy-ctor/assign operator(例如 Thread、Mutex、CondVar、Connection),如果沒有,對其第一印象就很差了。
關于 class 命名風格,Google、LLVM、Mozilla、muduo 都采用 Pascal 風格(LikeThis),例如 EventLoop、SudokuSolver 等等。正巧它們也都是用 2 格縮進的,可以用 clang-format 自動格式化代碼。
順便說說我不認同的兩個 C++ 教條:1. 用nullptr替換NULL,2. 用cstdio頭文件替換stdio.h。
因為例如 gettimeofday(&tv, NULL) 這種系統函數傳個 nullptr 進去實在是違和,現在用 NULL 也能達到 nullptr 的好處,大不了在某個頭文件里define一下就行。這條將來或許會變。
另外 ctime 頭文件沒定義 std::gmtime_r,而 time.h 定義了 ::gmtime_r。我可不想去背哪些函數是 C 語言的哪些是 Posix 的,哪些頭文件是 C 語言的哪些是 Posix 的(在Linux下,二者基本不分家)。為了用幾個系統函數(例如 fcntl() ),我該 include cfcntl 還是 fcntl.h?用線程是 cpthread 還是 pthread.h?我總是記不住 memset() 的參數順序,因此一般用 bzero() 代替,但是 manpage 說 bzero() 聲明于 strings.h,那我要不要考慮試試 cstrings 呢?何必給自己找麻煩,C++ 標準庫之外的內容干脆統一用 .h 頭文件好了。
性能優化?
有些人常常把“性能”掛在嘴邊,而且其以“提高性能”為理由的“優化措施”往往不到點子上,只增加了復雜性和維護難度,降低了代碼質量。這屬于決策點找偏了。我發現初學者往往過分關注微觀(語句級)性能,比方說關心 while(true) 和 for(;;) 哪個更快,++i 與 i=i+1 哪個更快,i/=16 和 i >>= 4 哪個快等等,而忽視了現代編譯器的優化能力。
有的人談性能優化,一是拿不出具體的合理的性能目標,只想越快越好,二是不能實際準確測量驗證性能數據,憑感覺和過時經驗行事。在編碼的時候,遇到兩種做法都可行,決策辦法是憑感覺猜選“性能會更好”的一種,而忽視了其他更重要的因素。可讀性和性能的典型關系如下圖,有多少場合是值得為了性能而犧牲代碼的可讀性和可維護性呢?我希望自己的代碼位于第 3 區,而一些人以為自己的代碼是在第 4 區,其實是在第 1 區。

能在第 4 區寫代碼的人屬于鳳毛麟角,有時候你費勁優化了半天,結果新CPU加了幾條指令,直接在硬件層面把問題解決了。現在一些人動不動就要挽起袖子自己寫內存池,號稱能提高性能,真當 Ulrich Drepper 是水貨?(書第 12.2.8 節“有必要自行定制內存分配器嗎”)你打算如何測試內存分配器(malloc)的性能?有哪些指標?有哪些影響因素需要控制或模擬(比如線程數)?你的測試結果是否反映實際場景?
雜項
有人問為什么我說“poco不是服務端C++網絡庫”( http://www.oschina.net/question/12_120943 ),雖然它也提供了reactor?因為它的reactor用的是 Socket::select(),雖然后者包裝了epoll,但看其實現就知道,它每次調用都會創建并銷毀 epoll fd,然后重建整個watch list,沒有哪個服務端網絡庫會這么做。
嗯,世界上有兩種網絡編程:網絡編程和Windows網絡編程。
2013年7月17日
《Linux多線程服務端編程:使用muduo C++網絡庫》這本書自今年一月上市以來,半年之內已經重印兩次(加上首印,一共是三次印刷),總印數達到了9000冊,這在技術書里已經算是相當不錯的成績。本書購買方式見配套網站 http://chenshuo.com/book 。
以下談一談這本書的寫作背景與內容取舍的原因。
參加工作以來,我編寫并維護了若干C++/Java多線程網絡服務程序,這本書總結了我在開發維護這類服務程序方面的經驗。工作中,我沒有寫過單線程的網絡服務程序,沒有寫過C語言的網絡服務程序,也沒有寫過運行在Windows下的網絡服務程序,因此本書不涉及這些內容。
在“Linux服務端開發”這一背景下,這本書主要講三個方面的內容[1]:現代C++、多線程、網絡編程,分別對應書的第3、1、2部分。這不是一本入門書,本書的讀者應該在以上三方面已經具備相當的基礎[2]:網絡編程方面,能輕松讀懂6.1節的兩個Python程序;C++方面,對12.8節的代碼不感到陌生;多線程方面,能明白第1章要解決什么問題。
第9章“分布式系統工程實踐”詳細介紹了這本書的應用背景,即開發公司內部的分布式服務系統,書中的很多決策(design decision)和技術取舍(trade-off)是在這一應用場景下做出的。以下是各章直接的交叉引用關系圖(沒有計算引用次數),其中第0章是前言,字母章節是附錄。可見第9章是被引用最多的一章,某種意義上可以說第9章既是本書的先決條件,又是本書的終極目標。由于章節之間存在眾多的交叉引用,去掉任何一章都會破壞內容的完整性。
這本書的書名原本打算叫“Linux C++ 多線程系統編程”。寫完之后發現,與其他Unix/Linux系統編程方面的書不同,這本書有明確的應用場景,因此可以砍掉很多內容,突出重點。甚至可以說我主要講別的書沒有講到的內容。這不是一本面面俱到的書,因此最終的書名也就不叫“系統編程”了。
同時,我認為很多教科書上介紹的一些做法是過時的(signal),一些是不推薦使用的(從外部終止線程、TCP OOB數據),一些是大多數情況下沒必要使用的(內存池、lock-free 編程)。作為全面的教材和手冊,把這些內容放進去可以理解。但是這本書定位是經驗總結,我略去了教科書上那些基本用不到的知識點,以免喧賓奪主,也建議讀者不要把精力花在那些次要問題上。
- 這本書沒有花很大的篇幅去講signal,而是在第4.10節說明多線程程序不要使用signal作為IPC。并且,在muduo-protorpc的示例中給出了Linux專有的signalfd(2)的用法,可以避免傳統signal handler的常見陷阱,也更符合UNIX的“everything is a file”哲學。第4.4節說明不要從外部終止線程,因此也就不必去細究Pthreads cancellation point了。多線程程序最好不要fork()(第4.9節)。
- 這本書沒介紹daemon進程,我認為daemon是過時的做法。因為daemon進程的父進程是init(1),配置文件在本機,不便于多機統一監控與管理(第9.8節)。(注:如果是第三方標準的服務程序,又不需要經常升級或改配置重啟,并且一旦崩潰,重啟就能繼續服務,那么做成 daemon 讓init(1)接管是可以的,比如ntpd、sshd等。這里談的是自己開發維護的服務程序。)另外,Java/Python/Go寫的服務程序似乎也沒有做成daemon的習慣,C++程序沒有理由要特殊對待。補充一點,Linux的進程管理機制很落后(從UNIX繼承而來),子進程退出的事件只能被父進程以SIGCHLD信號的方式收到(而且這個signal可能丟失),kill(pid) 也存在很多race condition(你怎么保證pid在kill之前的一瞬間還代表你想kill的那個進程,而不是一個新啟動的進程?close(fd)就不會有這種 race condition。)。這些困難在用戶態無法克服,只能修改內核,引入新的系統調用才能治本。例如 FreeBSD 9.0 引入了 pdfork()/pdkill() 等,將子進程變成文件描述符,這樣就能用IO事件框架統一處理了,也符合UNIX的“everything is a file”哲學。但愿Linux內核也能盡快引入類似的系統調用,減輕程序員的負擔。
- 這本書沒有講內存池,而是說明不是每個程序都要自己寫內存池(§12.2.8)。這本書也沒有把“避免內存碎片”掛在嘴邊,而是論證為什么一般的程序不必在意它(§A.1.8);
- 這本書只關注Linux,不考慮移植性。它推薦使用Linux專有的gettid()系統調用作為線程標識(第4.3節),而不是用pthread_self()。
- 這本書不講POSIX中五花八門的定時函數,而專講用Linux特有的timerfd來實現高精度定時(§7.8.2),因為它能方便地融入IO事件處理框架。muduo直接使用C++標準庫來管理定時器,而不是自己實現小頂堆(heap),這樣可以簡化實現(§8.2.1)。
- 這本書只講mutex和condition variable作為最基礎的線程同步手段(第2章),并且我認為一個C++多線程程序代碼里不應該直接出現pthread_mutex_lock之類的基本Pthreads調用。本書進一步建議只使用非遞歸的mutex(§2.1.1),這與某些網上文章的推薦正好相反。這本書第2.3節甚至建議不要使用讀寫鎖和信號量(semaphore),因為一是容易用錯,二是不見得能提高性能。mutex和condition variable是完備的,能實現多種更易用的同步設施,例如CountDownLatch和BlockingQueue。§12.8.3的代碼展示了用BlockingQueue和ThreadPool控制并發度的手法,做到了“No locks. No condition variables. No callbacks.”
- 這本書不講lock-free編程,因為編寫可靠的lock-free代碼并分析驗證其正確性的難度遠大于編寫普通的使用mutex和condition variable的多線程代碼,后者已經有了相當成熟的理論和工具。我認為lock-free不是每個多線程程序員應該掌握的技術,它投入高而用處少,可以適當了解,但不值得每個人都去深究。只需要少數人用它實現封裝好的數據結構,像我這樣的普通人就可以受益。
- 這本書只講BSD Sockets作為進程間通信的手段,并且只用TCP長連接(§3.4)。這樣就砍掉了pipe、FIFO、POSIX message queue、shared memory、STREAMS、UNIX domain socket等等內容,因為它們都只限本機進程間通信,無法擴展到多機。
- 網絡編程方面(第6、7章),這本書不講Sockets API的基本用法,而且代碼中也不會直接使用它們。我認為在程序中直接使用 Sockets API是初學者的做法,當寫一個新網絡服務程序,如果一開始考慮的是怎么組織accept、read、epoll_wait等調用,這種做法無異于用鉛筆刀鋸大樹,事倍功半,也不利于將來的功能擴展和維護。稍微像樣點的公司都會用成熟的網絡庫(不一定開源),把網絡編程的復雜性封裝進去,暴露出良好易用的接口,讓開發人員使用更高層的building blocks(消息傳遞或RPC)從功能的角度去設計程序,避免一次次反復掉到TCP網絡編程的坑里。多個服務程序共享相同的基礎庫和事件處理框架的益處是顯而易見的,一方面把網絡編程的復雜性集中到一起,避免每個團隊都去踏一遍坑;另一方面,基礎庫的bug修復與性能優化能惠及用到它的全部服務程序;最后,程序結構上的相似性讓編程經驗更加通用,多個服務程序在功能、性能、正確性等方面具有共性,能舉一反三觸類旁通,降低將來開發維護的成本。應該避免每個程序都另起爐灶,單獨設計其IO事件處理結構。
- 這本書只講非阻塞IO結合IO復用(IO-Multiplexing)這一種并發風格(歸納為三個半事件),并介紹在多線程下的擴展(one loop per thread)。IO復用方面,本書只講level-trigger,不講edge-trigger。一方面目前沒有up to date的測試表明ET更快,相反,我認為LT在讀取數據時可以節約一次read()調用(§8.7.2);另一方面,LT模式更容易與其他第三方庫結合(§7.15)。多線程程序管理并發socket fd有很多風格可供選擇,例如epoll fd是多個線程共享一個(多對一)還是每個線程有自己的epoll fd(一對一),每個socket fd是只屬于一個epoll fd(多對一)還是可以同時屬于多個 epoll fd(多對多),每個socket fd是只能被固定的一個線程讀寫還是可以被多個線程讀寫(如果是后者,那么讀寫的時候是加鎖還是使用ONESHOT)。以上不是每種都可行,本書也沒有一一加以分析,而是建議使用one loop per thread這種適用性較強的風格,首先是正確性容易驗證,其次是性能也能滿足要求。
- 本書不講IPv6,因為目前世界上最大的公司的服務機群也用不完一個私有A類地址(10.0.0.0/8)。本書不講UDP,因為《Unix網絡編程》已經講得很好了。
- 這本書舉的網絡編程的例子不再是簡單的echo服務,而是有格式(因此引入codec)、多連接之間會交換數據的網絡程序,更接近業務場景,也借機講解如何避免TCP網絡編程的常見陷阱。并且在示例代碼中給出了分布式單詞計數、多機求中位數等稍微復雜一點的程序。
- 在C++方面,這本書沒有介紹動態鏈接庫熱更新這種“高級”技術,而是說明,在分布式系統中,為了部署方便,應該從源碼編譯全部的庫,與主程序鏈接為一個standalone的可執行文件,以減小對運行環境的依賴(第10章)。第11章還討論了程序庫與應用程序之間的接口設計。
“信息”按照香農的定義,是“減少不確定性”,這本書包含的信息正是減少選用編程設施(facilities)方面的不確定性,讓讀者集中精力攻克本質問題。這本書介紹的方法不一定對于每個應用場景都是最好的,但肯定是簡便易行的,是時間成本、功能、性能的一種合理折中。
[1] 這本書前言的第一句話“本書主要講述采用現代 C++ 在 x86-64 Linux 上編寫多線程 TCP 網絡服務程序的主流常規技術”,封面印著“示范在多核時代采用現代 C++ 編寫多線程 TCP 網絡服務器的正規做法”。
[2] 前言寫到:讀者應該已經大致讀過《現代操作系統》、《UNIX 環境高級編程》、《UNIX 網絡編程》、《C++ Primer》或與之內容相近的書籍,熟悉基本概念,并掌握 Pthreads 和 Sockets API 的常規用法。
2013年1月28日
陳碩(giantchen_AT_gmail_DOT_com)
2012-01-28
我在《Linux 多線程服務端編程:使用 muduo C++ 網絡庫》第 1.9 節“再論 shared_ptr 的線程安全”中寫道:
(shared_ptr)的引用計數本身是安全且無鎖的,但對象的讀寫則不是,因為 shared_ptr 有兩個數據成員,讀寫操作不能原子化。根據文檔(http://www.boost.org/doc/libs/release/libs/smart_ptr/shared_ptr.htm#ThreadSafety), shared_ptr 的線程安全級別和內建類型、標準庫容器、std::string 一樣,即:
• 一個 shared_ptr 對象實體可被多個線程同時讀取(文檔例1);
• 兩個 shared_ptr 對象實體可以被兩個線程同時寫入(例2),“析構”算寫操作;
• 如果要從多個線程讀寫同一個 shared_ptr 對象,那么需要加鎖(例3~5)。
請注意,以上是 shared_ptr 對象本身的線程安全級別,不是它管理的對象的線程安全級別。
后文(p.18)則介紹如何高效地加鎖解鎖。本文則具體分析一下為什么“因為 shared_ptr 有兩個數據成員,讀寫操作不能原子化”使得多線程讀寫同一個 shared_ptr 對象需要加鎖。這個在我看來顯而易見的結論似乎也有人抱有疑問,那將導致災難性的后果。本文以 boost::shared_ptr 為例,與 std::shared_ptr 可能略有區別。
shared_ptr 的數據結構
shared_ptr 是引用計數型(reference counting)智能指針,幾乎所有的實現都采用在堆(heap)上放個計數值(count)的辦法(除此之外理論上還有用循環鏈表的辦法,不過沒有實例)。具體來說,shared_ptr<Foo> 包含兩個成員,一個是指向 Foo 的指針 ptr,另一個是 ref_count 指針(其類型不一定是原始指針,有可能是 class 類型,但不影響這里的討論),指向堆上的 ref_count 對象。ref_count 對象有多個成員,具體的數據結構如圖 1 所示,其中 deleter 和 allocator 是可選的。

圖 1:shared_ptr 的數據結構。
為了簡化并突出重點,后文只畫出 use_count:
以上是 shared_ptr<Foo> x(new Foo); 對應的內存數據結構。
如果再執行 shared_ptr<Foo> y = x; 那么對應的數據結構如下。

但是 y=x 涉及兩個成員的復制,這兩步拷貝不會同時(原子)發生。
中間步驟 1,復制 ptr 指針:
中間步驟 2,復制 ref_count 指針,導致引用計數加 1:
步驟1和步驟2的先后順序跟實現相關(因此步驟 2 里沒有畫出 y.ptr 的指向),我見過的都是先1后2。
既然 y=x 有兩個步驟,如果沒有 mutex 保護,那么在多線程里就有 race condition。
多線程無保護讀寫 shared_ptr 可能出現的 race condition
考慮一個簡單的場景,有 3 個 shared_ptr<Foo> 對象 x、g、n:
- shared_ptr<Foo> g(new Foo); // 線程之間共享的 shared_ptr
- shared_ptr<Foo> x; // 線程 A 的局部變量
- shared_ptr<Foo> n(new Foo); // 線程 B 的局部變量
一開始,各安其事。
線程 A 執行 x = g; (即 read g),以下完成了步驟 1,還沒來及執行步驟 2。這時切換到了 B 線程。
同時編程 B 執行 g = n; (即 write G),兩個步驟一起完成了。
先是步驟 1:
再是步驟 2:
這是 Foo1 對象已經銷毀,x.ptr 成了空懸指針!
最后回到線程 A,完成步驟 2:
多線程無保護地讀寫 g,造成了“x 是空懸指針”的后果。這正是多線程讀寫同一個 shared_ptr 必須加鎖的原因。
當然,race condition 遠不止這一種,其他線程交織(interweaving)有可能會造成其他錯誤。
思考,假如 shared_ptr 的 operator= 實現是先復制 ref_count(步驟 2)再復制 ptr(步驟 1),會有哪些 race condition?
雜項
shared_ptr 作為 unordered_map 的 key
如果把 boost::shared_ptr 放到 unordered_set 中,或者用于 unordered_map 的 key,那么要小心 hash table 退化為鏈表。http://stackoverflow.com/questions/6404765/c-shared-ptr-as-unordered-sets-key/12122314#12122314
直到 Boost 1.47.0 發布之前,unordered_set<std::shared_ptr<T> > 雖然可以編譯通過,但是其 hash_value 是 shared_ptr 隱式轉換為 bool 的結果。也就是說,如果不自定義hash函數,那么 unordered_{set/map} 會退化為鏈表。https://svn.boost.org/trac/boost/ticket/5216
Boost 1.51 在 boost/functional/hash/extensions.hpp 中增加了有關重載,現在只要包含這個頭文件就能安全高效地使用 unordered_set<std::shared_ptr> 了。
這也是 muduo 的 examples/idleconnection 示例要自己定義 hash_value(const boost::shared_ptr<T>& x) 函數的原因(書第 7.10.2 節,p.255)。因為 Debian 6 Squeeze、Ubuntu 10.04 LTS 里的 boost 版本都有這個 bug。
為什么圖 1 中的 ref_count 也有指向 Foo 的指針?
shared_ptr<Foo> sp(new Foo) 在構造 sp 的時候捕獲了 Foo 的析構行為。實際上 shared_ptr.ptr 和 ref_count.ptr 可以是不同的類型(只要它們之間存在隱式轉換),這是 shared_ptr 的一大功能。分 3 點來說:
1. 無需虛析構;假設 Bar 是 Foo 的基類,但是 Bar 和 Foo 都沒有虛析構。
shared_ptr<Foo> sp1(new Foo); // ref_count.ptr 的類型是 Foo*
shared_ptr<Bar> sp2 = sp1; // 可以賦值,自動向上轉型(up-cast)
sp1.reset(); // 這時 Foo 對象的引用計數降為 1
此后 sp2 仍然能安全地管理 Foo 對象的生命期,并安全完整地釋放 Foo,因為其 ref_count 記住了 Foo 的實際類型。
2. shared_ptr<void> 可以指向并安全地管理(析構或防止析構)任何對象;muduo::net::Channel class 的 tie() 函數就使用了這一特性,防止對象過早析構,見書 7.15.3 節。
shared_ptr<Foo> sp1(new Foo); // ref_count.ptr 的類型是 Foo*
shared_ptr<void> sp2 = sp1; // 可以賦值,Foo* 向 void* 自動轉型
sp1.reset(); // 這時 Foo 對象的引用計數降為 1
此后 sp2 仍然能安全地管理 Foo 對象的生命期,并安全完整地釋放 Foo,不會出現 delete void* 的情況,因為 delete 的是 ref_count.ptr,不是 sp2.ptr。
3. 多繼承。假設 Bar 是 Foo 的多個基類之一,那么:
shared_ptr<Foo> sp1(new Foo);
shared_ptr<Bar> sp2 = sp1; // 這時 sp1.ptr 和 sp2.ptr 可能指向不同的地址,因為 Bar subobject 在 Foo object 中的 offset 可能不為0。
sp1.reset(); // 此時 Foo 對象的引用計數降為 1
但是 sp2 仍然能安全地管理 Foo 對象的生命期,并安全完整地釋放 Foo,因為 delete 的不是 Bar*,而是原來的 Foo*。換句話說,sp2.ptr 和 ref_count.ptr 可能具有不同的值(當然它們的類型也不同)。
為什么要盡量使用 make_shared()?
為了節省一次內存分配,原來 shared_ptr<Foo> x(new Foo); 需要為 Foo 和 ref_count 各分配一次內存,現在用 make_shared() 的話,可以一次分配一塊足夠大的內存,供 Foo 和 ref_count 對象容身。數據結構是:
不過 Foo 的構造函數參數要傳給 make_shared(),后者再傳給 Foo::Foo(),這只有在 C++11 里通過 perfect forwarding 才能完美解決。
(.完.)