程式設計師的自我修養:溫故而知新

發表於2017-08-11

1.1從Hello World說起

目的:從最基本的編譯,靜態連結到作業系統如何轉載程式,動態連結及執行庫和標準庫的實現,和一些作業系統的機制。瞭解計算機上程式執行的一個基本脈絡。

1.2變不離其宗

計算機最關鍵的三個部分:CPU,記憶體,I/O控制晶片。

  • 早期的計算機:沒有複雜的圖形功能,CPU和記憶體頻率一樣,都連線在同一個匯流排上。
  • CPU頻率提升:記憶體跟不上CPU,產生了和記憶體頻率一致的系統匯流排,CPU使用倍頻的方式和匯流排通訊。
  • 圖形介面的出現:圖形晶片需要和記憶體和CPU大量交換資料,慢速的I/O匯流排無法滿足圖形裝置的巨大需求。為了高效處理資料,設計了一個高速的北橋晶片。後來有設計處理低速處理裝置南橋晶片,磁碟,USB,鍵盤都是連線在南橋上。在由南橋將它們彙總到北橋上。
北橋
  • 北橋左邊CPU和cache:CPU負責所有控制和運算。
  • 北橋下面PCI匯流排
  • 北橋右邊memory
SMP和多核

現在CPU已經達到物理極限,被4GHz所限制,於是,開始通過增加CPU數量來提高計算機速度。
對稱多處理器(SMP):最常見的一種形式。每個CPU在系統中所處的地位和所發揮的功能是一樣,是相互對稱的。但在處理程式時,我們並不能把他們分成若干個不相干的子問題,所以,使得多處理器速度實際提高得並沒有理論上那麼高。當對於相互獨立的問題,多處理器就能最大效能的發揮威力了(比如:大型資料庫,網路服務等)。
對處理器由於造價比較高昂,主要用在商用電腦上,對於個人電腦,主要是多核處理器
多核處理器:其實際上是(SMP)的簡化版,思想是將多個處理器合併在一起打包出售,它們之間共享比較昂貴的快取部件,只保留了多個核心。在邏輯上看,它們和SMP完全相同。

1.3站得高,看得遠

系統軟體:一般用於管理計算機本地的軟體。

主要分為兩塊:

  • 平臺性的:作業系統核心,驅動程式,執行庫。
  • 程式開發:編譯器,彙編器,連結器。

計算機系統軟體體系結構採用一種的結構。
每個層次之間都需要相互通訊,那麼它們之間就有通訊協議,我們將它稱為介面,介面下層是提供者,定義介面。上層是使用者,使用介面實現所需功能。
除了硬體和應用程式,其他的都是中間層,每個中間層都是對它下面的那層的包裝和擴充套件。它們使得應用程式和硬體之間保持相對獨立。
從整個層次結構來看,開發工具與應用程式屬於同一個層次,因為它們都使用同一個介面—作業系統應用程式程式設計介面。應用程式介面提供者是執行庫,什麼樣的執行庫提供什麼樣的介面。winsows的執行庫提供Windows API,Linux下的Gliba庫提供POSIX的API。
執行庫使用作業系統提供的系統呼叫介面
系統呼叫介面在實現中往往以軟體中斷的方式提供。
作業系統核心層對於硬體層來說是硬體介面的使用者,而硬體是介面的定義者。這種介面叫做硬體規格

1.4作業系統做了什麼

作業系統的一個功能是提供抽象的介面,另外一個主要功能是管理硬體資源。
一個計算機中的資源主要分CPU,儲存器(包括記憶體和磁碟)和I/O裝置。下面從這3個方面來看如何挖機它們。

1.4.1不要讓CPU打盹

多道程式:編譯一個監控程式,當程式不需要使用CPU時,將其他在等待CPU的程式啟動。但它的弊端是不分輕重緩急,有時候一個互動操作可能要等待數十分鐘。
改進後
分時系統:每個CPU執行一段時間後,就主動讓出給其他CPU使用。完整的作業系統雛形在此時開始出現。但當一個程式當機的時候,無法主動讓出CPU,那麼,整個系統都無法響應。
目前作業系統採用的方式
多工系統:作業系統接管了所有的硬體資源,並且本身執行在一個受硬體保護的級別。所有的應用都以程式的方式執行在比作業系統更低的級別,每個程式都有自己獨立的地址空間,使得程式之間的地址空間相互隔離。CPU由作業系統進行同一分配,每個程式根據程式優先順序的高低都有機會獲得CPU,但如果執行超過一定的時間,CPU會將資源分配給其他程式,這種CPU分配方式是搶佔式。如果作業系統分配每個程式的時間很短,就會造成很多程式都在同時執行的假象,即所謂的巨集觀並行,微觀序列

裝置驅動

作業系統作為硬體層的上層,它是對硬體的管理和抽象。
對於作業系統上面的執行庫和應用程式來說,它們只希望看到一個統一的硬體訪問模式。
當成熟的作業系統出現後,硬體逐漸成了抽象的概念。在UNIX中,硬體裝置的訪問形式和訪問普通的檔案形式一樣。在Windows系統中,圖形硬體被抽象成GDI,聲音和多媒體裝置被抽象成DirectX物件,磁碟被抽象成普通檔案系統。
這些繁瑣的硬體細節全都交給了作業系統中的硬體驅動。
檔案系統管理這磁碟的儲存方式。
磁碟的結構:一個硬碟往往有多個碟片,每個碟片分兩面,每面按照同心圓劃分為若干磁軌,每個磁軌劃分為若干扇區,每個扇區一般512位元組。
LBA:整個硬碟中所有扇區從0開始編號,一直到最後一個扇區,這個扇區編號叫做邏輯扇區號
檔案系統儲存了這些檔案的儲存結構,負責維護這些資料結構並且保證磁碟中的扇區能有效的組織和利用。

1.5記憶體不夠怎麼辦

在早期計算機中,程式是直接執行在實體記憶體上的,程式所訪問的都是實體地址。
那麼如何將計算機有限的地址分配給多個程式使用。
直接按實體記憶體分配將產生很多問題:

  • 地址空間不隔離:所有的程式都直接訪問實體地址,導致程式使用的實體地址不是相互隔離的,惡意的程式很容易串改其他程式的記憶體資料。
  • 記憶體使用效率低:由於沒有有效的記憶體管理機制,通常一個程式執行的時候,監控程式要將整個程式讀入。記憶體不夠的時候,需要先將記憶體中的程式讀出,儲存在硬碟上,才能將需要執行的程式讀入。這樣會使得整個過程有大量資料換入換出。
  • 程式執行地址不確定:每次程式執行都需要記憶體分配一塊足夠大的記憶體空間,使得這個地址是不確定的。但在程式編寫的時候,他訪問的資料和指令跳轉的目標地址都是固定的,這就涉及到了程式的重定向問題。

一種解決辦法:
中間層:使用一種間接的地址訪問方法,我們把程式給出的地址看作一種虛擬地址。虛擬地址是實體地址的對映,只要處理好這個過程,就可以起到隔離的作用。

1.5.1關於隔離

普通的程式它只需要一個簡單的執行環境,一個單一的地址空間,有自己的CPU。
地址空間比較抽象,如果把它想象成一個陣列,每一個陣列是一位元組,陣列大小就是地址空間的長度,那麼32位的地址空間大小就是2^32=4294967296位元組,即4G,地址空間有效位是0x00000000~0xFFFFFFFF。
地址空間分為兩種:
物理空間:就是實體記憶體。32位的機器,地址線就有32條,物理空間4G,但如果值裝有512M的記憶體,那麼實際有效的空間地址就是0x00000000~0x1FFFFFFF,其他部分都是無效的。
虛擬空間:每個程式都有自己獨立的虛擬空間,而且每個程式只能訪問自己的空間地址,這樣就有效的做到了程式隔離。

1.5.2分段

基本思路:把一段與程式所需要的記憶體空間大小的虛擬空間對映到某個地址空間。虛擬空間的每個位元組對應物理空間的每個位元組。這個對映過程由軟體來完成。
分段的方式可以解決之前的第一個(地址空間不隔離)和第三個問題(程式執行地址不確定)
第二問題記憶體使用效率問題依舊沒有解決。

1.5.3分頁

基本方法:把地址空間人為的分成固定大小的頁,每一頁大小有硬體決定或硬體支援多種大小的頁,由作業系統決定頁的大小。
目前幾乎所有的PC上的作業系統都是4KB大小的頁。
我們把程式的虛擬地址空間按頁分割,把常用的資料和內碼表轉載到記憶體中,把不常用的程式碼和資料儲存到磁碟裡,當需要的時候從磁碟取出來。
虛擬空間的頁叫做虛擬頁(VP),實體記憶體中頁叫做物理頁,把磁碟中的頁叫做磁碟頁。虛擬空間的有的頁被對映到同一個物理頁,這樣就可以實現記憶體共享。
當程式需要一個頁時,這個頁是磁碟頁時,硬體會捕獲到這個訊息,就是所謂的頁錯誤,然後作業系統接管程式,負責從磁碟中讀取內容裝入記憶體中,然後再將記憶體和這個頁建立對映關係。
保護也是頁對映的目的之一,每個頁都可以設定許可權屬性,只有作業系統可以修改這些屬性,這樣作業系統就可以保護自己保護程式。
虛擬儲存的實現需要依靠硬體支援,所有硬體都採用一個叫做MMU的部件來進行頁對映。
CPU發出虛擬地址經過MMU轉換成實體地址,MMU一般都整合在CPU內部。

1.6眾人拾柴火焰高

1.6.1執行緒基礎

多執行緒現在作為實現軟體併發執行的一個重要方法,具有越來越重的地位。

什麼是執行緒

執行緒有時被稱為輕量級的程式,是程式執行流的最小單位。

構成:

  • 執行緒ID
  • 當前指令指標
  • 暫存器集合
  • 堆疊空間(程式碼段,資料段,堆)
  • 程式級的資源(開啟檔案和訊號)

執行緒與程式的關係:

多執行緒可以互不干擾的併發執行,並共享程式的全域性變數和堆的資料。
使用多執行緒的原因有如下幾點:

  • 某個操作可能會陷入長時間等待,等待的執行緒會進入睡眠狀態,無法繼續執行。
  • 某個操作會消耗大量的時間,如果只有一個執行緒,程式和使用者之間的互動會中斷。
  • 程式邏輯本身就要求併發操作。
  • 多CPU或多核計算機,本身具備同時執行多個執行緒的能力。
  • 相對於多程式應用,多執行緒在資料共享方面效率要高很多。
執行緒的訪問許可權

執行緒的訪問非常自由,它可以訪問程式記憶體裡所有資料,包括其他執行緒的堆疊(如果知道地址的話,情況很少見)。
執行緒自己的私用儲存空間:

  • 棧(併發完全無法被其他執行緒訪問)
  • 執行緒區域性儲存。某些作業系統為執行緒提供私用空間,但容量有限。
  • 暫存器。執行流的基本資料,為執行緒私用。
執行緒私用 執行緒間共享(程式所有)
區域性變數 全域性變數
函式引數 堆上資料
TLS資料 函式裡的靜態變數
程式程式碼
開啟的檔案,A執行緒開啟的檔案可以由B執行緒讀取
執行緒排程與優先順序

不論在多處理器還是單處理器上,執行緒都是“併發”的。
執行緒數量小於處理器數量時,是真正併發的。
單處理器下,併發是模擬的,作業系統會讓這些多執行緒程式輪流執行,每次都只執行一小段時間,這就稱為執行緒排程
執行緒排程中,執行緒擁有三種狀態:

  • 執行:執行緒正在執行
  • 就緒:執行緒可以立刻執行,但CPU被佔用
  • 等待:執行緒正在等待某一事件發生,無法立即執行。

處於執行中的執行緒擁有一段可以執行的時間,這稱為時間片,當時間片用盡的時候,程式進入就緒狀態,如果在用盡之前開始等待某事件,那麼它就進入等待狀態。每當一個執行緒離開執行狀態的時候,排程系統就會選擇一個其他的就緒執行緒繼續執行。

現在的主流排程方法儘管都不一樣,但基本都帶有優先順序排程輪轉法
輪轉法:各個執行緒輪流執行一段時間。
優先順序排程:按執行緒的優先順序來輪流執行,每個執行緒都擁有各自的執行緒優先順序。
在win和lin裡面,執行緒優先順序不僅可以由使用者手動設定,系統還會根據不同執行緒表現自動調整優先順序。
一般頻繁等待的執行緒稱之為IO密集型執行緒,而把很少等待的執行緒稱為CPU密集型執行緒
優先順序排程下,存在一種餓死現象。
餓死:執行緒優先順序較低,在它執行之前,總是有較高階的執行緒要執行,所以,低優先順序執行緒總是無法執行的。
當一個CPU密集型執行緒獲得較高優先順序時,許多低優先順序執行緒就可能被餓死。
為了避免餓死,作業系統常常會逐步提升那些等待時間過長的執行緒。
執行緒優先順序改變一般有三種方式:

  • 使用者指定優先順序
  • 根據進入等待狀態的頻繁程度提升或降低優先順序
  • 長時間得不到執行而被提升優先順序
可搶佔執行緒和不可搶佔執行緒

搶佔:線上程用盡時間片之後被強制剝奪繼續執行的權利,而進入就緒狀態。
在早期的系統中,執行緒是不可搶佔的,執行緒必須主動進入就緒狀態。
在不可搶佔執行緒中,執行緒主動放棄主要是2種:

  • 當執行緒試圖等待某個事件(I/O)時
  • 執行緒主動放棄時間片

不可搶佔執行緒有一個好處,就是執行緒排程只會發生線上程主動放棄執行或執行緒等待某個事件的時候,這樣就可以避免一些搶佔式執行緒時間不確定而產生的問題。

Linux的多執行緒

Linux核心中並不存在真正意義上的執行緒概念。Linux所有執行實體(執行緒和程式)都稱為任務,每一個任務概念上都類似一個單執行緒的程式,具有記憶體空間,執行實體,檔案資源等。Linux不同任務之間可以選擇共享記憶體空間,相當於同一個記憶體空間的多個任務構成一個程式,這些任務就是程式中的執行緒。

系統呼叫 作用
fork 複製當前執行緒
exec 使用新的可執行映像覆蓋當前可執行映像
clone 建立子程式並從指定位置開始執行

fork產生新任務速度非常快,因為fork不復制原任務的記憶體空間,而是和原任務一起共享一個寫時複製的記憶體空間。

寫時複製:兩個任務可以同時自由讀取記憶體,當任意一個任務試圖對記憶體進行修改時,記憶體就會複製一份單獨提供給修改方使用。
fork只能夠產生本任務的映象,因此需要和exec配合才能啟動別的新任務。
而如果要產生新執行緒,則使用clone。
clone可以產生一個新的任務,從指定位置開始執行,並且共享當前程式的記憶體空間和檔案等,實際效果就是產生一個執行緒。

1.6.2執行緒安全

多執行緒程式處於一個多變的環境中,可訪問的全域性變數和堆資料隨時都可能被其他的執行緒改變。因此多執行緒程式在併發時資料的一致性變得非常重要。

競爭與原子操作

++i的實現方法:

  • 讀取i到某個暫存器X
  • X++
  • 將X的內容儲存回i
    單條指令的操作稱為原子的,單挑指令的執行不會被打斷。在windows裡,有一套API專門進行一些原子操作,這些API稱為Interlocked API。
同步與鎖

為了防止多個執行緒讀取同一個資料產生不可預料結果,我們將各個執行緒對一個資料的訪問同步。
同步:在一個執行緒對一個資料訪問結束的時候,其他執行緒不能對同一個資料進行訪問。對資料的訪問被原子化。
:鎖是一種非強制機制,每一個執行緒在訪問資料或者資源之前會先獲取鎖,在訪問結束後會釋放鎖。在鎖被佔用時候試圖獲取鎖時,執行緒會等待,知道鎖可以重新使用。
二元訊號量:最簡單的鎖,它適合只能被唯一一個執行緒獨佔訪問的資源,它的兩種狀態:

  • 非佔用狀態:第一個獲取該二元訊號量的執行緒會獲得該鎖,並將二元訊號量置為佔用狀態,其他所有訪問該二元訊號量執行緒將會等待。
  • 佔用狀態

訊號量:允許多個執行緒併發訪問的資源。一個初始值為N的訊號量允許N個執行緒併發訪問。
操作如下:

  • 將訊號量值鍵1
  • 如果訊號量值小於0,就進入等待狀態。

訪問完資源後,執行緒釋放訊號量:

  • 將訊號量加1
  • 如果訊號量的值小於1,喚醒一個等待中的執行緒。

互斥量:和二元訊號量很類似,但和訊號量不同的是:訊號量在一個系統中,可以被任意執行緒獲取或釋放。互斥量要求那個執行緒獲取互斥量,那麼哪個執行緒就釋放互斥量,其他執行緒釋放無效。
臨界區:比互斥量更加嚴格的手段。把臨界區的鎖獲取稱為進入臨界區,而把鎖的釋放稱為離開臨界區。臨界區和互斥量,訊號量區別在與互斥量,訊號量在系統中任意程式都是可見的。臨界區的作用範圍僅限於本執行緒,其他執行緒無法獲取。其他性質與互斥量相同。
讀寫鎖:致力於一種更加特定的場合的同步。如果使用之前使用的訊號量、互斥量或臨界區中的任何一種進行同步,對於讀取頻繁,而僅僅是偶爾寫入的情況會顯得非常低效。讀寫鎖可以避免這個問題。對於同一個鎖,讀寫鎖有兩種獲取方式:

  • 共享的
  • 獨佔的

讀寫鎖的總結

讀寫鎖狀態 以共享方式獲取 以獨佔方式獲取
自由 成功 成功
共享 成功 等待
獨佔 等待 等待

條件變數:作為同步的手段,作用類似於一個柵欄。對於條件變數,執行緒有兩個操作:

  • 執行緒可以等待條件變數,一個條件變數可以被多個執行緒等待
  • 執行緒可以喚醒條件變數,此時某個或所有等待此條件變數的執行緒都會被喚醒並繼續支援

使用條件變數可以讓許多執行緒一起等待某個事件的發生,當事件發生時,所有執行緒可以一起恢復執行。

可重入與執行緒安全

一個函式被重入,表示這個函式沒有執行完成,由於外部因素或內部呼叫,又一次進入該函式執行。
一個函式要被重入,只有兩種情況:

  • 多個執行緒同時執行這個函式
  • 函式自身(可能經過多層呼叫之後)呼叫自身

一個函式被稱為可重入,表示重入之後不會產生任何不良影響

可重入函式:

一個函式要成為可重入,必須具有如下特點:

  • 不使用任何(區域性)靜態或全域性的非const變數
  • 不返回任何(區域性)靜態或全部的非const變數的指標
  • 僅依賴呼叫方提供的引數
  • 不依賴任何單個資源的鎖
  • 不呼叫任何不可重入的函式

可重入是併發安全的強力保障,一個可重入的函式可以在多程式環境下方向使用

過度優化

有時候合理的合理的使用了鎖也不一定能保證執行緒的安全。

上面X的值應該為2,但如果編譯器為了提高X的訪問速度,把X放到了某個暫存器裡面,不同執行緒的暫存器是各自獨立的,因此,如果Thread1先獲得鎖,則程式的執行可能會呈現如下:
[Thread1]讀取x的值到某個暫存器R [1] (R[1]=0);
[Thread1]R[1]++(由於之後可能要訪問到x,所以Thread1暫時不將R[1]寫回x);
[Thread2]讀取x的值到某個暫存器R[2] (R[2]=0);
[Thread2]R[2]++(R[2]=1);
[Thread2]將R[2]寫回至x(x=1);
[Thread1] (很久以後)將R[1]寫回至x(x=1);
如果這樣,即使加鎖也不能保證執行緒安全

上面程式碼有可能發生r1=r2=0的情況。
CPU動態排程:在執行程式的時候,為了提高效率有可能交換指令的順序。
編譯器在進行優化的時候,也可能為了效率交換兩個毫不相干的相鄰指令的執行順序。
上面程式碼執行順序可能是這樣:

使用volatile關鍵字可以阻止過度優化,colatile可以做兩件事情:

  • 阻止編譯器為了提高速度將一個變數快取到暫存器內而不寫回
  • 阻止編譯器調整操作volatile變數的指令順序

但volatile無法阻止CPU動態排程換序
C++中,單例模式。

CPU的亂序執行可能會對上面程式碼照成影響
C++裡的new包含兩個步驟:

  • 分配記憶體
  • 呼叫建構函式

所以pInst=new T包含三個步驟:

  • 分配記憶體
  • 在記憶體的位置上呼叫建構函式
  • 將記憶體的地址賦值給pInst

這三步中2和3的步驟可以顛倒,可能出現這種情況:pInst中的值不是NULL,但物件還是沒有構造完成。
要阻止CPU換序,可以呼叫一條指令,這條指令常常被稱為barrier:它會阻止CPU將該指令之前的指令交換到barrier之後。
許多體系的CPU都提供了barrier指令,不過,它們的名稱各不相同。例如POWERPC提供的指令就叫做lwsync。所以我們可以這樣保證執行緒安全:

1.6.3多執行緒的內部情況

執行緒的併發執行是由多處理器或作業系統排程來實現的。windows和linux都在核心中提供執行緒支援,有多處理器或排程來實現併發。使用者實際使用執行緒並不是核心執行緒,而是存在於使用者態的使用者執行緒。使用者執行緒並不一定在作業系統核心裡對應同等數量的核心執行緒。對使用者來說,如果有三個執行緒同時執行,可能在核心中只有一個執行緒。

一對一模型

對於直接支援執行緒的系統,一對一模型始終是最為簡單的模型。一個使用者使用的執行緒就唯一對應一個核心使用的執行緒,但返回來,一個核心裡面的執行緒在使用者態不一定有對應的執行緒存在。

對於一對一模型,執行緒之間的併發是真正的併發,一個執行緒因為某個原因阻塞,並不會影響到其他執行緒。一對一模型也可以讓多執行緒程式在多處理器的系統上有更好的表現。
一般直接使用API或者系統呼叫建立的執行緒均為一對一執行緒。
一對一執行緒的兩個缺點:

  • 由於許多作業系統限制了核心執行緒數量,因此一對一執行緒會讓使用者的執行緒數量受到限制。
  • 許多作業系統核心執行緒排程是,上下文切換的開銷較大,導致使用者執行緒的執行效率下降。
多對一模型

多對一模型將多個使用者執行緒對映到一個核心執行緒上,執行緒之間的切換由使用者態的程式碼來進行,相對於一對一模型,多對一模型的執行緒切換要快速許多。

多對一模型的問題就是如果一個使用者執行緒阻塞了,那麼所有的執行緒都將無法執行。在多處理系統上,處理器的增多對多對一模型的執行緒效能不會有明顯幫助。多對一模型得到的好處是高效的上下文切換和幾乎無限制的執行緒數量。

多對多模型

多對多模型結合了多對一和一對一的特點,將多個使用者執行緒對映到少數但不止一個核心執行緒上。

一個使用者執行緒阻塞並不會使得所有的使用者執行緒阻塞。並且對使用者執行緒數量也沒有什麼限制,在多處理器系統上,多對多模型的執行緒也能得到一定的效能提升,不過提升的幅度沒有一對一模型高。

相關文章