學習計時:共5小時程序員 讀書:編程 代碼:數組 做業:安全 博客:服務器 |
1、學習目標網絡 |
1. 掌握三種併發的方式:進程、線程、I/O多路複用
2. 掌握線程控制及相關係統調用
3. 掌握線程同步互斥及相關係統調用
|
邏輯控制流在時間上重疊,那麼它們就是併發的。
併發(concurrency ) ,出如今計算機系統的許多不一樣層面上。多線程應用級併發是頗有用的:
- 訪問慢速I/O設備。
- 與人交互。
- 經過推遲工做以下降延遲。
- 服務多個網絡客戶端。
- 在多核機器上進行並行計算。併發
使用應用級併發的應用程序稱爲併發程序 (concurrent program)。現代操做系統提供了三種基本的構造併發程序的方法:ide
進程。每一個邏輯控制流都是一個進程,由內核來調度和維護。由於進程 有獨立的虛擬地址空間,想要和其餘流通訊,控制流必須使用某種顯式的進程間通訊 (interprocess communication, IPC) 機制。函數
I/O 多路複用。在這種形式的併發編程中,應用程序在一個進程的上下文中顯式地調度它們本身的邏輯流。邏輯流被模型化爲狀態機,數據到達文件描述符後,主程序顯式地從一個狀態轉換到另外一個狀態。由於程序是一個單獨的進程,因此全部的流都共享同一個地址空間。
線程。線程是運行在一個單一進程上下文中的邏輯流,由內核進行調度。是其餘兩種方式的混合體,像進程流同樣由內核進行調度,而像I/O 多路複用流同樣共享同一個虛擬地址空間。
構造併發程序最簡單的方法就是用進程。
一個構造併發服務器的天然方法就是,在父進程中接受客戶端鏈接請求,而後建立一個新的子進程來爲每一個新客戶端提供服務。服務器正在監昕一個監聽描述符(好比描述符 3)上的鏈接請求。如今假設服務器接受了客戶端 1 的鏈接請求, 並返回一個已鏈接描述符(好比描述符4)。
在接受鏈接請求以後,服務器派生一個子進程,這個子進程得到服務器描述符表的完整拷貝。子進程關閉它的拷貝端的鏈接請求中的監聽描述符 3,而父進程關閉它的己鏈接描述符 4 的拷貝,由於再也不須要這些描述符了。
其中子進程正忙於爲客戶端提供服務。由於父、子進程中的已鏈接描述符都指向同一個文件表表項,因此父進程關閉它的已鏈接描述符的拷貝是相當重要的。不然,將永遠不會釋放已鏈接描述符 4 的文件表條目,並且由此 引發的存儲器泄漏將最終消耗盡可用的存儲器,使系統崩潰。
第一步:服務器接受客戶端的鏈接請求:
父進程爲客戶端 1 建立了子進程以後,它接受一個新的客戶端 2 的鏈接請求, 並返回一個新的已鏈接描述符(好比描述符5),而後,父進程又派生另外一個子進程,這個子進程用已鏈接描述符 5 爲它的客戶端提供服務。
此時,父進程正在等待下一個鏈接請求,而兩個子進程正在併發地爲它們各自的客戶端提供服務。
第二步:服務器派生一個子進程爲這個客戶端服務:
第三步:服務器接受另外一個鏈接請求:
- 一般服務器會運行很長的時間,因此咱們必需要包括一個 SIGCHLD 處理程序,來回收僵死 (zombie) 子進程的資源。由於當 SIGCHLD 處理程序執行時, SIGCHLD 信號是阻塞的,而 Unix 信號是不排隊的,因此 SIGCHLD 處理程序必須準備好回收多個僵死子進程的資源。
- 父子進程必須關閉它們各自的 connfd 拷貝。這對父進程而言尤其重要,它必須關閉它的已鏈接描述 符,以免存儲器泄漏。
- 由於套接字的文件表表項中的引用計數,直到父子進程的 connfd 都關閉了,到客戶端的鏈接纔會終止。
第四步:服務器派生另外一個子進程爲新的客戶端服務
基於進程的併發 echo 服務器.父進程派生一個子進程來處理每一個新的鏈接請求:
在父、子進程間共享狀態信息,進程有一個很是清晰的模型 : 共享文件表,可是不共享用戶地址空間。進程有獨立的地址空間既是優勢也是缺點。一個進程不可能不當心覆蓋另外一個進程的虛擬存儲器,這就消除了許多使人迷惑的錯誤一一這是一個明顯的優勢。
另外一方面,獨立的地址空間使得進程共享狀態信息變得更加困難。爲了共享信息,它們必須使用顯式的IPC(進程間通訊)機制。基於進程的設計的另外一個缺點是,它們每每比較慢,由於進程控制和 IPC 的開銷很高。
I/O 多路複用(I/O multiplexing) 技術。基本的思路就是使用 select 函數,要求內核掛起進程,只有在一個或多個I/O事件發生後,纔將控制返回給應用程序
select函數:
select 函數處理類型爲 fd_set 的集合,也叫作描述符集合。邏輯上,咱們將描述符集合當作一個大小爲 n 的位向量。
每一個位 bk對應於描述符 k。當且僅當 bk= 1, 描述符 k才代表是描述符集合的一個元素。只容許你對描述符集合作三件事: 1) 分配它們, 2) 將一個此種類型的變量賦值給另外一個變量, 3) 用 FD_ZERO、 FD_SET、 FD_CLR 和 FD_ISSET 宏指令來修改和檢查它們。select 函數有兩個輸人 : 一個稱爲讀集合的描述符集合(fdset)和該 讀集合的基數 (n) (其實是任何描述符集合的最大基數). select 函數會一直阻塞,直到讀集合中至少有一個描述符準備好能夠讀。當且僅當一個從該描述符讀取一個字節的請求不會阻塞時,描述符 k就表示準備好能夠讀了。做爲一個反作用, select 修改了參數 fdset 指向的 fd_set,指明讀集合中一個稱爲準備好集合 (ready set) 的子集,這個集合是由讀集合中準備好能夠讀了的描述符組成的。函數返回的值指明瞭準備好集合的基數。注意,因爲這個反作用, 咱們必須在每次調用 select 時都更新讀集合。
使用 I/O 多路複用的 echo 服務器。服務器使用 select 等待監聽描述符上的鏈接請求和標準輸入上的命令:
不是調用 accept 函數來等待一個鏈接請求,而是調用 select 函數,這個函數會一直阻塞,直到監聽描述符或者標準輸入準備好能夠讀。
一旦 select 返回,咱們就用 FD_ISSET 宏指令來判斷哪一個描述符準備好能夠讀了。
一旦它鏈接到某個客戶端,就會連續回送輸入行,直到客戶端關閉這個鏈接中它的那一端。所以,若是你鍵入一個命令到標準輸入,你將不會獲得響應,直到服務器和客戶端之間結束。一個 更好的方法是更細粒度的多路複用,服務器每次循環〈至多)回送一個文本行。
I/O 多路複用能夠用作併發事件驅動 (event-driven) 程序的基礎,在事件驅動程序中,流是由於某種事件而前進的
將邏輯流模型化爲狀態機。不嚴格地說,一個狀態機 (state machine) 就是一組狀態 (state)、輸入事件(input event) 和轉移他(transition),其中轉移就是將狀態和輸入事件映射到狀態。每一個轉移都將一個(輸入狀態,輸入事件)對映射到一個輸出狀 態。自循環(self-loop) 是同一輸入和輸出狀態之間的轉移。節 點表示狀態,有向弧表示轉移,而弧上的標號表示輸入事件。一個狀態機從某種初始狀態開始執行。每一個輸入事件都會引起一個從當前狀態到下一狀態的轉移。
併發事件驅動 echo 服務器中邏輯流的狀態機:
服務器使用I/O多路複用,藉助 select 函數檢測輸入事件的發生。
服務器調用 select 函數來 檢測兩種不一樣類型的輸人事件: a) 來自一個新客戶端的鏈接請求到達, b) 一個己存在的客戶 端的己鏈接描述符準備好能夠讀了。
基於I/O 多路複用的併發 echo 服務器。每次服務器迭代都回送來自每一個準備好的描述符的文本行:
init_pool 函數初始化客戶端池。 clientfd 數組表示已鏈接描述符的集合, 其中整數 -1 表示一個可用的槽位。初始時,已鏈接描述符集合是空的,並且監聽描述符是 select 讀集合中惟一的描述符。
init_pool: 初始化活動客戶端池:
add_clieht函數添加一個新的客戶端到活動客戶端池中。在 clientfd 數組中找到一個空槽位後,服務器將這個已鏈接描述符添加到數組中,並初始化相應的RIO讀緩衝區,這樣一來咱們就可以對這個描述符調用rio_readlineb。將這個已鏈接描述符添加到 select 讀集合,並更新該池的一些全局屬性。 maxfd 變量記錄了 select 的最大文件描述符。 maxi 變量記錄的 是到 clientfd數組的最大索引,這樣 check_clients 函數就無需搜索整個數組了。
check_clients 函數回送來自每一個準備好的已鏈接描述符的一個文本行。 若是成功地從描述符讀取了一個文本行,那麼咱們就將該文本行回送到客戶端
add_client: 向池中添加一個新的客戶端鏈接:
check_clients: 爲準備好的客戶端鏈接服務:
select 函數檢測到輸入事件,而 add_client 函數建立 一個新的邏輯流(狀態機). check_clients 函數經過回送輸入行來執行狀態轉移,並且當客 戶端完成文本行發送時,它還要刪除這個狀態機。
事件驅動設計的一個優勢是,它比基於進程的設計給了程序員更多的對程序行爲的控制。
另外一個優勢是,一個基於 I/O 多路複用的事件驅動服務器是運行在單一進程上下文中的,因 此每一個邏輯流都能訪問該進程的所有地址空間。
事件驅動設計的一個明顯的缺點就是編碼複雜。咱們的事件驅動的併發 echo 服務器須要的代碼比基於進程的服務器多三倍。不幸的是,隨着併發粒度的減少,複雜性還會上升。這裏的粒度是指每一個邏輯流每一個時間片執行的指令數量。
線程(thread) 就是運行在進程上下文中的邏輯流。
每一個線程都有它本身的線程上下文 (thread context),包括一個惟一的整數線程 (Thread ID, TID)、棧、棧指針、程序計數器、通用目的寄存器和條件碼。全部的運行在一個進程裏的線程共享該進程的整個虛擬地址空間。
基於線程的邏輯流結合了基於進程和基於 I/O 多路複用的流的特性。同進程同樣,線程由內核自動調度,而且內核經過一個整數 ID 來識別線程。同基於 I/O 多路複用的流同樣,多個線程 運行在單一進程的上下文中,所以共享這個進程虛擬地址空間的整個內容,包括它的代碼、數據、堆、共享庫和打開的文件。
每一個進程開始生命週期時都是單一線程,這個線程稱爲主線程 (main thread)。在某一時刻,主線程建立一個對等線程 (peer thread),從這個時間點開始,兩個線程就併發地運行。最後,因 爲主線程執行一個慢速系統調用。或者由於它被系統的間隔計時器中斷, 控制就會經過上下文切換傳遞到對等線程。對等線程會執行一段時間,而後控制傳遞迴主線程,依次類推。
由於一個線程的上下文要比一個進程的上下文小得多,線程的上下文切換要比 進程的上下文切換快得多。另外一個不一樣就是線程不像進程那樣,不是按照嚴格的父子層次來組織的。和一個進程相關的線程組成一個對等(線程)池 (pool),獨立於其餘線程建立的線程。主線程和其餘線程的區別僅在於它老是進程中第一個運行的線程。對等 (線程)池概念的主要影響是,一個線程可 以殺死它的任何對等線程,或者等待它的任意對等線程終止。另外,每一個對等線程都能讀寫相同的共享數據。
併發線程執行:
Posix 線程 (Pthreads) 是在 C 程序中處理線程的一個標準接口。Pthreads 定義了大約 60 個函數,容許程序建立、殺死和回收線程,與對等線程安全地共享數據,還能夠通知對等線程系統狀態的變化。
線程的代碼和本地數據被封裝在一個線程例程(thread routine) 中。若是想傳遞多個參數給錢程例程,那麼你應該將參數放 到一個結構中,並傳遞一個指向該結構的指針。想要線程例程返回多個參數,你能夠返回一個指向一個結構的指針。
線程經過調用 pthread create 函數來建立其餘線程。
pthread_create 函數建立一個新的線程,並帶着一個輸入變量arg,在新線程的上下文中運行線程例程f。能用attr參數來改變新建立線程的默認屬性。
當 pthread_create 返回時,參數 tid包含新建立線程的ID。新線程能夠經過調用 pthread_self 函數來得到它本身的線程 ID.
一個線程是如下列方式之一來終止的 :
- 當頂層的線程例程返回時,線程會隱式地終止。
- 經過調用 pthread_exit 函數,線程會顯式地終止。若是主線程調用 pthread_exit , 它會等待全部其餘對等線程終止,而後再終止主線程和整個進程,返回值爲 thread_return。
線程經過調用 pthread_join 函數等待其餘線程終止。
pthread_join 函數會阻塞,直到線程 tid 終止,將線程例程返回的 (void*) 指針賦值 爲 thread_return 指向的位置,而後回收己終止線程佔用的全部存儲器資源。
pthread join 函數只能等待一個指定的線程終止。
在任何一個時間點上,線程是可結合的 (joinable) 或者是分離的 (detached)。一個可結合的線程可以被其餘線程收回其資源和殺死。在被其餘線程回收以前,它的存儲器資源(例如棧)是沒有被釋放的。相反,一個分離的線程是不能被其餘線程回收或殺死的。它的存儲器資源在它終止時由系統自動釋放。
默認狀況下,線程被建立成可結合的。爲了不存儲器泄漏,每一個可結合線程都應該要麼被其餘線程顯式地收回,要麼經過調用 pthread_detach 函數被分離。
pthread_detach 函數分離可結合線程 tid. 線程可以經過以 pthread_self ()爲參數 的 pthread_detach 調用來分離它們本身。
pthread_once 函數容許你初始化與線程例程相關的狀態。
once_control 變量是一個全局或者靜態變量,老是被初始化爲 PTHREAD_ONCE_INIT。 當你第一次用參數 once_control 調用 pthread_once 時, 它調用 init_routine,這是一個沒有輸入參數,也不返回什麼的函數。
動態初始化多個線程共享的全局變量時, pthread_once 函數是頗有用的。
調用 pthread_ create 時,如何將已鏈接描述符傳遞給對等線程。最明顯的方法就是傳遞一個指向這個描述符的指針。
對等線程間接引用這個指針,並將它賦值給一個局部變量。
這樣可能會出錯,由於它在對等線程的賦值語句和主線程的 accept 語句間引入了競爭 (race)。若是賦值語句在下一個 accept 以前完成,那麼對等線程中的局部變量 connfd 就獲得正確的描述符值。然而,若是賦值語句是在 accept 以後才完成的,那麼對等線程中的 局部變量 connfd 就獲得下一次鏈接的描述符值。那麼不幸的結果就是,如今兩個線程在同一 個描述符上執行輸入和輸出。爲了不這種潛在的致命競爭,咱們必須將每一個 accept 返回的 已鏈接描述符分配到它本身的動態分配的存儲器塊
是在線程例程中避免存儲器泄漏。既不顯式地收回線程,就必須分離 每一個線程,使得在它終止時它的存儲器資源可以被收回。更進一步,咱們必須當心釋放主線程分配的存儲器塊
一組併發線程運行在一個進程的上下文中。每一個線程都有它本身獨立的線程上下文,包括線程 ID、棧、棧指針、程序計數器、條件碼和通用目的寄存器值。每一個線程和其餘線程一塊兒共享進程上下文的剩餘部分。這包括整個用戶虛擬地址空間,它是由只讀文本(代碼)、讀/寫數據、堆以及全部的共享庫代碼和數據區域組成的。線程也共享一樣的打開文件的集合。
讓一個線程去讀或寫另外一個線程的寄存器值是不可能的。另外一方 面,任何線程均可以訪問共享虛擬存儲器的任意位置。
保存在虛擬地址空間的棧區域中,而且一般是被相應的線程獨立地訪問的。
線程化的 C 程序中變量根據它們的存儲類型被映射到虛擬存儲器:
- 全局變量。
- 本地自動變量。
- 本地靜態變量。
一個變量 v 是共亭的,當且僅當它的一個實例被一個以上的線程引用。
同步錯誤。
將線程 i 的循環代碼分解成五個部分:
- Hi:在循環頭部的指令塊
- Li:加載共享變量 cnt 到寄存器%eaxi 的指令,這裏%eaxi 表示線程i 中的寄存器%eax的值。
- Ui:更新(增長) %eaxi 的指令。
- Si:將%eaxi 的更新值存回到共享變量 cnt 的指令。
- Ti:循環尾部的指令塊。
注意頭和尾只操做本地棧變量,而 Li、 Ui 和 Si操做共享計數器變量的內容。
通常而言, 你沒有辦法預測操做系統是否將爲你的線程選擇一個正確的順 序。
進度圖 (progress graph) 的方法來闡明這些正確 的和不正確的指令順序的概念
進度圖 (progress graph) 將 n 個併發線程的執行模型化爲一條 n 維笛卡兒空間中的軌跡線。每條軸 k對應於線程 k 的進度。每一個表明線程 k 已經完成了指令 Ik 這一狀態。圖的原點對應於沒有任何線 程完成一條指令的初始狀態。
進度圖將指令執行模型化爲從一種狀態到另外一種狀態的轉換(transition)。轉換被表示爲一條從一點到相鄰點的有向邊。合法的轉換是向右(線程 1 中的一條指令完成〉或者向上(線程 2 中的一條指令完成)的。兩條指令不能在同一時刻完成一一對角線轉換是不容許的。程序決不會反向運行,因此向下或者向左移動的轉換也是不合法的。
一個程序的執行歷史被模型化爲狀態空間中的一條軌跡線。
繞開不安全區的軌跡線叫作安全軌跡線 (safe trajectory)。 相反,接觸到任何不安全區的軌跡線就叫作不安全軌跡線 (unsafe trajectory)。
一種解決同步不一樣執行線程問題的方法,這種方法是基於一種叫作信號量 (semaphore) 的特殊類型變量的。信號量 s 是具備非負整數值的全 局變量,只能由兩種特殊的操做來處理,這兩種操做稱爲 P 和 V:
- P(s) :若是 s 是非零的,那麼 P 將 s 減1,而且當即返回。若是 s 爲零,那麼就掛起這個線程, 直到 s變爲非零,而一個 V操 做會重啓這個線程。在重啓以後, P 操做將 s 減1,並將控制 返回給調用者。
- V(s): V操做將s 加 1。若是有 任何線程阻塞在P 操做等待 s 變成非零,那麼 V操做會重啓 這些線程中的一個,而後該線程將s 減1,完成它的 P 操做。
信號量的函數:
sem_init 函數將信號量 sem 初始化爲 value. 每一個信號量在使用前必須初始化。針對我 們的目的,中間的參數老是0。程序分別經過調用 sem_wait 和 sem_post 函數來執行 P 和 V 操做。
P 和 V的包裝函數:
基本思想是將每一個共享變量 (或者一組相關的共享變量)與一個信號量 s (初始爲1)聯繫起來,而後用 P(s) 和V(s) 操做將 相應的臨界區包圍起來。
保護共享變量的信號量叫作二元信號量 (binary semaphore),由於它的值老是 0 或者 1 。以提供互斥爲目的的二元信號量經常也稱爲互斥鎖 (mutex)。在一個互斥鎖上執行 P 操做稱爲對互斥鎖加鎖。相似地,執行 V操做稱爲對互斥鎖解鎖。對一個互斥鎖加了鎖可是尚未解鎖的線程稱爲佔用這個互斥鎖。一個被用做一組可用資源的計數器的信號量稱爲計數信號量。
關鍵思想是這種 P 和 V操做的結合建立了一組狀態, 叫作禁止區 (forbidden region),其中 s 使用信號量來互斥。 s
由 P 和 V操做建立的禁止區使得在任什麼時候間點上,在被包圍的臨 界區中,不可能有多個線程在執行指令。換句話說,信號量操做確保了對臨界區的互斥訪問。
除了提供互斥以外,信號量的另外一個重要做用是調度對共享資源的訪問。
生產者一消費者問題。生產者產生項目並把它們插入到一個有限的緩衝區中。消費者從緩衝區中取出這些項目,而後消費它們
由於插入和取出項目都涉及更新共享變量,因此咱們必須保證對緩衝區的訪問是互斥的。可是隻保證互斥訪問是不夠的,咱們還須要調度對緩衝區的訪問。若是緩衝區是滿的(沒有空的槽位),那麼生產者必須等待直到有一個槽位變爲可用。與之類似,若是緩衝區是空的(沒有可取用的項目),那麼消費者必須等待直到有一個項目變爲可用。
基於預線程化 (prethreading) 的有趣的併發服務器。 SBUP 操做類型爲 sbuf_t 的有限緩衝區,項目存放在一個動態分配的 n 項整數數組 (buf) 中。 front 和 rear 索引值記錄該數組中的第一項和最後一項。三個信號量同步對緩 衝區的訪問。 mutex 信號量提供互斥的緩衝區訪問。 slots 和 items 信號量分別記錄空槽位和可用項目的數量。
sbuf_t: SBUF 包使用的有限緩衝區:
sbuf_init 函數爲緩衝區分配堆存儲器,設置 front 和 rear 表示一個空的緩衝區,併爲三個信號量賦初始值。這個函數在調用其餘三個函數中的任何一個以前調用一次。 sbuf_deinit函數是當應用程序使用完緩衝區時,釋放緩衝區存儲的。 sbuf_insert 函數等待一個可用的槽位,對互斥鎖加鎖,添加項目,對互斥鎖解鎖,而後宣佈有一個新項目可用。 sbuf_remove 函數是與 sbuf_insert 函數對稱的。在等待一個可用的緩衝區項目以後,對互斥鎖加鎖,從緩衝區的前面取出該項目,對互斥鎖解鎖,而後發信號通知一個新的槽位可供使用。
SBUF: 同步對有限緩衝區併發訪問的包:
預線程化的併發服務器的組織結構。一組現有的線程不斷地取出和處理來自有限緩衝區的已鏈接描述符:
順序、併發和並行程序集合之間的關係:
並行程序經常被寫爲每一個核上只運行一個線程。
並行程序的加速比 (speedup) 一般定義爲:
p 是處理器核的數量,凡是在 k個核上的運行時間。這個公式有時稱爲強擴展 (strong scaling)。當 T1 是程序順序執行版本的執行時間時, Sp 稱爲絕對加速比.(absolute speedup)。當 T1 是程序並行版本在一個核上的執行時間時, Sp 稱爲相對加速比 (relative speedup)。絕對加速 比比相對加速比能更真實地衡量並行的好處。
效率 (efficiency ) ,定義爲
一般表示爲範圍在 (0, 100] 之間的百分比。效率是對因爲並行化形成的開銷的衡量。具備高 效率的程序比效率低的程序在有用的工做上花費更多的時間,在同步和通訊上花費更少的時間。
加速比還有另一面,稱爲弱擴展 (weak scaling),在增長處理器數量的同時,增長問題的規模,這樣隨着處理器數量的增長,每一個處理器執行的工做量保持不變。加速比和效率被表達爲單位時間完成的工做總量。
當用線程編寫程序時,咱們必須當心地編寫那些具備稱爲線程安全性(thread safety) 屬性的畫數。一個函數被稱爲線程安全的(thread-safe),當且僅當被多個併發線程反覆地調用時,它會一直產生正確的結果。若是一個函數不是線程安全的,咱們就說它是線程不安全的(thread-unsafe)。
四個(不相交的)線程不安全函數類:
- 第 1 類: 不保護共享變量的函數。thread 函數中對一個未受保護的全局計數器變量加 1. 將這類線程不安全函數變成線程安全的, 相對而言比較容易:利用像P和 V操做這樣的同步操做來保護共享的變量。這個方法的優勢是在調用程序中不須要作任何修改。缺點是同步操做將減慢程序的執行時間。
- 第 2 類:保持跨越多個調用的狀態的函數。一個僞隨機數生成器是這類線程不安全函數的簡單例子。僞隨機數生成器程序包. rand 函數是線程不安全的,由於當前調用的結果依賴於前次調用的中間結果。當調用 srand 爲 rand 設置了一個種子後,咱們從一個單線程中反覆地調用 rand,可以預期獲得一個可重複的隨機數字序列。然而,若是多線程調用 rand 函數,這種假設就再也不成立了。
使得像 rand這樣的函數線程安全的惟一方式是重寫它,使得它再也不使用任何 static 數據,而是依靠調用者在參數中傳遞狀態信息。這樣作的缺點是,程序員如今還要被迫修改調用程序中的代碼。在一個大的程序中,可能有成百上千個不一樣的調用位置,作這樣的修改將是很是麻煩的,並且容易出錯。
- 第 3 類:返回指向靜態變量的指針的函數。某些函數,例如 ctime 和 gethostbyname,將計算結果放在一個 static 變量中,而後返回一個指向這個變量的指針。若是咱們從併發線程中調用 這些函數,那麼將可能發生災難,由於正在被一個線程使用的結果會被另外一個線程悄悄地覆蓋了。有兩種方法來處理這類線程不安全函數。一種選擇是重寫函數,使得調用者傳遞存放結果的變量的地址。這就消除了全部共享數據,可是它要求程序員可以修改函數的源代碼。
- 第 4 類:調用線程不安全函數的函數。若是函數f調用線程不安全函數 g,那麼f就是線程不安全的嗎?不必定。若是 g是第 2 類函數,即依賴於跨越屢次調用的狀態,那麼f也是線程不安全的,並且除了重寫 g 之外,沒有什麼辦法。然而,若是 g 是第 1 類或者第 3 類函數,那麼只 要你用一個互斥鎖保護調用位置和任何獲得的共享數據,f仍然多是線程安全的。
可重入函數 (reentrant function),其特色在於它們具備這 樣一種屬性:當它們被多個線程調用時,不會引用任何共享數據。
可重入函數一般要比不可重人的線程安全的函數高效一些,由於它們不須要同步操做。更進一步來講,將第 2 類線程不安全函數轉化爲線 程安全函數的惟一方法就是重寫它,使之變爲可重入的。
可重入函數、線程安全函數和線程不安全函數之間的集合關係:
檢查某個函數的代碼並先驗地判定它是可重入的。
若是全部的函數參數都是傳值傳遞的(即沒有指針),而且全部的數據引用都是本地的自動棧變量(即沒有引用靜態或全局變量),那麼函數就是顯式可重入的 (explicitly reentrant),也就是說,不管它是被如何調用的,咱們均可以斷言它是可重入的。
咱們老是使用術語可重入的 (reenntrant) 既包括顯式可重入函數也包括隱式可重入函數。然而,認識到可重入性有時既是調用者也是被調用者的屬性,並不僅是被調用者單獨的屬性是很是重要的。
大多數 Unix 函數,包括定義在標準 C 庫中的函數(例如 malloc、 free、 realloc、 printf 和 scanf) 都是線程安全的,只有一小部分是例外。
常見的線程不安全的庫函數:
除了 rand 和 strtok 之外,全部這些線程不安全函數都是第 3 類的,它們返回一個指向靜態變量的指針。若是咱們須要在一個線程化的程序中調用這些函數中的某一個,對調用者來講最不惹麻煩的方法是加鎖-拷貝。然而,加鎖-拷貝方法有許多缺點。首先,額外的同步下降了 程序的速度。其次,像 gethostbyname 這樣的函數返回指向複雜結構的結構的指針,要拷貝整個結構層次,須要深層拷貝 (deep copy) 結構。再次,加鎖-拷貝方法對像 rand 這樣依賴 跨越調用的靜態狀態的第 2 類函數並不有效。 所以,Unix系統提供大多數線程不安全函數的可重人版本。可重入版本的名字老是以"_r" 後綴結尾。
當一個程序的正確性依賴於一個線程要在另外一個線程到達y 點以前到達它的控制流中的 x 點時,就會發生競爭。
發生競爭是由於程序員假定線程將按照某種特殊的軌跡線穿過執行狀態空間,而忘記了另外一條準則規定:線程化的程序必須對任何可行的軌跡線都正確工做。
信號量引人了一種潛在的使人厭惡的運行時錯誤,叫作死鎖(deadlock) ,它指的是一組線程被阻塞了,等待一個永遠也不會爲真的條件。
- 程序員使用 P 和 V操做順序不當,以致於兩個信號量的禁止區域重疊。若是某個執行軌跡 線碰巧到達了死鎖狀態 d,那麼就不可能有進一步的進展了,由於重疊的禁止區域阻塞了 每一個合法方向上的進展。換句話說,程序死鎖是由於每一個線程都在等待其餘線程執行一個根本不可能發生的V操做。
- 重疊的禁止區域引發了一組稱爲死所區域(deadlock region )的狀態。若是一個軌跡線碰巧到達了一個死鎖區域中的狀態,那麼死鎖就是不可避免的了。軌跡線能夠進人死鎖區域, 可是它們不可能離開。
- 死鎖是一個至關困難的問題,由於它不老是可預測的。一些幸運的執行軌跡線將繞開死鎖區域,而其餘的將會陷入這個區域。
程序死鎖有不少緣由,要避免死鎖通常而言是很困難的。然而,當使用二元信號量來實現互斥時,能夠應用下面的簡單而有效的規則來避免死鎖:
互斥鎖加鎖順序規則:若是對於程序中每對互斥鎖 (s, t), 每一個同時佔用 s 和 t 的線程都按照相同的順序對它們加鎖,那麼這個程序就是無死鎖的。
有死鎖程序的進度圖:
無死鎖程序的進度圖:
一個併發程序是由在時間上重疊的一組邏輯流組成的。 三種不一樣的構建併發程序的機制:進程、I/O 多路複用和線程。 進程是由內核自動調度的,並且由於它們有各自獨立的虛擬地址空間,因此要實現共享數 據,必需要有顯式的 IPC 機制。事件驅動程序建立它們本身的併發邏輯流,這些邏輯流被模型化爲狀態機,用I/O 多路複用來顯式地調度這些流。由於程序運行在一個單一進程中,因此在流之間共享數據速度很快並且很容易。線程是這些方法的綜合。同基於進程的流同樣,線程也是由內核自動調度的。同基於 I/O 多路複用的流同樣,線程是運行在一個單一進程的上下文中的,因 此能夠快速而方便地共享數據。 不管哪一種併發機制,同步對共享數據的併發訪問都是一個困難的問題。提出對信號量的 P 和 V操做就是爲了幫助解決這個問題。信號量操做能夠用來提供對共享數據的互斥訪問,也對諸如生產者一消費者程序中有限緩衝區和讀者一寫者系統中的共享對象這樣的資源訪問進行調度。 併發也引人了其餘一些困難的問題。被線程調用的函數必須具備一種稱爲線程安全的屬性。 可重入函數是線程安全函數的一個真子集,它不訪問任何共享數據。可重入函數一般比不可重人函數更爲有效,由於它們不須要任何同步原語。競爭和死鎖是併發程序中出現的另外一些困難的問題。當程序員錯誤地假設邏輯流該如何調度時,就會發生競爭。當一個流等待一個永遠不會發生的事件時,就會產生死鎖。 四類線程不安全的函數。