程序員的自我修養一溫故而知新

1.1從Hello World提及

目的:從最基本的編譯,靜態連接到操做系統如何轉載程序,動態連接及運行庫和標準庫的實現,和一些操做系統的機制。瞭解計算機上程序運行的一個基本脈絡。linux

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站得高,看得遠

系統軟件:通常用於管理計算機本地的軟件。windows

主要分爲兩塊:數組

  • 平臺性的:操做系統內核,驅動程序,運行庫。
  • 程序開發:編譯器,彙編器,連接器。

計算機系統軟件體系結構採用一種的結構。
每一個層次之間都須要相互通訊,那麼它們之間就有通訊協議,咱們將它稱爲接口,接口下層是提供者,定義接口。上層是使用者,使用接口實現所需功能。
除了硬件和應用程序,其餘的都是中間層,每一箇中間層都是對它下面的那層的包裝和擴展。它們使得應用程序和硬件之間保持相對獨立。
從整個層次結構來看,開發工具與應用程序屬於同一個層次,由於它們都使用同一個接口—操做系統應用程序編程接口。應用程序接口提供者是運行庫,什麼樣的運行庫提供什麼樣的接口。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,喚醒一個等待中的線程。

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

  • 共享的
  • 獨佔的

讀寫鎖的總結

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

條件變量:做爲同步的手段,做用相似於一個柵欄。對於條件變量,線程有兩個操做:

  • 線程能夠等待條件變量,一個條件變量能夠被多個線程等待
  • 線程能夠喚醒條件變量,此時某個或全部等待此條件變量的線程都會被喚醒並繼續支持

使用條件變量可讓許多線程一塊兒等待某個事件的發生,當事件發生時,全部線程能夠一塊兒恢復執行。

可重入與線程安全

一個函數被重入,表示這個函數沒有執行完成,因爲外部因素或內部調用,又一次進入該函數執行。
一個函數要被重入,只有兩種狀況:

  • 多個線程同時執行這個函數
  • 函數自身(可能通過多層調用以後)調用自身

一個函數被稱爲可重入,表示重入以後不會產生任何不良影響

可重入函數:

1 int sqr(int x)
2 {
3 return x*x;
4 }

一個函數要成爲可重入,必須具備以下特色:

  • 不使用任何(局部)靜態或全局的非const變量
  • 不返回任何(局部)靜態或所有的非const變量的指針
  • 僅依賴調用方提供的參數
  • 不依賴任何單個資源的鎖
  • 不調用任何不可重入的函數

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

過分優化

有時候合理的合理的使用了鎖也不必定能保證線程的安全。

//Thread1
x=0;
lock();
x++;
unlock();
//Thread2
x=0;
lock();
x++;
unlock();

上面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);
若是這樣,即便加鎖也不能保證線程安全

x=y=0;
//Thread1
x=1;
r1=y;
//Thread2
y=1;
r2=x;

上面代碼有可能發生r1=r2=0的狀況。
CPU動態調度:在執行程序的時候,爲了提升效率有可能交換指令的順序。
編譯器在進行優化的時候,也可能爲了效率交換兩個絕不相干的相鄰指令的執行順序。
上面代碼執行順序多是這樣:

x=y=0;
[Thread1]
r1=y;
x=1;
[Thread2]
y=1;
r2=x;

使用volatile關鍵字能夠阻止過分優化,colatile能夠作兩件事情:

  • 阻止編譯器爲了提升速度將一個變量緩存到寄存器內而不寫回
  • 阻止編譯器調整操做volatile變量的指令順序

但volatile沒法阻止CPU動態調度換序
C++中,單例模式。

volatile T* pInst=0;
T* GetInstance()
{
if(pInst==NULL)
{
LOCK();
if(pInst==NULL)
pInst=new T;
unlock();
}
return pInst;
}

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

  • 分配內存
  • 調用構造函數

因此pInst=new T包含三個步驟:

  • 分配內存
  • 在內存的位置上調用構造函數
  • 將內存的地址賦值給pInst

這三步中2和3的步驟能夠顛倒,可能出現這種狀況:pInst中的值不是NULL,但對象仍是沒有構造完成。
要阻止CPU換序,能夠調用一條指令,這條指令經常被稱爲barrier:它會阻止CPU將該指令以前的指令交換到barrier以後。
許多體系的CPU都提供了barrier指令,不過,它們的名稱各不相同。例如POWERPC提供的指令就叫作lwsync。因此咱們能夠這樣保證線程安全:

#define barrier() __asm__ volatile ("lwsync")
volatile T* pInst=0;
T* GetInstance()
{
if(pInst==NULL)
{
LOCK();
if(pInst==NULL)
{
T* temp=new T;
barrier();
pInst=temp;
}
unlock();
}
return pInst;
}

1.6.3多線程的內部狀況

線程的併發執行是由多處理器或操做系統調度來實現的。windows和linux都在內核中提供線程支持,有多處理器或調度來實現併發。用戶實際使用線程並非內核線程,而是存在於用戶態的用戶線程。用戶線程並不必定在操做系統內核裏對應同等數量的內核線程。對用戶來講,若是有三個線程同時執行,可能在內核中只有一個線程。

一對一模型

對於直接支持線程的系統,一對一模型始終是最爲簡單的模型。一個用戶使用的線程就惟一對應一個內核使用的線程,但返回來,一個內核裏面的線程在用戶態不必定有對應的線程存在。

對於一對一模型,線程之間的併發是真正的併發,一個線程由於某個緣由阻塞,並不會影響到其餘線程。一對一模型也可讓多線程程序在多處理器的系統上有更好的表現。
通常直接使用API或者系統調用建立的線程均爲一對一線程。
一對一線程的兩個缺點:

  • 因爲許多操做系統限制了內核線程數量,所以一對一線程會讓用戶的線程數量受到限制。
  • 許多操做系統內核線程調度是,上下文切換的開銷較大,致使用戶線程的執行效率降低。
多對一模型

多對一模型將多個用戶線程映射到一個內核線程上,線程之間的切換由用戶態的代碼來進行,相對於一對一模型,多對一模型的線程切換要快速許多。

多對一模型的問題就是若是一個用戶線程阻塞了,那麼全部的線程都將沒法執行。在多處理系統上,處理器的增多對多對一模型的線程性能不會有明顯幫助。多對一模型獲得的好處是高效的上下文切換和幾乎無限制的線程數量。

多對多模型

多對多模型結合了多對一和一對一的特色,將多個用戶線程映射到少數但不止一個內核線程上。

一個用戶線程阻塞並不會使得全部的用戶線程阻塞。而且對用戶線程數量也沒有什麼限制,在多處理器系統上,多對多模型的線程也能獲得必定的性能提高,不過提高的幅度沒有一對一模型高。

相關文章
相關標籤/搜索