1. 程式人生 > >我在知乎回答關於 Linux C++ 服務端程式設計的學習方法

我在知乎回答關於 Linux C++ 服務端程式設計的學習方法

轉載自:http://blog.csdn.net/solstice/article/details/18944959

和          http://www.zhihu.com/question/22608820/answer/21968467

感謝陳碩前輩。

既然你是在校學生,而且程式語言和資料結構的基礎還不錯,我認為應該在《作業系統》和《計算機體系結構》這兩門課上下功夫,然後才去讀程式設計方面的 APUE、UNP 等書。


下面簡單談談我對學習這兩門課的看法和建議,都是站在服務端程式設計師的角度,從實用主義(pragmatic)的立場出發而言的。


學習作業系統的目的,不是讓你去發明自己作業系統核心,打敗 Linux;也不是成為核心開發人員;而是理解作業系統為使用者態程序提供了怎樣的執行環境,作為程式設計師應該如何才能充分利用好這個環境,哪些做法是有益的,哪些是做無用功,哪些則是幫倒忙。


學習計算機體系結構的目的,不是讓你去設計自己的 CPU(新的 ISA 或微架構),打敗 Intel 和 ARM;也不是參與到 CPU 設計團隊,改進現有的微架構;而是明白現代的處理器的能力與特性(例如流水線、多發射、分支預測、亂序執行等等指令級並行手段,記憶體區域性性與 cache,多處理器的記憶體模型、能見度、重排序等等),在程式設計的時候通過適當組織程式碼和資料來發揮 CPU 的效能,避免 pitfalls。


這兩門課程該如何學?看哪些書?這裡我告訴你一個通用的辦法,去美國計算機系排名靠前的大學的課程主頁,找到這兩門課最近幾年的課程大綱、講義、參考書目、閱讀材料、隨堂練習、課後作業、程式設計實驗、期末專案等,然後你就心裡有數了。


學習任何一門課程都要善於抓住主要矛盾、分清主次、突出重點,關鍵是掌握知識框架,學會以後真正有用的知識和技能,而不要把精力平均分配在一些瑣事上。


我(孟巖)主張,在具備基礎之後,學習任何新東西,都要抓住主線,突出重點。對於關鍵理論的學習,要集中精力,速戰速決。而旁枝末節和非本質性的知識內容,完全可以留給實踐去零敲碎打。


原因是這樣的,任何一個高階的知識內容,其中都只有一小部分是有思想創新、有重大影響的,而其它很多東西都是瑣碎的、非本質的。因此,集中學習時必須把握住真正重要那部分,把其它東西留給實踐。對於重點知識,只有集中學習其理論,才能確保體系性、連貫性、正確性,而對於那些旁枝末節,只有邊幹邊學能夠讓你瞭解它們的真實價值是大是小,才能讓你留下更生動的印象。如果你把精力用錯了地方,比如用集中大塊的時間來學習那些本來只需要查查手冊就可以明白的小技巧,而對於真正重要的、思想性東西放在平時零敲碎打,那麼肯定是事倍功半,甚至適得其反。


因此我對於市面上絕大部分開發類圖書都不滿——它們基本上都是面向知識體系本身的,而不是面向讀者的。總是把相關的所有知識細節都放在一堆,然後一堆一堆攢起來變成一本書。反映在內容上,就是毫無重點地平鋪直敘,不分輕重地陳述細節,往往在第三章以前就用無聊的細節謀殺了讀者的熱情。


比如說作業系統,應該把精力主要放在程序管理與排程、記憶體管理、併發程式設計與同步、高效的IO等等,而不要過於投入到初始化(從 BIOS 載入引導扇區、設定 GDT、進入保護模式)這種一次性任務上。我發現國內講 Linux 核心的書往往把初始化的細節放在前幾章,而國外的書通常放附錄,你可以體會一下。初始化對作業系統本身而言當然是重要的,但是對於在使用者態寫服務程式的人來說,弄清楚為什麼要開啟 PC 上的 A20 地址線真的有用處嗎?(這不過是個歷史包袱罷了。)


再比方說《計算機網路》,關鍵之一是理解如何在底層有丟包、重包、亂序的條件下設計出可靠的網路協議,這不算難。難一點的是這個可靠協議能達到“既能充分利用頻寬,又能做到足夠公平(併發連線大致平均分享頻寬)”。而不是學會手算 CRC32,這更應該放到資訊理論或別的課程裡去講。


注意分清知識的層次。就好比造汽車與開汽車的區別,我認為一個司機的技能主要體現在各種道路條件和天氣狀況下都能安全駕駛(城市道路、高速公路、鄉間公路 X 晴、雨、雪、霧),平安到達目的地。作為一名司機,瞭解汽車執行的基本原理當然是好事,可以有助於更好地駕駛和排除一些常見故障。但不宜喧賓奪主,只要你不真正從事汽車設計工作,你再怎麼研究發動機、傳動、轉向,也不可能比汽車廠的工程師強,畢竟這是人家的全職工作。而且鑽研汽車構造超過一定程度之後,對開好車就沒多大影響了,成了個人興趣愛好。“有的人學著學著成了語言專家,反而忘了自己原本是要解決問題來的。”(語出孟巖 快速掌握一個語言最常用的50%

對於併發程式設計來說,掌握 mutex、condition variable 的正確用法,避免誤用(例如防止 busy-waiting 和 data race)、避免效能 pitfalls,是一般服務端程式設計師應該掌握的知識。而如何實現高效的 mutex 則是 libc 和 kernel 開發者應該關心的事,隨著硬體的發展(CPU 與記憶體之間互聯方式的改變、核數的增加),最優做法也隨之改變。如果你不能持續跟進這一領域的發展,那麼你深入鑽研之後掌握的知識到了幾年之後可能反而成為負擔,當年針對當時硬體的最優特殊做法(好比說定製了自己的 mutex 或 lock-free 資料結構)在幾年後有可能反而會拖低效能。還不如按最清晰的方式寫程式碼,利用好語言和庫的現成同步設施,讓編譯器和 libc 的作者去操心“與時俱進”的事。

注意識別過時的知識。比方說《作業系統》講磁碟IO排程往往會講電梯演算法,但是現在的磁碟普遍內建了這一功能(NCQ),無需作業系統操心了。如果你在一個比較好的學校,作業系統課程的老師應該能指出這些知識點,避免學生浪費精力;如果你全靠自學,我也沒什麼好辦法,儘量用新版的書吧。類似的例子還有《計算機體系結構》中可能會講 RISC CPU 流水線中的 delay slot,現在似乎也都廢棄了。《計算機網路》中類似的情況也不少,首先是 OSI 七層模型已經被證明是扯淡的,現在國外流行的教材基本都按五層模型來講(Internet protocol suite),如果你的教材還鄭重其事地講 OSI (還描繪成未來的希望),扔了換一本吧。其次,區域網層面,乙太網一家獨大(幾乎成了區域網的代名詞),FDDI/Token ring/ATM 基本沒啥公司在用了。就說乙太網,現在也用不到 CSMA/CD 機制(因為 10M 的同軸電纜、10M/100M 的 hub 都過時了,交換機也早就普及了),因此碰撞檢測演算法要求“乙太網的最小幀長大於最大傳播延遲的二倍”這種知識點了解一下就行了。

另外一點是 low level 優化的知識非常容易過時,編碼時要避免過度擬合(overfitting)。比方說目前國內一些教科書(特別是大一第一門程式語言的教程)還在傳授“乘除法比加減法慢、浮點數運算比整數運算慢、位運算最快”這種過時的知識。現代通用 CPU 上的實際情況是整數的加減法和乘法運算幾乎一樣快,整數除法慢很多;浮點數的加減法和乘法運算幾乎和整數一樣快,浮點數除法慢很多。因此用加減法代替乘法(或用位運算代替算術運算)不見得能提速,反而讓程式碼難懂。而且現代編譯器可以把除數為小整數的整數除法轉變為乘法來做,無需程式設計師操心。(目前用浮點數乘法代替浮點數除法似乎還是值得一做的,例如除以10改為乘以0.1,因為浮點運算的特殊性(不滿足結合律和分配率),阻止了編譯器優化。)

類似的 low level 優化過時的例子是早年用匯編語言寫了某流行影象格式的編解碼器,但隨著 CPU 微架構的發展,其並不比現代 C 語言(可能用上 SIMD)的版本更快,反而因為使用了 32-bit 組合語言,導致往 64-bit 移植時出現麻煩。如果不能派人持續維護更新這個私有庫,還不如用第三方的庫呢。現在能用匯編語言寫出比 C 語言更快的程式碼幾乎只有一種可能:使用 CPU 的面向特定演算法的新指令,例如 Intel 的新 CPU 內建了 AES、CRC32、SHA1、SHA256 等演算法的指令。不過主流的第三方庫(例如 OpenSSL)肯定會用上這些手段,及時跟進即可,基本無需自己操刀。(再舉一個例子,假如公司早先用匯編語言寫了一個非常高效的大整數運算庫,一直運轉良好,原來寫這個庫的高人也升職或另謀高就了。Intel 在 2013 年釋出了新微架構 Haswell,新增了 MULX 指令,可以進一步提高大整數乘法的效率 GMP on Intel Haswell ,那麼貴公司是否有人持續跟進這些 CPU 的進化,並及時更新這個大整數運算庫呢?或者直接用開源的 GMP 庫,讓 GMP 的作者去操心這些事情?)

如果你要記住結論,一定要同時記住前提和適用條件。在錯誤的場合使用原本正確的結論的搞笑例子舉不勝舉。
  1. 《Linux核心原始碼情景分析》上分析核心使用 GDT/LDT 表項的狀況,得出程序數不超過 4090 的結論。如果你打算記住這個結論,一定要記住這是在 Linux 2.4.0 核心,32-bit Intel x86 平臺上成立,新版的核心和其他硬體平臺很可能不成立。看完書後千萬不要張口就來“書上說 Linux 的最大程序數是 4090”。
  2. 一個 Linux 程序最多建立 300 餘個執行緒,這個結論成立的條件是 3GB 使用者空間,執行緒棧為 10M 或 8M。在 64-bit 下不成立。
  3. Reactor 模式只能支援不超過 64 個 handle,這個結論成立的條件是 Windows 下使用 WaitForMultipleObjects 函式實現的 WFMO_Reactor,對於 Linux 下使用 poll/epoll 實現的 Reactor 則無此限制。
  4. C++ STL 的 vector 容器在 clear() 之後不會釋放記憶體,需要 swap(empty vector),這是有意為之(C++11 裡增加了 shrink_to_fit() 函式)。不要記成了所有 STL 容器都需要 swap(empty one) 來釋放記憶體,事實上其他容器(map/set/list/deque)都只需要 clear() 就能釋放記憶體。只有含 reserve()/capacity() 成員函式的容器才需要用 swap 來釋放空間,而 C++ 裡只有 vector 和 string 這兩個符合條件。

最後一點小建議,服務端開發這幾年已經普及 64-bit 多核硬體平臺,因此在學習作業系統的時候,可以不必太關心單核上特有的做法(在單核時代,核心程式碼進入臨界區的辦法之一是關中斷,但到了多核時代,這個做法就行不通了),也不必太花精力在 32-bit 平臺上。特別是 32-bit x86 為了能支援大記憶體,不得已有很多 work around 的做法(困難在於 32-bit 地址空間不夠將全部實體記憶體對映入核心),帶來了額外的複雜性,這些做法當時有其積極意義,但現在去深入學似乎不太值得。

關於專案,我出兩個練手題目

一、多機資料處理。有 10 臺機器,每臺機器上儲存著 10 億個 64-bit 整數(不一定剛好 10 億個,可能有上下幾千萬的浮動),一共約 100 億個整數(其實一共也就 80GB 資料,不算大,選這個量級是考慮了 VPS 虛擬機器的容量,便於實驗)。程式設計求出:

1. 這些數的平均數。

2. 這些數的中位數。

3. 出現次數最多的 100 萬個數。

*4. (附加題)對這 100 億個整數排序,結果順序存放到這 10 臺機器上。

*5. (附加健壯性要求)你的程式應該能正確應對輸入資料的各種分佈(均勻、正態、Zipf)。

*6. (附加伸縮性要求)你的程式應該能平滑擴充套件到更多的機器,支援更大的資料量。比如 20 臺機器、一共 200 億個整數,或者 50 臺機器、一共 500 億個整數。


二、N-皇后問題的多機並行求解。利用多臺機器求出 N-皇后問題有多少個解。(注意目前的世界紀錄是 N = 26,A000170 - OEIS )

1. 8 皇后問題在單機上的運算時間是毫秒級,有 92 個解,程式設計實現之。

2. 研究 N-皇后問題的並行演算法,寫一個單機多執行緒程式,爭取達到線性加速比(以 CPU 核數計)。再設法將演算法擴充套件到多機並行。

3. 用 10 臺 8 核的機器(一共 80 個 CPU cores),求解 19-皇后和 20-皇后問題,看看分別需要多少執行時間。你的方案能否平滑擴充套件到更多的機器?

*4. (附加題)如果這 10 臺機器的型號不一,有 8 核也有 16 核,有舊 CPU 也有更快的新 CPU,你該採用何種負載均衡策略,以求縮短求解問題的時間(至少比 plain round-robin 演算法要好)?


你可以用 Amazon EC2 或 Google GCE 來驗證你的程式的正確性和效能,這兩家的虛擬機器都是按小時(甚至更短)收費,開 10 臺虛擬機器做一個下午的實驗也花不了多少錢。