談到鎖,離不開多線程,或者進程間的通訊。
還記得上次跟你撕逼內存模型的那我的嗎,他又來了,而且向你甩出了一堆問題:
咱們知道,在操做系統中,互相協做的進程之間可能共享一些彼此都能讀寫的公共存儲區,假設兩個進程都須要改寫這個公共的存儲區那麼就會產生競爭關係了。
下面舉個例子
假設兩個進程a和b共享一個脫機目錄,脫機目錄中有許多槽位,free記錄了下一個空的槽位,進程能夠往下一個空槽位中寫入內容。
進程a準備往下一個空槽位寫入內容"test",進程b準備往下一個空槽位寫入內容「good」。
咱們來分析下極端狀況:
能夠發現,因爲發生了時鐘中斷,兩個進程都往槽位3寫入了內容,進程b的內容被進程a的內容覆蓋掉了。
像這種因爲兩個或者多個進程讀寫某些共享數據,最後結果取決於進程運行的精確時序,稱爲競態條件
。
爲了不這種競態條件
的出現,就須要找出存在這種競態條件
的程序片斷,經過互斥
的手段來阻止多個進程同時讀寫共享的數據。
對共享內存進行訪問的程序片斷稱爲臨界區
。
爲了實現互斥而選擇適當的原語是任何操做系統的主要涉及內容之一。
仍是以上面的例子來講明,爲了不競態條件
的產生,咱們須要把獲取空槽位和往槽位寫內容的程序片斷做爲一個臨界區,任何不一樣的進程,不能夠在同一個時刻進入這個臨界區:
如上圖,進程b試圖在a離開臨界區以前進入臨界區,會進入不了,致使阻塞,一般表現的行爲爲:
爲了實現這種臨界區的互斥,須要進程之間可以像對話同樣,確認是否能夠進入臨界區執行代碼,這種對話即進程通訊
。
所謂忙等待,指的是進程本身一直在循環判斷是否能夠獲取到鎖了,這種循環也稱爲自旋
。
屏蔽中斷
和鎖變量
的介紹,依次引出忙等待的相關互斥手段方法。
以下圖,在進程進入臨界區以前,調用local_irq_disable
宏來屏蔽中斷,在進程離開臨界區以後,調用local_irq_disable
宏來使能中斷。
CPU只有發生時鐘中斷或其餘中斷纔會進行進程切換,也就是說,屏蔽中斷後,CPU不會切換到其餘進程。
另外,這個屏蔽中斷是用戶進程觸發的,若是用戶進程長時間沒有離開臨界區,那就意味着中斷一直啓用不了,最終致使整個系統的終止。
因而可知,在這個多核CPU普及的時代,屏蔽中斷並非實現互斥的良好手段。
上面一種硬件的解決方案,既然硬件解決不了,那麼咱們嘗試經過軟件層面的解決方案去實現。
可是因爲對Lock的check和set是分爲兩步,並不是原子性的,那麼可能會出現以下狀況:
也就是說在進程a把Lock設置爲1以前,b就進行check和set操做了,也獲取到了Lock=0,致使兩個進程同時進入了臨界區。
這種非原子性的檢查並設置鎖操做仍是會存在競態條件,並不能做爲互斥的解決方案。
接下來咱們升級一下程序,爲了不這種競態條件
,咱們讓進程間嚴格輪換的方式去爭搶使用Lock的機會。
所謂嚴格輪換法,就是指定一個標識位turn
,當turn=0的時候讓進程a進入臨界區,當turn=1的時候,讓進程b進入臨界區。
1// 進程a 2while(TRUE){ 3 while(turn != 0); /* 循環測試turn,看其值什麼時候變爲0 */ 4 critical_region(); /* 進入臨界區 */ 5 turn = 1; /* 讓給下一個進程處理 */ 6 noncritical_region(); /* 離開臨界區 */ 7} 8// 進程b 9while(TRUE){10 while(turn != 1); /* 循環測試turn,看其值什麼時候變爲1 */11 critical_region(); /* 進入臨界區 */12 turn = 0; /* 讓給下一個進程處理 */13 noncritical_region(); /* 離開臨界區 */14}複製代碼
這種方法可能致使在循環中不停的測試turn,這稱爲
忙等待
,比較浪費CPU,只有有理由認爲等待時間是很是短的情形下,才使用忙等待
,用於忙等待的鎖,稱爲自旋鎖(spin lock)。
假設如今進程a在臨界區裏面,而且執行了turn=1,準備把臨界區輪換給進程b,可是這個時候進程b正在處理其餘事情,那麼這個臨界區就一直被進程b阻塞了。
也就是說,我唱完一首歌,把麥給了你,輪到你唱,這個時候你拿着麥去上廁所了。
你上廁所居然影響到了我唱歌,就是所謂的臨界區外運行的進程阻塞了其餘想進入臨界區的進程。
看來這種解決方案並非一個很好的選擇。
接下來咱們經過一種G.L.Peterson
發現的一種互斥算法來實現互斥功能。
既然Linus
說了Talk is cheap. Show me the code.
話很少說,咱們直接上代碼:
1#define FALSE 0 2#define TRUE1 3#define N 2 /* 進程數量 */ 4 5int turn; /* 如今輪到誰? */ 6int interested[N]; /* 全部值初始化爲0 (FALSE) */ 7 8void enter_region(int process){ /* 進程是0或1 */ 9 int other; /* 其餘進程號 */10 other = 1 - process; /* 另外一方進程 */11 interested[process] = TRUE; /* 表示所感興趣 */12 turn = process;13 while(turn == process && interested[other] == TRUE);/* 空循環 */14}1516void leave_region(int process){17 interested[process] = FALSE; /* 表示離開臨界區 */18}複製代碼
算法關鍵代碼是while循環,若是併發執行,當進程0調用完enter_region
以後,變量值以下:
1interested[0] = TRUE2turn=0複製代碼
進程1調用完enter_region
後,給turn賦值=1,覆蓋了進程0的賦值:
1interested[0] = TRUE2interested[1] = TRUE3turn=1複製代碼
而後進程1發現turn == process
成立,而且interested[other] == TRUE
,而後卡在這裏自旋等待,直到另外一個進程離開了臨界區。
能夠看到,這個算法只適用於兩個進程間的互斥處理,更多進程就沒辦法了。
接下來,咱們經過一種硬件指令的方式,幫助咱們更好的實現互斥。
基於硬件指令通常是基於衝突檢測的樂觀併發策略:
樂觀併發策略須要硬件指令集的發展才能進行,須要硬件指令實現:
這類指令有:
測試並設置鎖 Test and Set Lock (TSL)
獲取並增長 Fetch-and-Increment
交換 Swap
比較並交換 Compare-and-Swap (CAS)
加載連接/條件存儲 Load-linked / Store-Conditional LL/SC
測試並設置鎖 Test and Set Lock (TSL),指令格式以下:
TSL RX, LOCK
做用是將一個內存字lock讀到寄存器RX,而後將lock設置爲一個非0值。
執行原理:
執行TSL指令的CPU會鎖住內存總線,禁止其餘CPU在這個指令結束以前訪問內存。
爲了使用TSL指令,須要使用一個共享變量lock來協調多內存的訪問。
1enter_region:2 TSL REGISTER,LOCK | 複製鎖到寄存器並將鎖設爲13 CMP REGISTER,#0 | 鎖是0嗎?4 JNE enter_region | 若不是0,說明鎖已被設置,因此循環5 RET | 返回調用者,進入臨界區67leave_region:8 MOVE LOCK,#0 | 在鎖中存入 09 RET | 返回調用者複製代碼
若是TSL原子操做沒有成功,則從新跳轉到enter_region
方法循環執行。
Peterson
算法有點相似,不過TSL能夠支持任意多個進程的併發執行。
IA64 和 X86 使用cmpxchg指令完成CAS功能。
cas 內存位置 舊預期值 新值
CAS存在ABA問題,可使用版本號進行控制,保證其正確性。
JDK中的CAS,相關類:
Unsafe
裏面的compareAndSwapInt()
以及compareAndSwapLong()
等幾個方法包裝提供。只有啓動類加載器加載的class才能訪問他,或者經過反射獲取。
硬件指令既能夠實現忙等互斥,也能夠實現進程掛起阻塞,關鍵看具體的實現代碼,這裏使用了JNE指令進行跳轉循環等待,後面咱們會介紹用TSL指令實現進程掛起阻塞的互斥量。
以上的解法都是能夠實現互斥的,可是存在忙等,致使浪費CPU時間的問題,若是同步資源鎖定時間很短,那麼這個等待仍是值得的,可是若是鎖佔用時間過長,那麼自旋就會浪費CPU資源了。
優先級反轉問題
:
以下圖,進程H優先級較高,進程L先進入了臨界區,而後H變到就緒狀態,準備運行,如今H開始忙等待。
爲了避免浪費CPU資源,咱們可使用進程間通訊的原語sleep
和wakeup
,sleep
形成調用者阻塞,直到其餘進程喚醒它。
以下圖,生產者往隊列裏面生產消息,消費者從隊列裏面取消息進行消費:
當消息隊列滿的時候,生產者準備進行睡眠,但還沒睡着:
消費者消費了一條消息以後,他認爲生產者正在睡覺,準備通知生產者也起牀幹活生產消息了:
但是這個時候,生產者實際上都還沒真正睡着,因此:
wakeup信號丟失以後,生產者才真正的睡着了,這個時候消費者殊不知道生產者睡着了,因而一直在消費消息,知道消息消費完了,消費者本身也睡覺了。
生產者消費者完整代碼以下:
1#define N 100 /* 緩衝區中的槽數量 */ 2int count = 0; /* 緩衝區中的數據項數目 */ 3 4// 生產者 5void producer(void){ 6 int item; 7 8 while(TRUE){ /* 無限循環 */ 9 item = produce_item() /* 產生下一新數據項 */10 if(count == N) sleep(); /* 若是緩衝區滿了,就進入休眠狀態 */11 insert_item(item); /* 將新數據放入緩衝區中 */12 count = count + 1; /* 緩衝區數據項計數器+1 */13 if(count == 1) wakeup(consumer); /* 緩衝區不爲空則喚醒消費 */14 }15}1617// 消費者18void consumer(void){19 int item;2021 while(TRUE){ /* 無限循環 */22 if(count == 0) sleep(); /* 若是緩衝區是空的,則進入休眠 */23 item = remove_item(); /* 從緩衝區中取出一個數據項 */24 count = count - 1 /* 將緩衝區的數據項計數器-1 */25 if(count == N - 1) wakeup(producer); /* 緩衝區不滿,則喚醒生產者? */26 consumer_item(item); /* 打印數據項 */27 }28}複製代碼
怎麼解決這種進程之間不一樣步,致使的死鎖問題呢,接下來咱們就經過信號量來實現。
經過使用一個整型變量來累計喚醒次數,以供以後使用。
down:
up:
檢查數值、修改變量值以及可能發生的睡眠或者喚起操做是原子性的。
信號量原理:
檢查數值、修改變量值以及可能發生的休眠或者喚起操做是原子性的,一般將up和down做爲系統調用來實現;
當執行如下操做時,操做系統暫時屏蔽所有中斷:
檢查信號量、更新、可能發生的休眠或者喚醒,這些操做須要不多的指令,所以中斷不會形成影響; 若是是多核CPU,信號量同時會被保護起來,經過使用TSL或者XCHG指令確保同一個時刻只有一個CPU對信號量進行操做。
使用信號量解決進程同步問題代碼以下:
1#define N 100 /* 緩衝區中的槽數目 */ 2typedef int semaphore; /* 信號量是一種特殊的整型數據 */ 3semaphore mutex = 1; /* 控制對臨界區的訪問 */ 4semaphore empty = N; /* 計數緩衝區的空槽數目 */ 5semaphore full = 0; /* 計數緩衝區的滿槽數目 */ 6 7void producer(void){ 8 9 int item; 1011 while(TRUE){ /* TRUE是常量1 */12 item = producer_item(); /* 產生放在緩衝區中的一些數據 */13 down(&empty); /* 將空槽數目-1 */14 down(&mutex); /* 進入臨界區 */15 insert_item(item); /* 將新數據放入緩衝區中 */16 up(&mutex); /* 離開臨界區 */17 up(&full); /* 將滿槽數目+1 */18 }19}2021void consumer(void){2223 int item;2425 while(TRUE){ /* 無限循環 */26 down(&full); /* 將滿槽數目-1 */27 down(&mutex); /* 進入臨界區 */28 item = remove_item(); /* 從緩衝區取出數據項 */29 up(&mutex); /* 離開臨界區 */30 up(&empty); /* 將空槽數目+1 */31 consume_item(item); /* 處理數據項 */32 }33}複製代碼
如上,每一個進程在進入關鍵區域以前執行down操做,在離開關鍵區域以後執行up操做,這樣就能夠確保互斥了。
mutex
:
full
和empty
:
若是僅僅是須要互斥,而不是計數能力,可使用信號量的簡單版本:
mutex_lock
:
mutex_unlock
:
互斥量能夠經過TSL或者XCHG指令實現,下面是用戶線程包的mutex_lock
和mutex_unlock
的代碼:
1mutex_lock: 2 TSL REGISTER,MUTEX | 將互斥信號量複製到寄存器,而且將互斥信號量置爲1 3 CMP REGISTER,#0 | 互斥信號量是0嗎? 4 JZE ok | 若是互斥信號量爲0,它被解鎖,因此返回 5 CALL thread_yield | 互斥信號量忙;調度其餘線程 6 JMP mutex_lock | 稍後再試 7ok: RET | 返回調用者,進入臨界區 8 9mutex_unlock:10 MOVE MUTEX,#0 | 將mutex置爲 011 RET | 返回調用者複製代碼
以上代碼和
enter_region
的區別?
enter_region
失敗的時候會始終重試,而這裏會調度其餘進程進行執行,這樣早晚擁有鎖的進程會進入運行並釋放鎖;在用戶線程中,
enter_region
經過忙等待試圖獲取鎖,將永遠循環下去,絕對不會獲得所,由於其餘線程不能獲得運行進行釋放鎖。沒有時鐘中止運行時間過長的線程。 線程庫沒法像進程那樣經過時鐘中斷強制線程讓出CPU。
在單核系統中若是一個線程霸佔了CPU,那麼該進程中的其餘線程就沒法執行了。
因爲thread_yield
僅僅是一個用戶空間的進程調度,因此它運行很是快捷。
mutex_lock
和mutex_unlock
都不須要任何內核調用,從而實現了在用戶空間中的同步,這個過程僅僅須要少許的同步。
有些線程包也會提供mutex_trylock
,嘗試獲取鎖或者失敗,讓調用方本身決定是等待下去仍是使用個替代方法。
爲了可以編寫更加準確無誤的程序,因而出現了管程(monitor)的概念:
管程
是程序、變量和數據結構等組成的集合,構成一個特殊模塊或者軟件包,進程能夠調用管程中的程序,可是不能在管程以外聲明的過程當中直接訪問管程內的數據結構。
注意:
1monitor example 2 integer i; 3 condition c; 4 5 procedure producer(); 6 . 7 end; 8 9 procedure consumer();10 .11 end;12end monitor;複製代碼
管程中任意時刻只能有一個活躍的進程,從而實現了互斥。
管程的互斥由編譯器負責決定。
互斥量
和二進制信號量
。
Java中的synchronized關鍵字正是基於管程實現的,咱們後面會具體介紹。
經過臨界區的自動互斥,管程比信號量更容易保證並行編程的正確性。
不管是經過硬件指令,仍是信號量阻塞,或者管程,都是設計用來解決一個或者多個CPU上的互斥問題的。
消息傳遞
。
這個是專門給進程組而不是進程間實現同步的。
有寫程序中劃分了若干階段,而且規定,除非全部進程都準備就緒着手下一個階段,不然任何進程都不能進入下一個階段,能夠在每一個階段結尾安裝屏障來實現這種行爲,當一個進程達到屏障的時候,就會被屏障阻攔,直到全部進程都達到該屏障爲止。
第2節內容主要提煉於《現代操做系統》一書,並添加了一些輔助理解的圖片,給枯燥的描述添加一點趣味兒。
線程通訊是創建在線程模型之上的,咱們首先來說一下Java的線程模型。
注意:
上面說的互斥量實現,是 用戶線程包
中的實現,因此不須要內核調用。而Java中的互斥量會有所不一樣,仍是須要進行系統調用,由用戶態切換到內核態。 JVM規範未指定Java線程須要用哪一種線程模型。
常見線程實現方式有如下幾種:
使用內核線程實現
內核線程(KLT
, Kernel-Level Thread)是直接由操做系統內核支持的線程,內核完成線程切換,內核經過操縱調度器對線程進行調度,並負責將線程的任務映射到各個處理器上。
各類線程操做,如建立、析構及同步,都須要進行系統調用
,須要在用戶態
和內核態
中來回切換。
程序經過內核線程的高級接口:
LWP
, Light Weight Process)操做內核線程。
每一個輕量級進程
都須要有一個內核線程
的支持,所以輕量級進程要消耗必定的內核資源如內核線程的棧空間,因此一個系統可以支持的輕量級進程的數量是有限的。
內核線程至關於內核的分身,這樣能夠內核同時處理多件事情,支持多線程的內核叫多線程內核。
使用用戶線程實現
咱們上面將的互斥量的實現,即便基於用戶線程的。
缺點:
使用用戶線程+輕量級進程混合實現
Java線程的實現
JVM規範並無限定Java線程須要那種模型,對於Windows和Linux版本使用的是1:1的線程,映射到輕量級進程中。
經過使用synchronized
,能夠實現任意大語句塊的原子性單位,使咱們可以解決volatile沒法實現的read-modify-write
問題。
Java中的synchronized
關鍵字也是經過管程實現的,保證了操做單原子性和可見性。
管程通用作法是經過互斥量實現的,這會致使線程掛起阻塞,這種傳統的鎖稱爲重量級鎖
。
輕量級鎖
和偏向鎖
的概念。
對象頭中記錄了對象的鎖類型,咱們再來回顧一下對象的內存佈局:
咱們先來介紹下輕量級鎖和偏向鎖。
之因此叫輕量級鎖
,是與互斥量致使線程掛起阻塞這種重量級鎖
對比的叫法,沒錯,我就是比互斥重量級鎖輕巧多了。
從未鎖定到輕量級鎖定的過程仍是有點繁瑣的,涉及複製Mark Word
,CAS
指定鎖記錄,指定失敗的狀況下可能還須要膨脹爲重量級鎖
;
CAS
替換Mark Word
,替換失敗則說明有其餘線程在等待獲取鎖,這個時候在釋放鎖的同時須要喚起其餘線程。
Mark Word
的變化:
所謂偏向鎖,就是在數據無競爭的狀況下,消除同步原語,進一步提升運行性能。
輕量級鎖
在無競爭狀況下使用CAS消除同步使用的互斥量,偏向鎖
在無競爭的狀況把整個同步都消除了,更加輕量級。
爲何叫偏向鎖
,覺得偏愛呀,總是偏袒第一個獲取到他的線程。
若是開啓了偏向鎖(JDK1.8默認是開啓的),那麼當鎖對象第一次被線程獲取的時候,虛擬機就會嘗試設置爲偏向鎖模式:
一旦有其餘線程競爭,那麼偏向模式就結束了。
爲了提升synchronized的性能,HotSpot虛擬機團隊在JDK 1.6版本花費了大量精力進行鎖優化,包括:
自旋鎖
:
-XX:PreBlockSpin
;
自適應自旋鎖
:
鎖消除
:
即時編譯器
乾的活,通常經過逃逸分析
的數據支持進行鎖消除
,通常程序員都不會直接在單線程代碼中顯示的使用鎖,可是有時候雖然只有一行代碼:
str = "a" + "b" + "."
可是在JDK5以前底層是翻譯爲了StringBuffer的append()操做,該方法是包含synchronized
鎖的,因此這種狀況及時編譯器仍是會進行鎖消除。
鎖粗化
:
固然,上面說起的鎖升級,也是鎖優化的一種手段。
對於同一個鎖,若是一個線程成功進入了臨界區,那麼該線程在持有鎖的同時,能夠反覆進入該鎖。
每退出一個synchronized方法塊,計數器就-1,直到0的時候就釋放鎖。
爲何說它是一把悲觀鎖呢,由於假設有一個線程獲取到了鎖,那麼其餘嘗試獲取鎖的線程只能等待,因而悲觀的去睡覺了,等到別人叫醒以後才從新去競爭獲取鎖。
咱們在各類文章書籍裏面可能會看到對鎖的各類分類,都是什麼意思呢?
樂觀鎖老是很樂觀的認爲不會有太多人會搶佔鎖,因此通常不會先進行加鎖,等到出了問題以後再處理。
對於樂觀鎖,可能若是發現的確出現了問題,通常會經過自旋,或者直接放棄等的方式進行處理。
鎖以樂觀鎖適用於併發寫入少,大部分是讀的場景。
悲觀鎖就是很悲觀的認爲會有不少人想佔用這個鎖,悲觀鎖爲了保證本身能夠拿到鎖,一上來就嘗試鎖定,若是鎖不住,那就放棄了,直接睡覺去了,也就是線程掛起,等到下次有人叫他起牀的時候,纔會從新參與到鎖的競爭中來。
對於競爭比較激烈,臨界區消耗比較多的時間的場景,比較適合悲觀鎖。
這個概念,詳細看完上文的你應該比較瞭解了,就是獲取鎖失敗以後,循環重試。
這個概念,詳細看完上文的你應該比較瞭解了,就是獲取鎖失敗以後,掛起線程。
又稱讀鎖,既然共享了,那麼就不能隨便刪除和修改了。
跟共享鎖不同,排他鎖就是一旦獲取到了他以後,其餘線程就不再能獲取到了。
這個比較容易理解,咱們在上面講synchronized的時候已經介紹了。
可重入鎖在反覆屢次使用同一個鎖的場景下,避免了死鎖的發生。
公平鎖,就是徹底按照請求順序來分配的鎖,保證了對全部線程公平。
跟公平鎖不同,是不徹底按照請求順序來處理的。
Java併發包中的
ReentrantLock
鎖就提供了非公平鎖和公平鎖的實現。
爲何要有公平鎖呢?
可是輪到一個號以後,假如那個號的人恰好去外面買東西了,若是你們要繼續等它回來辦理,就會很花時間,因而索性讓人去搶櫃檯窗口,先搶到的人就先辦理,若是連續兩次都搶位失敗了,那麼咱們就把這我的放入排隊隊列,等到搶到窗口的人辦理完了業務,再輪流叫喚他們,這就是非公平鎖。
可是若是窗口的那我的辦理業務的時間好久,忽然叫一大波人衝上來搶窗口,是搶不到的呀,也就是說對於業務執行時間很長的場景,非公平鎖其實效率並不高。
很明顯,公平鎖吞吐量小,但能夠保證每一個線程在等一段時間總有機會執行;
而非公平鎖吞吐量更大,可是可能有些線程會長時間得不到執行。
能夠響應線程中斷的鎖,如ReentrantLock.lockInterruptibly()。
不能夠響應線程中斷的鎖,如ReentrantLock.lock()。
其實Java併發包中針對不一樣的使用場景,也提供了不少的鎖,咱們能夠直接拿來用。
好了,咱們今天就講到這裏了,可以看到這裏的朋友們是真的很熱愛技術,想對大家說:
本文爲arthinking基於相關技術資料和官方文檔撰寫而成,確保內容的準確性,若是你發現了有何錯漏之處,煩請高擡貴手幫忙指正,萬分感激。
你們能夠關注個人博客:
itzhai.com
獲取更多文章,我將持續更新後端相關技術,涉及JVM、Java基礎、架構設計、網絡編程、數據結構、數據庫、算法、併發編程、分佈式系統等相關內容。
若是您以爲讀完本文有所收穫的話,能夠關注個人帳號,或者點個贊。
關注個人公衆號,及時獲取最新的文章。
《現代操做系統》
《深刻理解Java虛擬機:
Java併發編程—細說J.U.C下Lock的分類及特色詳解(結合案例和源碼)
本文做者:
arthinking 博客連接:
https://www.itzhai.com/cpj/process-synchronization-and-lock.html 版權聲明:
BY-NC-SA
許可協議:創做不易,如需轉載,請務必附加上博客連接,謝謝!