我們都知道,進程是運轉中的程序,是為了在CPU上實現多道編程而發明的一個概念。但是進程在一個時間只能干一件事情,如果想要同時干兩件或者多件事情,例如同時看兩場電影,我們自然會想到傳說中的分身術,就像孫悟空那樣可以變出多個真身。雖然我們在現實中無法分身,但進程卻可以辦到,辦法就是線程。線程就是我們為了讓一個進程能夠同時干多件事情而發明的“分身術”。
一、線程基礎
1.1 線程概念
線程是進程的“分身”,是進程里的一個執行上下文或執行序列。of course,一個進程可以同時擁有多個執行序列。這就像舞台,舞台上可以有多個演員同時出場,而這些演員和舞台就構成了一出戲。類比進程和線程,每個演員是一個線程,舞台是地址空間,這樣同一個地址空間中的所有線程就構成了進程。
在線程模式下,一個進程至少有一個線程,也可以有多個線程,如下圖所示:
將進程分解為線程可以有效地利用多處理器和多核計算機。例如,當我們使用Microsoft Word時,實際上是打開了多個線程。這些線程一個負責顯示,一個負責接收輸入,一個定時進行存盤......這些線程一起運轉,讓我們感覺到輸入和顯示同時發生,而不用鍵入一些字符等待一會兒才顯示到屏幕上。在不經意間,Word還能定期自動保存。
1.2 線程管理
線程管理與進程管理類似,需要一定的基礎:維持線程的各種信息,這些信息包含了線程的各種關鍵資料。於是,就有了線程控制塊。
由於線程間共享一個進程空間,因此,許多資源是共享的(這部分資源不需要存放在線程控制塊中)。但又因為線程是不同的執行序列,總會有些不能共享的資源。一般情況下,統一進程內的線程間共享和獨享資源的划分如下表所示:
1.3 線程模型
現代操作系統結合了用戶態和內核態的線程模型,其中用戶態的執行系統負責進程內部在非阻塞時的切換,而內核態的操作系統則負責阻塞線程的切換,即同時實現內核態和用戶態線程管理。其中,內核態線程數量極少,而用戶態線程數量較多。每個內核態線程可以服務一個或多個用戶態線程。換句話說,用戶態線程會被多路復用到內核態線程上。
1.4 多線程的關系
推出線程模型的目的就是實現進程級並發,因為在一個進程中通常會出現多個線程。多個線程共享一個舞台,時而交互,時而獨舞。但是,共享一個舞台會帶來不必要的麻煩,這些麻煩歸結到下面兩個根本問題:
(1)線程之間如何通信?
(2)線程之間如何同步?
上述兩個問題在進程層面同樣存在,在前面的進程原理部分已經進行了介紹,從一個更高的層次上看,不同的進程也共享着一個巨大的空間,這個空間就是整個計算機。
二、線程同步
2.1 同步的原因和目的
(1)原因
線程之間的關系是合作關系,既然是合作,那么久得有某種約定的規則,否則合作就會出問題。例如下圖中,一個進程的兩個線程因為操作不同步而造成線程1運行錯誤:
出現上述問題原因在於兩點:一是線程之間共享的全局變量;二是線程之間的相對執行順序是不確定的。針對第一點,如果所有資源都不共享,那就違背了進程和線程設計的初衷:資源共享、提高資源利用率。針對第二點,需要讓線程之間的相對執行順序在需要的時候可以確定。
(2)目的
線程同步的目的就在於不管線程之間的執行如何穿插,其運行結果都是正確的。換句話說,就是要保證多線程執行下結果的確定性。與此同時,也要保持對線程執行的限制越少越少。
2.2 同步的方式
(1)一些必要概念
① 兩個或多個線程爭相執行同一段代碼或訪問同一資源的現象稱為競爭。
② 可能造成競爭的共享代碼段或資源就被稱為臨界區。
③ 在任何時刻都能有一個線程在臨界區中的現象被稱為互斥。(一次只有一個人使用共享資源,其他人皆排除在外)
(2)鎖
① 關於鎖
當兩個教師都想使用同一個教室來為學生補課,如何協調呢?進到教室后將門鎖上,另外一個教室就無法進來使用教室了。即教室是用鎖來保證互斥的,那么在操作系統中,這種可以保證互斥的同步機制就被稱為鎖。
例如,在.NET中可以直接使用lock語句來實現線程同步:
private object locker = new object(); public void Work() { lock (locker) { // 做一些需要線程同步的工作 } }
鎖有兩個基本操作:閉鎖和開鎖。很容易理解,閉鎖就是將鎖鎖上,其他人進不來;開鎖就是你做的事情做完了,將鎖打開,別的人可以進去了。開鎖只有一個步驟那就是打開鎖,而閉鎖有兩個步驟:一是等待鎖達到打開狀態,二是獲得鎖並鎖上。顯然,閉鎖的兩個操作應該是原子操作,不能分開。
② 睡覺與叫醒
當對方持有鎖時,你就不需要等待鎖變為打開狀態,而是去睡覺,鎖打開后對方再來把你叫醒,這是一種典型的生產者消費者模式。用計算機來模擬生產者消費者並不難:一個進程代表生產者,一個進程代表消費者,一片內存緩沖區代表商店。生產者將生產的物品從一端放入緩沖區,消費者則從另外一端獲取物品,如下圖所示:
例如,在.NET中可以通過Monitor.Wait()與Monitor.Pulse()來進行睡覺和叫醒操作:
首先是消費者線程
public void ConsumerDo() { while (true) { lock(sync) { // Step1:做一些消費的事情 ...... // Step2:喚醒生產者線程 Monitor.Pulse(sync); // Step3:釋放鎖並阻止消費者線程 Monitor.Wait(sync); } } }
其次是生產者線程
public void ProducerDo() { while (true) { lock(sync) { // Step1:做一些生產操作 ...... // Step2:喚醒消費者線程 Monitor.Pulse(Dog.lockCommunicate); // Step3:釋放鎖並阻止生產者線程 Monitor.Wait(Dog.lockCommunicate); } } }
但是,在此種情形下,生產者和消費者都有可能進入睡覺狀態,從而無法相互叫醒對方而繼續往前推進,也就發生了系統死鎖。如何解決?我們可以用某種方法將發出的信號累積起來,而不是丟掉。即消費者獲得CPU執行sleep語句后,生產者在這之前發送的叫醒信號還保留,因此消費者將馬上獲得這個信號而醒過來。而能夠將信號累積起來的操作系統原語就是信號量。
(2)信號量
信號量(Semaphore)是一個計數器,其取值為當前累積的信號數量。它支持兩個操作:加法操作up和減法操作down。執行down減法操作時,請求該信號量的一個線程會被掛起;而執行up加法操作時,會叫醒一個在該信號量上面等待的線程。down和up操作在歷史上被稱為P和V操作,是操作系統中最重要的同步原語的兩個基本操作。
有些房間,可以同時容納n個人,比如廚房。也就是說,如果人數大於n,多出來的人只能在外面等着。這好比某些內存區域,只能供給固定數目的線程使用。這時的解決方法,就是在門口掛n把鑰匙。
進去的人就取一把鑰匙,出來時再把鑰匙掛回原處。后到的人發現鑰匙架空了,就知道必須在門口排隊等着了。這種做法就叫做"信號量",用來保證多個線程不會互相沖突。
例如,在.NET中提供了一個Semaphore類來進行信號量操作,下面的示例代碼演示了4條線程想要同時執行ThreadEntry()方法,但同時只允許2條線程進入:
class Program { // 第一個參數指定當前有多少個“空位”(允許多少條線程進入) // 第二個參數指定一共有多少個“座位”(最多允許多少個線程同時進入) static Semaphore sem = new Semaphore(2, 2); const int threadSize = 4; static void Main(string[] args) { for (int i = 0; i < threadSize; i++) { Thread thread = new Thread(ThreadEntry); thread.Start(i + 1); } Console.ReadKey(); } static void ThreadEntry(object id) { Console.WriteLine("線程{0}申請進入本方法", id); // WaitOne:如果還有“空位”,則占位,如果沒有空位,則等待; sem.WaitOne(); Console.WriteLine("線程{0}成功進入本方法", id); // 模擬線程執行了一些操作 Thread.Sleep(100); Console.WriteLine("線程{0}執行完畢離開了", id); // Release:釋放一個“空位” sem.Release(); } }
如果將資源比作“座位”,Semaphore接收的兩個參數中:第一個參數指定當前有多少個“空位”(允許多少條線程進入),第二個參數則指定一共有多少個“座位”(最多允許多少個線程同時進入)。WaitOne()方法則表示如果還有“空位”,則占位,如果沒有空位,則等待;Release()方法則表示釋放一個“空位”。
不難看出,mutex互斥鎖是semaphore信號量的一種特殊情況(n=1時)。也就是說,完全可以用后者替代前者。
但是,如果生產者或消費者將兩個up/down操作順序顛倒,也同樣會產生死鎖。也就是說,使用信號量原語時,信號量操作的順序至關重要。那么,有木有辦法改變這種情況,可不可將信號量的這些組織工作交給一個專門的構造來負責,解放廣大程序員?答案是管程。
(3)管程
管程(Monitor)即監視器的意思,它監視的就是進程或線程的同步操作。具體來說,管程就是一組子程序、變量和數據結構的組合。言下之意,把需要同步的代碼用一個管程的構造框起來,即將需要保護的代碼置於begin monitor和end monitor之間,即可獲得同步保護,也就是任何時候只能有一個線程活躍在管程里面。
同步操作的保證是由編譯器來執行的,編譯器在看到begin monitor和end monitor時就知道其中的代碼需要同步保護,在翻譯成低級代碼時就會將需要的操作系統原語加上,使得兩個線程不能同時活躍在同一個管程內。
例如,在.NET中提供了一個Monitor類,它可以幫我們實現互斥的效果:
private object locker = new object(); public void Work() { // 避免直接使用私有成員locker(直接使用有可能會導致線程不安全) object temp = locker; Monitor.Enter(temp); try { // 做一些需要線程同步的工作 } finally { Monitor.Exit(temp); } }
在管程中使用兩種同步機制:鎖用來進行互斥,條件變量用來控制執行順序。從某種意義上來說,管程就是鎖+條件變量。
About:條件變量就是線程可以在上面等待的東西,而另外一個線程則可以通過發送信號將在條件變量上的線程叫醒。因此,條件變量有點像信號量,但又不是信號量,因為不能對其進行up和down操作。
管程最大的問題就是對編譯器的依賴,因為我們需要將編譯器需要的同步原語加在管程的開始和結尾。此外,管程只能在單台計算機上發揮作用,如果想在多計算機環境下進行同步,那就需要其他機制了,而這種其他機制就是消息傳遞。
(4)消息傳遞
消息傳遞是通過同步雙方經過互相收發消息來實現,它有兩個基本操作:發送send和接收receive。他們均是操作系統的系統調用,而且既可以是阻塞調用,也可以是非阻塞調用。而同步需要的是阻塞調用,即如果一個線程執行receive操作,就必須等待受到消息后才能返回。也就是說,如果調用receive,則該線程將掛起,在收到消息后,才能轉入就緒。
消息傳遞最大的問題就是消息丟失和身份識別。由於網絡的不可靠性,消息在網絡間傳輸時丟失的可能性較大。而身份識別是指如何確定收到的消息就是從目標源發出的。其中,消息丟失可以通過使用TCP協議減少丟失,但也不是100%可靠。身份識別問題則可以使用諸如數字簽名和加密技術來彌補。
(5)柵欄
柵欄顧名思義就是一個障礙,到達柵欄的線程必須停止下來,知道出去柵欄后才能往前推進。該院與主要用來對一組線程進行協調,因為有時候一組線程協同完成一個問題,所以需要所有線程都到同一個地方匯合之后一起再向前推進。
例如,在並行計算時就會遇到這種需求,如下圖所示:
參考資料
鄒恆明,《操作系統之哲學原理》,機械工業出版社