引言
深入理解計算機系統,對我來說是部大塊頭。說實話,我沒有從頭到尾完完整整的全部看完,而是選擇性的看了一些我自認為重要的或感興趣的章節,也從中獲益良多,看清楚了計算機系統的一些本質東西或原理性的內容,這對每個想要深入學習程式設計的程式設計師來說都是至關重要的。只有很好的理解了系統到底是如何執行我們程式碼的,我們才能針對系統的特點寫出高質量、高效率的程式碼來。這本書我以後還需要多研究幾遍,今天就先總結下書中我已學到的幾點知識。
重點筆記
-
編寫高效的程式需要下面幾類活動:
- 選擇一組合適的演算法和資料結構。這是很重要的,好的資料結構有時能幫助更快的實現某些演算法,這也要求程式設計人員能夠熟知各種常用的資料結構和演算法。
- 編寫出使編譯器能夠有效優化以轉換成高效可執行的原始碼。因此,理解編譯器優化的能力和侷限性是很重要的。編寫程式方式中看上去只是一點小小的變動,都會引起編譯器優化方式很大的變化。有些程式語言比其他語言容易優化得多。C語言的某些特性,例如執行指標運算和強制型別轉換的能力,使得編譯器很難對其進行優化。
- 並行技術,針對處理運算量特別大的計算,將一個任務分成多個部分,這些部分可以在多核和多處理器的某種組合上並行地計算。
-
讓編譯器展開迴圈
說到程式優化,很多人都會提到迴圈展開技術。現在編譯器可以很容易地執行迴圈展開,只要優化級別設定的足夠高,許多編譯器都能例行公事的做到這一點。用命令列選項“-funroll-loops”呼叫gcc,會執行迴圈展開。 -
效能提高技術:
- 高階設計,為手邊的問題選擇適當的演算法和資料結構,要特別警覺,避免使用會漸進地產生糟糕效能的演算法或編碼技術。
- 基本編碼原則。避免限制優化的因素,這樣編譯器就能產生高效程式碼。
- 消除連續的函式呼叫。在可能時將計算移到迴圈外,考慮有選擇的妥協程式的模組性以獲得更大效率。
- 消除不必要的儲存器引用。引入臨時變數來儲存中間結果,只有在最後的值計算出來時,才能將結果放到陣列或全域性變數中。
- 低階優化。
- 嘗試各種與陣列程式碼相對的指標形式。
- 通過開展通過展開迴圈降低迴圈開銷。
- 通過諸如迭代分割之類的技術,找到使用流水線化的功能單元的方法。
說到效能提高,可能有人會有一些說法:
(1)不要過早優化,優化是萬惡之源;
(2)花費很多時間所作的優化可能效果不明顯,不值得;
(3)現在記憶體、CPU價格都這麼低了,效能的優化已經不是那麼重要了。
……其實我的看法是:我們也許不必特地把以前寫過的程式拿出來優化下,花費N多時間只為提升那麼幾秒或幾分鐘的時間。但是,我們在重構別人的程式碼或自己最初開始構思程式碼時,就需要知道這些效能提高技術,一開始就遵守這些基本原則來寫程式碼,寫出的程式碼也就不需要讓別人來重構以提高效能了。另外,有的很簡單的技術,比如說將與迴圈無關的複雜計算或大記憶體操作的程式碼放到迴圈外,對於整個效能的提高真的是較明顯的。
-
如何使用程式碼剖析程式(code profiler,即效能分析工具)來調優程式碼?
程式剖析(profiling)其實就是在執行程式的一個版本中插入了工具程式碼,以確定程式的各個部分需要多少時間。
Unix系統提供了一個profiling叫GPROF
,這個程式產生兩類資訊:首先,它確定程式中每個函式花費了多少CPU時間。
其次,它計算每個函式被呼叫的次數,以執行呼叫的函式來分類。還有每個函式被哪些函式呼叫,自身又呼叫了哪些函式。使用GPROF進行剖析需要3個步驟,比如源程式為prog.c。
1)編譯:gcc -O1 -pg prog.c -o prog
(只要加上-pg引數即可)
2)執行:./prog
會生成一個gmon.out檔案供 gprof分析程式時候使用(執行比平時慢些)。
3)剖析:gprof prog
分析gmon.out中的資料,並顯示出來。
剖析報告的第一部分列出了執行各個函式花費的時間,按照降序排列。
剖析報告的第二部分是函式的呼叫歷史。具體例子可參考網上資料。GPROF有些屬性值得注意:
- 計時不是很準確。它的計時基於一個簡單的間隔計數機制,編譯過的程式為每個函式維護一個計數器,記錄花費在執行該函式上的時間。對於執行時間較長的程式,相對準確。
- 呼叫資訊相當可靠。
- 預設情況下,不顯示庫函式的呼叫。相反地,庫函式的時間會被計算到呼叫它們的函式的時間中。
-
靜態連結和動態連結一個很重要的區別是:動態連結時沒有任何動態連結庫的程式碼和資料節真正的被拷貝到可執行檔案中,反之,連結器只需拷貝一些重定位和符號表資訊,即可使得執行時可以解析對動態連結庫中程式碼和資料的引用。
-
儲存器對映
指的是將磁碟上的空間對映為虛擬儲存器區域。Unix程式可以使用mmap函式來建立新的虛擬儲存器區域,並將物件對映到這些區域中,這屬於低階的分配方式。
一般C程式會使用malloc和free來動態分配儲存器區域,這是利用堆的方式。 -
造成堆利用率很低的主要原因是碎片,當雖然有未使用的儲存器但不能用來滿足分配請求時,就會發生這種現象。
有兩種形式的碎片:內部碎片和外部碎片。兩者的區別如下:- 內部碎片是在一個已分配的塊比有效載荷大時發生的。例如,有些分配器為了滿足對其約束新增額外的1字的儲存空間,這個1字的空間就是內部碎片。它就是已分配塊大小和它們的有效載荷大小之差的和。
- 外部碎片是當空閒儲存器合計起來足夠滿足一個分配請求,但是沒有一個單獨的空閒塊足夠大可以來處理這個請求時發生的。
-
現代OS提供了三種方法實現併發程式設計:
- 程式。用這種方法,每個邏輯控制流都是一個程式,由核心來排程和維護。因為程式有獨立的虛擬地址空間,想要和其他流通訊,控制流必須使用程式間通訊(IPC)。
- I/O多路複用。這種形式的併發,應用程式在一個程式的上下文中顯示地排程它們自己的邏輯流。邏輯流被模擬為“狀態機”,資料到達檔案描述符後,主程式顯示地從一個狀態轉換到另一個狀態。因為程式是一個單獨的程式,所以所有的流都共享一個地址空間。
- 執行緒。執行緒是執行在一個單一程式上下文中的邏輯流,由核心進行排程。執行緒可以看做是程式和I/O多路複用的合體,像程式一樣由核心排程,像I/O多路複用一樣共享一個虛擬地址空間。
(1)基於程式的併發伺服器
構造併發最簡單的就是使用程式,像fork函式。例如,一個併發伺服器,在父程式中接受客戶端連線請求,然後建立一個新的子程式來為每個新客戶端提供服務。為了瞭解這是如何工作的,假設我們有兩個客戶端和一個伺服器,伺服器正在監聽一個監聽描述符(比如描述符3)上的連線請求。下面顯示了伺服器是如何接受這兩個客戶端的請求的。關於程式的優劣,對於在父、子程式間共享狀態資訊,程式有一個非常清晰的模型:共享檔案表,但是不共享使用者地址空間。程式有獨立的地址控制元件愛你既是優點又是缺點。由於獨立的地址空間,所以程式不會覆蓋另一個程式的虛擬儲存器。但是另一方面程式間通訊就比較麻煩,至少開銷很高。
(2)基於I/O多路複用的併發程式設計
比如一個伺服器,它有兩個I/O事件:1)網路客戶端發起連線請求,2)使用者在鍵盤上鍵入命令列。我們先等待那個事件呢?沒有那個選擇是理想的。如果accept中等待連線,那麼無法響應輸入命令。如果在read中等待一個輸入命令,我們就不能響應任何連線請求(這個前提是一個程式)。
針對這種困境的一個解決辦法就是I/O多路複用技術。基本思想是:使用select函式,要求核心掛起程式,只有在一個或者多個I/O事件發生後,才將控制返給應用程式。
I/O多路複用的優劣:由於I/O多路複用是在單一程式的上下文中的,因此每個邏輯流程都能訪問該程式的全部地址空間,所以開銷比多程式低得多;缺點是程式設計複雜度高。(3)基於執行緒的併發程式設計
每個執行緒都有自己的執行緒上下文,包括一個執行緒ID、棧、棧指標、程式計數器、通用目的暫存器和條件碼。所有的執行在一個程式裡的執行緒共享該程式的整個虛擬地址空間。由於執行緒執行在單一程式中,因此共享這個程式虛擬地址空間的整個內容,包括它的程式碼、資料、堆、共享庫和開啟的檔案。所以我認為不存線上程間通訊,執行緒間只有鎖的概念。-
執行緒執行的模型。執行緒和程式的執行模型有些相似。每個程式的生明週期都是一個執行緒,我們稱之為主執行緒。但是大家要有意識:執行緒是對等的,主執行緒跟其他執行緒的區別就是它先執行。
一般來說,執行緒的程式碼和本地資料被封裝在一個執行緒例程中(就是一個函式)。該函式通常只有一個指標引數和一個指標返回值。
在Unix中執行緒可以是joinable(可結合)或者detached(分離)的。joinable可以被其他執行緒殺死,detached執行緒不能被殺死,它的儲存器資源有系統自動釋放。 -
執行緒儲存器模型,每個執行緒都有它自己的獨立的執行緒上下文,包括執行緒ID、棧、棧指標、程式計數器、條件碼和通用目的暫存器。每個執行緒和其他執行緒共享剩下的部分,包括整個使用者虛擬地址空間,它是由程式碼段、資料段、堆以及所有的共享庫程式碼和資料區域組成。不同執行緒的棧是對其他執行緒不設防的,也就是說:如果一個執行緒以某種方式得到一個指向其他執行緒的指標,那麼它可以讀取這個執行緒棧的任何部分。
-
什麼樣的變數多執行緒可以共享,什麼樣的不可以共享?
有三種變數:全域性變數、本地自動變數(區域性變數)和本地靜態變數,其中本地自動變數每個執行緒的本地棧中都存有一份,不共享。而全域性變數和靜態變數可以共享。