併發程式設計bug的源頭

2022-01-22 16:05:14 字數 3771 閱讀 6672

--極客時間學習筆記

在計算機的發展歷程中,cpu、記憶體、i/o三者之間的效能差異是其一直存在的乙個核心矛盾,三者的速度由快到慢依次為:cpu > 記憶體 > i/o

為了解決木桶效應的短板,平衡三者之間的速度差異,計算機體系結構、作業系統、編譯程式做出了如下貢獻:

cpu增加了快取,以均衡與記憶體的速度差異(很多時候的木桶是沒有i/o的,即程式的執行不涉及i/o,因此解決cpu與記憶體之間的效能差異是有意義的)

作業系統增加了程序、執行緒,以分時復用cpu,進而均衡cpu與i/o裝置的速度差異

編譯程式優化指令執行順序,使得快取能夠得到更加合理地利用

解決一些問題的同時必然會帶來新的問題,這些問題就是執行緒安全問題:

乙個執行緒對共享變數的修改,另乙個執行緒能夠立即看見,我們稱為可見性

// 啟動兩個執行緒

th1.start();

th2.start();

// 等待兩個執行緒執行結束

th1.join();

th2.join();

return count;

}}以上程式在單執行緒中的執行結果應該是20000,但是在這裡結果是在10000-20000之間的某個值。原因就是兩個執行緒的計算都是對各自快取中的值進行計算的。

以上程式如果將迴圈次數改為1億,可能最終的結果會更加接近1億。而迴圈次數為10000的時候,可能會更加接近20000,原因是兩個執行緒不是同時啟動的,有乙個時間差。

由於i/o太慢,早期的作業系統發明了多程序,即使在單核cpu上我們也可以一邊聽**,一邊寫bug,這就是多程序的功勞。

作業系統允許某個程序執行一小段時間,每經過乙個「時間片」,作業系統就會重新選擇乙個程序來執行。在乙個時間片內,如果乙個程序進行乙個io操作,這個時候該程序可以把自己標記為「休眠狀態」

支援多程序分時復用在作業系統的發展史上具有里程碑意義,unix就是因為解決了這個問題而名噪天下。

早期的作業系統基於程序來排程cpu,不同的程序間是不共享記憶體空間的,所以程序要做任務切換就要切換記憶體對映位址,而乙個程序建立的所有執行緒,都是共享乙個記憶體空間的(上述的可見性問題針對的是多核處理器中每顆cpu的快取對其他執行緒是不可見的,即執行緒的工作空間),所以執行緒做任務切換成本就很低了。現代的作業系統都基於更輕量級的執行緒來排程,現在我們提到的「任務切換」都是指「執行緒切換」。

執行緒切換帶來的非原子操作引發的執行緒安全問題:

count+=1;
上述**對應的cpu指令:

指令一:變數count從記憶體載入到cpu的暫存器;

指令二:在暫存器執行+1操作;

指令三:將結果寫入記憶體(快取機制導致可能寫入的是cpu快取而不是記憶體,即可見性問題

在上述示意圖中,我們兩個執行緒分別執行了count+=1操作,期望的執行結果應該是2,但實際執行結果卻是1,這就是非原子操作引發的執行緒安全問題。

cpu的指令執行順序有時候會發生改變,即編譯優化,進而引發執行緒安全問題。以單例模式的雙重鎖驗證方式為例:

public class singleton 

public static singleton getinstance() }}

return instance;

}}

case1:假設兩個執行緒呼叫getinstance()方法,兩個執行緒均判斷instance==null,只有乙個執行緒能加鎖成功,完成instance初始化。另乙個執行緒加鎖成功,判斷instance不為null,返回insatnce。但是存在乙個問題,即可見性問題,也就是說instance初始化完成對另乙個執行緒可能是不可見的。

case2:假設乙個執行緒呼叫getinstance()方法,判斷instance==null,加鎖成功,判斷instance==null,初始化步驟如下:1.分配一塊記憶體m;2.在記憶體上初始化singleton物件;3.將m的記憶體位址賦值給instance物件。經過編譯優化後的順候為1,3,2。假設執行完步驟三之後發生了執行緒切換(不會釋放鎖),此時另乙個執行緒呼叫getinstance()方法,發現instance!=null,所以直接返回instance。但此時的instance是尚未初始化完成的,會觸發空指標異常。而導致這一問題的源頭就是有序性問題

解決方案:增加volatile關鍵字(可解決可見性與有序性問題)

課後思考:在32位的機器上(32位和64位表示cpu一次能處理的最大位數)對long型變數進行加減操作是否會引發執行緒安全問題。 

答案:由於32位機器的計算能力導致對long型變數的運算需要分為多個指令執行,存在由於原子性而引發執行緒安全問題的可能。

課後補充:

------可見性問題------

對於可見性那個例子我們先看下定義:

可見性:乙個執行緒對共享變數的修改,另外乙個執行緒能夠立刻看到

併發問題往往都是綜合證,這裡即使是單核cpu,只要出現執行緒切換就會有原子性問題。但老師的目的是為了讓大家明白什麼是可見性

或許我們可以把執行緒對變數的讀可寫都看作時原子操作,也就是cpu對變數的操作中間狀態不可見,這樣就能更加理解什麼是可見性了。

------cpu快取重新整理到記憶體的時機------

cpu將快取寫入記憶體的時機是不確定的。除非你呼叫cpu相關指令強刷

------雙重鎖問題------

如果a執行緒與b執行緒如果同時進入第乙個分支,那麼這個程式就沒有問題

如果a執行緒先獲取鎖並出現指令重排序時,b執行緒未進入第乙個分支,那麼就可能出現空指標問題,這裡說可能出現問題是因為當把記憶體位址賦值給共享變數後,cpu將資料寫回快取的時機是隨機的

------ synchronized------

執行緒在synchronized塊中,發生執行緒切換,鎖是不會釋放的

------指令優化------

除了編譯優化,有一部分可以通過看彙編**來看,但是cpu和直譯器在執行期也會做一部分優化,所以很多時候都是看不到的,也很難重現。

------jmm模型和物理記憶體、快取等關係------

記憶體、cpu快取是物理存在,jvm記憶體是軟體存在的。

關於執行緒的工作記憶體和暫存器、cpu快取的關係 大家可以參考這篇文章

------io操作------

io操作不占用cpu,讀檔案,是裝置驅動幹的事,cpu只管發命令。發完命令,就可以幹別的事情了。

------暫存器切換------

人貴有志,學貴有恆!

singleton

併發程式設計 Bug 的源頭

public class test public static long calc thread th2 newthread 啟動兩個執行緒 th1.start th2.start 等待兩個執行緒執行結束 th1.join th2.join return count 指令 1 首先,需要把變數 co...

併發程式設計學習 併發程式設計的挑戰

死鎖 資源限制的挑戰 併發程式設計的目的是為了讓程式執行的更快,但是並不是啟動更多的執行緒,就能讓程式最大限度的併發執行。在進行併發程式設計時,如果希望通過多執行緒執行任務讓程式執行的更快,會面臨非常多的挑戰,比如上下文切換的問題,死鎖的問題,以及受限於硬體和軟體的資源限制問題 即使是單核處理器也支...

併發程式設計的藝術(一) 併發程式設計的挑戰

含義 cpu通過給每個執行緒分配cpu時間片實現多執行緒執行 當前任務執行乙個時間片後會切換下乙個任務,但切換前會儲存上乙個任務的狀態,從儲存到載入的過程就是一次上下文切換。但執行緒會有建立和上下文切換的開銷,所以多執行緒不一定快。減少上下文切換方法 無鎖併發程式設計 如id按hash演算法取模,不...