前言
歡迎來到操做系統系列,採用圖解 + 大白話的形式來說解,讓小白也能看懂,幫助你們快速科普入門。數據庫
上篇文章有介紹過進程與線程的基礎知識,進程下擁有多個線程,雖然多線程間通訊十分方便(同進程),可是卻帶來了線程安全問題,本篇主要就是介紹操做系統中是用什麼方法解決多線程安全,廢話很少說,進入正文吧。數組
博主但願讀者閱讀文章後能夠養成思考與總結的習慣,只有這樣才能把知識消化成本身的東西,而不是單純的去記憶緩存
內容大綱
小故事
帶薪蹲坑,相信都是大夥都愛作的事情,阿星也不例外,可是我司所在的樓層的坑位較少,粥少僧多,十分煩惱。安全
阿星(線程A)每次去廁所(共享資源),門都是鎖着的,說明有同事在裏面佔着坑(線程B持有鎖),只能無奈的在外面乖乖的等着,不久後沖水聲響起,同事爽完出來(線程B釋放鎖),阿星一個健步進入廁所把門鎖住(線程A持有鎖),享受屬於本身的空間,晚來的其餘同事只能乖乖排隊,一切都是那麼井井有理。網絡
假設門鎖壞了,井井有理就不存在了,上廁所再也不是享受,而是高度緊張,防止門忽然被打開,更糟糕的是,開門時,是個妹子,這下不只僅是線程安全問題,還有數組越界了。多線程
故事說完,扯了那麼多,就是想說明,在多線程環境裏,對共享資源進行操做,若是多線程之間不作合理的協做(互斥與同步),那麼必定會發生翻車現場。分佈式
競爭條件
由於多線程共享進程資源,在操做系統調度進程內的多線程時,必然會出現多線程競爭共享資源問題,若是不採起有效的措施,則會形成共享資源的混亂!函數
來寫個小例子,建立兩個線程,它們分別對共享變量 i
自增 1
執行 1000
次,以下代碼測試
正常來講,i
變量最後的值是 2000
,但是並不是如此,咱們執行下代碼看看結果this
- 結果:
2000
- 結果:
1855
運行了兩次,結果分別是185五、2000,咱們發現每次運行的結果不一樣,這在計算機裏是不能容忍的,雖然是小几率出現的錯誤,可是小几率它必定是會發生的。
彙編指令
爲了搞明白到底發生了什麼事情,咱們必需要了解彙編指令執行,以 i
加 1
爲例子,彙編指令的執行過程以下
好傢伙,一個加法動做,在 C P U 運行,實際要執行 3
條指令。
如今模擬下線程A與線程B的運行,假設此時內存變量 i
的值是 0
,線程A加載內存的 i
值到寄存器,對寄存器 i
值加 1
,此時 i
值是 1
,正準備執行下一步寄存器 i
值回寫內存,時間片使用完了,發生線程上下文切換,保存線程的私有信息到線程控制塊T C P。
操做系統調度線程B執行,此時的內存變量 i
依然仍是 0
,線程B執行與線程A同樣的步驟,它很幸運,在時間片使用完前,執行完了加 1
,最終回寫內存,內存變量 i
值是 1
。
線程B時間片使用完後,發生線程上下文切換,回到線程A上次的狀態繼續執行,寄存器中的 i
值回寫內存,內存變量再次被設置成 1
。
按理說,最後的 i
值應該是 2
,可是因爲不可控的調度,致使最後 i
值是 1
,下面是線程A與線程B的流程圖
- 第一步:內存取出
i
值,加載進寄存器 - 第二步:對寄存器內的
i
值加1
- 第三步:寄存器內的
i
值取出 加載進內存
小結
這種狀況稱爲競爭條件(race condition),多線程相互競爭操做共享資源時,因爲運氣很差,在執行過程當中發生線程上下文切換,最後獲得錯誤的結果,事實上,每次運行均可能獲得不一樣的結果,所以輸出的結果存在不肯定性(indeterminate)。
互斥與同步
爲了解決因競爭條件出現的線程安全,操做系統是經過互斥與同步來解決此類問題。
互斥概念
多線程執行共享變量的這段代碼可能會致使競爭狀態,所以咱們將此段代碼稱爲臨界區(critical section),它是執行共享資源的代碼片斷,必定不能給多線程同時執行。
因此咱們但願這段代碼是互斥(mutualexclusion)的,也就說執行臨界區(critical section)代碼段的只能有一個線程,其餘線程阻塞等待,達到排隊效果。
互斥並不僅是針對多線程的競爭條件,同時還可用於多進程,避免共享資源混亂。
同步概念
互斥解決了「多進程/線程」對臨界區使用的問題,可是它沒有解決「多進程/線程」協同工做的問題
咱們都知道在多線程裏,每一個線程必定是順序執行的,它們各自獨立,以不可預知的速度向前推動,但有時候咱們但願多個線程能密切合做,以實現一個共同的任務。
所謂同步,就是「多進程/線程間」在一些關鍵點上可能須要互相等待與互通消息,這種相互制約的等待與互通訊息稱爲「進程/線程」同步。
舉個例,有兩個角色分別是研發、質量管控,質量管控測試功能,須要等研「發完成開發」,研發要修bug也要等質量管控「測試完成提交B U G」,正常流程是研發完成開發,通知質量管控進行測試,質量管控測試完成,通知研發人員修復bug。
互斥與同步的區別
-
互斥:某一資源同時只容許一個訪問者對其進行訪問,具備惟一性和排它性。但互斥沒法限制訪問者對資源的訪問順序,即訪問是無序的(操做 A 和操做 B 不能在同一時刻執行)
-
同步:互斥的基礎上,經過其它機制實現訪問者對資源的有序訪問。在大多數狀況下,同步已經實現了互斥(操做 A 應在操做 B 以前執行,操做 C 必須在操做 A 和操做 B 都完成以後才能執行)
顯然,同步是一種更爲複雜的互斥,而互斥是一種特殊的同步。也就是說互斥是兩個線程之間不能夠同時運行,他們會相互排斥,必須等待一個線程運行完畢,另外一個才能運行,而同步也是不能同時運行,但他是必需要按照某種次序來運行相應的線程(也是一種互斥)!
互斥與同步的實現
互斥與同步能夠保證「多進程/線程間正確協做」 ,可是互斥與同步僅僅只是概念,操做系統必需要提供對應的實現,針對互斥與同步的實現有下面兩種
-
鎖:加鎖、解鎖操做(互斥)
-
信號量:P、V 操做(同步)
這兩個種方式均可以實現「多進程/線程」互斥,信號量比鎖的功能更強一些,它還能夠方便地實現「多進程/線程」同步。
鎖
顧名思義,給臨界區上一把鎖,任何進入臨界區)的線程,必須先執行加鎖操做,加鎖成功,才能進入臨界區,在離開臨界區時再釋放鎖,達到互斥的效果。
鎖的實現方式又分爲「忙等待鎖」和「無忙等待鎖」
忙等鎖
檢查並設置(test-and-set-lock,TSL)是一種不可中斷的原子運算,它屬於原子操做指令,能夠經過它來實現忙等鎖(自旋鎖)。
test-and-set-lock 指令僞代碼
檢查並設置作了以下幾個步驟
- 檢查舊值是否相等
- 相等設置新值,返回原舊值(成功)
- 不相等,無任何操做,直接返回原舊值(失敗)
上面的步驟,把它當作一步並具有原子性,原子性的意思是指所有執行或都不執行,不會出現執行到一半的中間狀態.
僞代碼testAndSetLock
實現忙等鎖(自旋鎖)
下面兩種場景運行
-
單線程:假設一個線程訪問臨界區,執行
getLock
方法,檢查舊值0
經過,更新原舊值0
爲新值1
,返回原舊值0
,獲取鎖成功,離開臨界區時,執行unLock
方法,檢查舊值1
經過,更新原舊值1
爲新值0
,釋放鎖成功。 -
多線程:假設兩個線程,線程A訪問臨界區,執行
getLock
方法,檢查舊值0
經過,更新原舊值0
爲新值1
,返回原舊值0
,獲取鎖成功,此時線程B執行getLock
方法,舊值檢查失敗,獲取鎖失敗,一直循環直到更新成功爲止,當線程A離開臨界區時,執行unLock
方法,檢查舊值1
經過,更新原舊值1
爲新值0
,釋放鎖成功,線程B獲取鎖成功。
當獲取不到鎖時,線程就會一直 wile
循環,不作任何事情,因此就被稱爲忙等待鎖,也被稱爲自旋鎖。
這是最簡單的鎖,一直自旋,利用 C P U 週期,直到鎖可用。在單處理器上,須要搶佔式的調度器(即不斷經過時鐘中斷一個線程,運行其餘線程)。不然,自旋鎖在 C P U 上沒法使用,由於一個自旋的線程永遠不會放棄 C P U。
無忙等鎖
顧名思義,無忙等鎖不須要主動自旋,被動等待喚醒便可,在沒有獲取到鎖的時候,就把該線程加入到等待隊列,讓出 C P U 給其餘線程,其餘線程釋放鎖時,再從等待隊列喚醒該線程。
兩種鎖的實現都是基於檢查並設置(test-and-set-lock,TSL),上面只是簡單的僞代碼,實際上操做系統的實現會更復雜,可是基本思想與大體流程仍是與本例同樣。
信號量
操做系統中協調「多線程/進程」共同配合工做,就是經過信號量實現的,一般信號量表明「資源數量」,對應一個整型(s e n
)變量,還有兩個原子操做的系統調用函數來控制「資源數量」。
-
P 操做:將
s e n
減1
,相減後,若是s e n
<0
,則進程/線程進入阻塞等待,不然繼續,P 操做可能會阻塞 -
V 操做:將
s e n
加1
,相加後,若是s e n
<=0
,喚醒等待中的進程/線程,V 操做不會阻塞
P V操做必須是成對出現,可是沒有順序要求,也就說你能夠P V或V P。
舉個例子,最近新冠病毒又出來搗亂了,爲了自身安全,你們都去打疫苗,由於醫生只有兩位(至關於2個資源的信號量),因此同時只能爲兩我的接種疫苗,過程以下圖
- 信號量等於
0
時,表明無資源可用 - 信號量小於
0
時,表明有線程在阻塞 - 信號量大於
0
時,表明資源可用
使用僞代碼實現P V 信號量
P V操做的函數是由操做系統管理和實現的,因此 P V 函數是具備原子性的。
實踐
信號量仍是比較有意思的,這裏來作幾個實踐,加深你們對信號量的理解,實踐的內容分別是
- 信號量實現互斥
- 信號量實現事件同步
- 信號量實現生產者與消費者
互斥
使用信號量實現互斥很是簡單,信號量數量爲1
,線程進入臨界區進行 P 操做,離開臨界區進行 V 操做。
事件同步
之前面說的研發、質量管控線程爲例子,實現事件同步的效果,僞代碼以下
首先抽象出兩個信號量,「是否能提測」與「是否能修BUG」,它們默認都是否,也就是 0
,關鍵點就是對兩個信號量進行 P V 操做
-
質量管控線程詢問開發線程有沒有完成開發,執行
P
操做p(this.rDSemaphore)
- 若是沒有完成開發,
this.rDSemaphore
減1
結果爲-1
,質量管控線程阻塞等待喚醒(等後續研發線程進行V
操做) - 若是完成開發,說明研發線程先執行
V
操做v(this.rDSemaphore)
完成開發,this.rDSemaphore
加1
結果1
,此時質量管控線程P
操做this.rDSemaphore
減1
結果0
,進行後面的提測工做
- 若是沒有完成開發,
-
研發線程詢問質量管控線程能不能修復B U G,執行
P
操做p(this.qualitySemaphore)
- 若是不能夠修復B U G,
this.qualitySemaphore
減1
結果爲-1
,研發線程阻塞等待喚醒(等後續質量管控線程執行V
操做) - 若是能夠修復B U G,說明質量管控線程先執行
V
操做v(this.qualitySemaphore)
提交BUG,this.qualitySemaphore
加1
結果爲1
,此時研發線程P
操做this.qualitySemaphore
減1
結果0
,進行後面的修復 B U G 操做
- 若是不能夠修復B U G,
-
流程
- 質量管控線程執行
P
操做p(this.rDSemaphore)
能不能提測,this.rDSemaphore
減1
結果是-1
,不能進行提測,質量管控線程阻塞等待喚醒 - 研發線程運行,執行
V
操做v(this.rDSemaphore)
完成研發功能,this.rDSemaphore
加1
結果是0
,通知質量管控線程提測 - 研發線程繼續執行
P
操做p(this.qualitySemaphore)
能不能修復B U G,this.qualitySemaphor
減1
結果是-1
,不能修復B U G,研發線程阻塞等待喚醒 - 質量管控線程喚醒後進行提測,提測完畢執行
V
操做v(this.qualitySemaphore)
完成提測與提交相關B U G,this.qualitySemaphore
加1
結果是0
,通知研發線程進行B U G修復
- 質量管控線程執行
生產者與消費者
生產者與消費者是一個比較經典的線程同步問題,咱們先分析下有那些角色
- 生產者:生產事件放入緩衝區
- 消費者:從緩衝區消費事件
- 緩衝區:裝載事件的容器
問題分析能夠得出:
- 任什麼時候刻只能有一個線程操做緩衝區,說明操做緩衝區是臨界代碼,須要互斥
- 緩衝區空時,消費者必須等待生產者生成數據
- 緩衝區滿時,生產者必須等待消費者取出數據
經過問題分析咱們能夠抽象出3個信號量
- 互斥信號量:互斥訪問緩衝區,初始化
1
- 消費者資源信號量:緩衝區是否有事件,初始化
0
,無事件 - 生產者信號量:緩衝區是否有空位裝載事件,初始化
N
(緩衝區大小)
僞代碼以下
關鍵的 P V 操做以下
- 生產線程,在往緩衝區裝載事件以前,執行
P
操做p(this.produceSemaphore)
,緩衝區空槽數量減1
,結果 <0
說明無空槽,阻塞等待「消費線程」喚醒,不然執行後續邏輯 - 不管是生產線程仍是消費線程在操做緩衝區都要執行
P V
臨界區操做p(this.mutexSemaphore)
與v(this.mutexSemaphore)
,這裏就不作過多概述了 - 消費線程,在從緩存區消費事件以前,執行
P
操做p(this.consumeSemaphore)
,緩衝區事件數量減1
,結果 <0
說明緩衝區無事件消費,阻塞等待「生產線程」喚醒,否執行後續邏輯 - 生產線程與消費線程,執行完「裝載/消費」後,都要喚醒對應的「生產/消費線程」,執行
V
操做「緩衝區空槽加1
/緩衝區事件加1
」
關於我
公衆號 : 「程序猿阿星」 專一技術原理、源碼,經過圖解方式輸出技術,這裏將會分享操做系統、計算機網絡、Java、分佈式、數據庫等精品原創文章,期待你的關注。