如何避免死鎖?我們有套路可循
寫在前面
上一篇文章共享資源那么多,如何用一把鎖保護多個資源? 文章我們談到了銀行轉(zhuǎn)賬經(jīng)典案例,其中有兩個問題:
- 單純的用 synchronized 方法起不到保護作用(不能保護 target)
- 用 Account.class 鎖方案,鎖的粒度又過大,導(dǎo)致涉及到賬戶的所有操作(取款,轉(zhuǎn)賬,修改密碼等)都會變成串行操作
如何解決這兩個問題呢?咱們先換好衣服穿越回到過去尋找一下錢莊,一起透過現(xiàn)象看本質(zhì),dengdeng deng.......
來到錢莊,告訴柜員你要給鐵蛋兒轉(zhuǎn) 100 銅錢,這時柜員轉(zhuǎn)身在墻上尋找你和鐵蛋兒的賬本,此時柜員可能面臨三種情況:
- 理想狀態(tài): 你和鐵蛋兒的賬本都是空閑狀態(tài),一起拿回來,在你的賬本上減 100 銅錢,在鐵蛋兒賬本上加 100 銅錢,柜員轉(zhuǎn)身將賬本掛回到墻上,完成你的業(yè)務(wù)
- 尷尬狀態(tài): 你的賬本在,鐵蛋兒的賬本被其他柜員拿出去給別人轉(zhuǎn)賬,你要等待其他柜員把鐵蛋兒的賬本歸還
- 抓狂狀態(tài): 你的賬本不在,鐵蛋兒的賬本也不在,你只能等待兩個賬本都?xì)w還
放慢柜員的取賬本操作,他一定是先拿到你的賬本,然后再去拿鐵蛋兒的賬本,兩個賬本都拿到(理想狀態(tài))之后才能完成轉(zhuǎn)賬,用程序模型來描述一下這個拿取賬本的過程:
我們繼續(xù)用程序代碼描述一下上面這個模型:
- class Account {
- private int balance;
- // 轉(zhuǎn)賬
- void transfer(Account target, int amt){
- // 鎖定轉(zhuǎn)出賬戶
- synchronized(this) {
- // 鎖定轉(zhuǎn)入賬戶
- synchronized(target) {
- if (this.balance > amt) {
- this.balance -= amt;
- target.balance += amt;
- }
- }
- }
- }
- }
這個解決方案看起來很不錯,解決了文章開頭說的兩個問題,但真是這樣嗎?
我們剛剛說過的理想狀態(tài)是錢莊只有一個柜員(既單線程)。隨著錢莊規(guī)模變大,墻上早已掛了非常多個賬本,錢莊為了應(yīng)對繁忙的業(yè)務(wù),開通了多個窗口,此時有多個柜員(多線程)處理錢莊業(yè)務(wù)。
柜員 1 正在辦理給鐵蛋兒轉(zhuǎn)賬的業(yè)務(wù),但只拿到了你的賬本;柜員 2 正在辦理鐵蛋兒給你轉(zhuǎn)賬的業(yè)務(wù),但只拿到了鐵蛋兒的賬本,此時雙方出現(xiàn)了尷尬狀態(tài),兩位柜員都在等待對方歸還賬本為當(dāng)前客戶辦理轉(zhuǎn)賬業(yè)務(wù)。
現(xiàn)實中柜員會溝通,喊出一嗓子 老鐵,鐵蛋兒的賬本先給我用一下,用完還給你,但程序卻沒這么智能,synchronized 內(nèi)置鎖非常執(zhí)著,它會告訴你「死等」的道理,最終出現(xiàn)死鎖
Java 有了 synchronized 內(nèi)置鎖,還發(fā)明了顯示鎖 Lock,是不是就為了治一治 synchronized 「死等」的執(zhí)著呢?😏
解決方案
如何解決上面的問題呢?正所謂知己知彼方能百戰(zhàn)不殆,我們要先了解什么情況會發(fā)生死鎖,才能知道如何避免死鎖,很幸運我們可以站在巨人的肩膀上看待問題
Coffman 總結(jié)出了四個條件說明可以發(fā)生死鎖的情形:
Coffman 條件
互斥條件:指進程對所分配到的資源進行排它性使用,即在一段時間內(nèi)某資源只由一個進程占用。如果此時還有其它進程請求資源,則請求者只能等待,直至占有資源的進程用畢釋放。
請求和保持條件:指進程已經(jīng)保持至少一個資源,但又提出了新的資源請求,而該資源已被其它進程占有,此時請求進程阻塞,但又對自己已獲得的其它資源保持不放。
不可剝奪條件:指進程已獲得的資源,在未使用完之前,不能被剝奪,只能在使用完時由自己釋放。
環(huán)路等待條件:指在發(fā)生死鎖時,必然存在一個進程——資源的環(huán)形鏈,即進程集合{P1,P2,···,Pn}中的 P1 正在等待一個 P2 占用的資源;P2 正在等待 P3 占用的資源,……,Pn 正在等待已被 P0 占用的資源。
這幾個條件很好理解,其中「互斥條件」是并發(fā)編程的根基,這個條件沒辦法改變。但其他三個條件都有改變的可能,也就是說破壞另外三個條件就不會出現(xiàn)上面說到的死鎖問題
破壞請求和保持條件
每個柜員都可以取放賬本,很容易出現(xiàn)互相等待的情況。要想破壞請求和保持條件,就要一次性拿到所有資源。
作為程序猿你一定聽過這句話:
任何軟件工程遇到的問題都可以通過增加一個中間層來解決
我們不允許柜員都可以取放賬本,賬本要由單獨的賬本管理員來管理
也就是說賬本管理員拿取賬本是臨界區(qū),如果只拿到其中之一的賬本,那么不會給柜員,而是等待柜員下一次詢問是否兩個賬本都在
- //賬本管理員
- public class AccountBookManager {
- synchronized boolean getAllRequiredAccountBook( Object from, Object to){
- if(拿到所有賬本){
- return true;
- } else{
- return false;
- }
- }
- // 歸還資源
- synchronized void releaseObtainedAccountBook(Object from, Object to){
- 歸還獲取到的賬本
- }
- }
- public class Account {
- //單例的賬本管理員
- private AccountBookManager accountBookManager;
- public void transfer(Account target, int amt){
- // 一次性申請轉(zhuǎn)出賬戶和轉(zhuǎn)入賬戶,直到成功
- while(!accountBookManager.getAllRequiredAccountBook(this, target)){
- return;
- }
- try{
- // 鎖定轉(zhuǎn)出賬戶
- synchronized(this){
- // 鎖定轉(zhuǎn)入賬戶
- synchronized(target){
- if (this.balance > amt){
- this.balance -= amt;
- target.balance += amt;
- }
- }
- }
- } finally {
- accountBookManager.releaseObtainedAccountBook(this, target);
- }
- }
- }
破壞不可剝奪條件
上面已經(jīng)給了你小小的提示,為了解決內(nèi)置鎖的執(zhí)著,Java 顯示鎖支持通知(notify/notifyall)和等待(wait),也就是說該功能可以實現(xiàn)喊一嗓子 老鐵,鐵蛋兒的賬本先給我用一下,用完還給你 的功能,這個后續(xù)將到 Java SDK 相關(guān)內(nèi)容時會做說明
破壞環(huán)路等待條件
破壞環(huán)路等待條件也很簡單,我們只需要將資源序號大小排序獲取就會解決這個問題,將環(huán)路拆除
繼續(xù)用代碼來說明:
- class Account {
- private int id;
- private int balance;
- // 轉(zhuǎn)賬
- void transfer(Account target, int amt){
- Account smaller = this
- Account larger = target;
- // 排序
- if (this.id > target.id) {
- smaller = target;
- larger = this;
- }
- // 鎖定序號小的賬戶
- synchronized(smaller){
- // 鎖定序號大的賬戶
- synchronized(larger){
- if (this.balance > amt){
- this.balance -= amt;
- target.balance += amt;
- }
- }
- }
- }
- }
當(dāng) smaller 被占用時,其他線程就會被阻塞,也就不會存在死鎖了.
附加說明
在實際業(yè)務(wù)中,關(guān)于 Account 都會是數(shù)據(jù)庫對象,我們可以通過事務(wù)或數(shù)據(jù)庫的樂觀鎖來解決的。另外分布式系統(tǒng)中,賬本管理員這個角色的處理也可能會用 redis 分布式鎖來解決.
在處理破壞請求和保持條件時,我們使用的是 while 循環(huán)方式來不斷請求鎖的時候,在實際業(yè)務(wù)中,我們會有 timeout 的設(shè)置,防止無休止的浪費 CPU 使用率
另外大家可以嘗試使用阿里開源工具 Arthas 來查看 CPU 使用率,線程等相關(guān)問題,github 上有明確的說明
總結(jié)
計算機的計算能力遠(yuǎn)遠(yuǎn)超過人類,但是他的智慧還需要有帶提高,當(dāng)看待并發(fā)問題時,我們往往認(rèn)為人類的最基本溝通計算機也可以做到,其實不然,還是那句話,編寫并發(fā)程序,要站在計算機的角度來看待問題
粗粒度鎖我們不提倡,所以會使用細(xì)粒度鎖,但使用細(xì)粒度鎖的時候,我們要嚴(yán)格按照 Coffman 的四大條件來逐條判斷,這樣再應(yīng)用我們這幾個解決方案來解決就好了
靈魂追問
- 破壞請求和保持條件時,處理能力的瓶頸在賬本管理員那里,那你覺得這種處理方式會提高并發(fā)量嗎?
- 破壞請求保持條件的方法和破壞環(huán)路等待的方法,你覺得那種方式更好
- 破壞請求和保持條件時,如果代碼換成下面的樣子會發(fā)生什么?
- public void transfer(Account target, int amt){
- // 一次性申請轉(zhuǎn)出賬戶和轉(zhuǎn)入賬戶,直到成功
- while(accountBookManager.getAllRequiredAccountBook(this, target)){}
- try{
- // 鎖定轉(zhuǎn)出賬戶
- synchronized(this){
- // 鎖定轉(zhuǎn)入賬戶
- synchronized(target){
- if (this.balance > amt){
- this.balance -= amt;
- target.balance += amt;
- }
- }
- }
- } finally {
- accountBookManager.releaseObtainedAccountBook(this, target);
- }
- }
- }