簡單的並發編程中犯2的一個小例子--CAS使用時一定要考慮下是否有必要做輪詢
並發編程我自己寫過不少文章,不過我由於其相對需要理解更多的東西,我自己寫代碼也有時長犯2的時候,對於這些犯2的問題,我們隻能將它作為自己寶貴的經曆和財富,本文是很簡單Java並發方麵的小文章,為啥?因為是一個犯2的例子,這裏給大家做個簡單分享。
先簡單描述下場景:
在一個app中,我需要為訪問者提供某種信息的存儲,由於架構上已經確定的方式,所以可以確保每一個app上存儲的用戶不會太多,於是就放在了內存中,而不是緩存。
這些信息需要定期清理掉,就像會話一樣,每個用戶都會有一個唯一的key標識符,用一個ConcurrentHashMap存放,長時間不使用就需要刪除掉了。
但是它與會話不同的是,在清空的同時會清空掉許多用戶級別的網絡通信對象,例如Socket或數據庫連接對象等。因此它的清理將與傳統的清理方法有一些區別,為何?
因為當清理程序發現需要清理該對象的時候,這個對象正好被一個有效請求所使用,在清理對象的時候,需要將內部的Socket等資源關閉,就會導致問題。
因此我不得不在這個用戶級別的對象上去做一個狀態:
簡單來說有一個FREE、USE、DELETE三種狀態,FREE是可以修改為任意狀態的,USE是使用狀態的,DELETE是刪除狀態的。USE狀態的不能被刪除,DELETE狀態的不能再被使用。
簡單邏輯是:
1、如果通過ConcurrentHashMap獲取到相應的對象後,需要判定狀態不能是DELETE,再嚐試在對象上修改狀態為USE才能使用,如果修改失敗則不能被使用,當然是用後會更新下最新的時間,這個時間將用volatile來保證可見性,以便於最近不會被清理掉,使用完後會講對象的狀態重新修改為FREE。偽代碼如下所示:
int old = status.get(); if(old != DELETE && status.compareAndSet(old , USED)) { return this.userXXXDO; } return null;
2、在刪除操作前也必須先獲通過ConcurrentHashMap取到對象,需要判定狀態不能是USE,然後嚐試將狀態修改為DELELE才能真正開始做刪除操作。代碼與上麵類似。
這個邏輯似乎看似完美,我當時暈頭轉向的也認為CAS就可以簡單搞定這個問題,做幾個狀態嘛,簡單事情,嗬嗬。
結果以外發生了,外部程序偶然情況下獲取不到這個對象,但是在獲取不到這個對象的斷點中,我使用表達式再執行一次又能獲取到,這尼瑪是什麼問題發生了呢?
剛開始我也跑偏了,因為外層有一個ConcurrentHashMap,思維凝固在是不是這有並發可見性問題,不過這樣的猜測連我自己都沒有相信,因為我對這個組件的內在的源碼是比較了解的,如果它有問題,就徹底顛覆可見性的問題了。
在不斷加班到半夜的迷煳中,迷迷煳煳地跟蹤代碼,發現裏頭還有一層,就看到點希望,看到了剛才的代碼。咋一看,代碼沒有啥問題,因為這個就是狀態轉換,而且這個是在一個用戶下的操作,一個用戶並發的概率本來就很低,而且有CAS來保證原子性,能有什麼問題呢?
後來一個哥們問我可不可以用synchronzied一下子提醒了我,我的第一反應是不到萬不得已不用這個,這個如果放在內部做就是所有的狀態轉換全部要加上,悲觀鎖就不好了,放在外麵更不靠譜,那就是一個全局的ConcurrentHashMap,那用它來控製個毛的並發啊,我就是要把鎖打散。
但是這個提示讓我在迷迷煳煳中醒了一下,我發現可能真的有並發問題,或者說假設一個用戶的客戶端同時發送多個請求上來,此時由於是同一個用戶的請求是同一個,所以KEY肯定是一樣的,緩存用戶對象也應該是一樣的,此時如果兩個請求都運行到代碼:
int old = status.get();
那麼兩個請求在此時獲取到的狀態值就是一樣的,當發生CAS的時候,隻有一個會成功,另一個不成功的就返回null了,代碼看了很久,雖然很簡單,但是隻可能這裏有問題。
考慮實際場景,還真的可能有一個客戶端的瀏覽器同時發起多個請求的情況,因為客戶端並不是簡單的頁麵跳轉(簡單頁麵用戶手點擊再快也有時差),而是與服務器端很多ajax交互,當一個選項發生變化的時候,確實有可能同時發起多個ajax請求。
不過怎麼改呢?用syncrhonezized,顯示我不是那麼容易放棄自己的人,哈哈,迷迷煳煳中終於才想起來,CAS也需要考慮下嚐試,確實是這樣,那麼就改為循環來做。
但是一旦改為循環大夥第一個擔心的問題就是能否退出循環,Java的裏麵有許多死循環方式,但是這種代碼不退出就是一個大問題,但是限製次數的話,多少為好?這不好說,因為樂觀鎖在這個階段是不好講清楚具體的次數的,或許在許多人眼中這算是小問題,但是我認為在這些問題上是關鍵的關鍵,如果不注重就會出大問題。
後來考慮來考慮去發現這樣寫沒問題:
int old = status.get();
while(old != DELETED) {
if(old == USED && status.compareAndSet(old , USED)) {
return this.loginDO;
}
old = use.get();
}
return null;
而對於刪除就沒有必要循環了,刪除操作發現狀態是USE就不能刪除,狀態為FREE在做CAS的時候如果CAS征用失敗也沒有必要再去征用,為何?假如有兩個線程在征用DELETE,另一個成功了就OK了,如果有一個USE在與之征用,它本身就沒有再征用的必要。
到這裏問題基本解決,但是這個程序是不是就沒有問題了呢?
未必然也,因為最初我們寫代碼的時候沒有考慮到多個請求同時發起的過程,所以也自然不會考慮到多個請求將狀態改為FREE的過程,假如有2個請求,其中1個請求釋放掉了將狀態修改為FREE,而另一個還在使用中,此時有線程想將它DELETE掉,發現FREE狀態,是可以刪除的,於是將相應的Socket關閉掉,就出大事了。
如果要完全解決這種問題,還需要一個條件變量來使用和釋放的次數,使用時加1,釋放時候減掉1,這就有點像Lock機製了,隻是可控性上更強,但是對於代碼複雜性更大,你自己也需要承擔更大的責任。
如果在應用中,出現這種問題的概率極低,那麼可以暫時用狀態也可以,或者為了簡單處理也可以直接換成Lock。為何說概率低呢?因為這種數據的清理理論上不會到秒級別,例如10分鍾,一個請求來的時候,會刷新最近的操作時間,後台操作即使一長一短,隻要偏差不是10分鍾以上,在理論上就不會有問題。
大家可能一想,一般要求係統響應3s,不會有那種情況發生。真的是這樣嘛?我認為未必,所謂3s隻是常規係統,有的係統就未必了,例如WEB版本的數據庫軟件,通過UI上輸入SQL獲取結果,WEB版本的安裝係統給上千的服務器安裝相應的軟件等等,這些操作的響應都是可以很長的,這個值是有可能超過我們的清理時間的,所以一切皆有可能,當你真正遇到的時候,希望這些小思路能幫助到你。
最後更新:2017-04-03 12:54:47