天天看點

并發理論基礎:并發問題産生的三大根源

并發問題變幻莫測,一談到并發就顯得非常高深,一般的程式員對于并發問題也是頭疼不已,但是随着網絡互聯越來越普遍,大規模使用者通路網站程式也越來越頻繁,并發問題又無法避免。

在我們解決并發問題前首先要了解産生并發問題的根源是什麼,所有并發處理的工具隻是針對這些根源問題的其中一種解決方案,如果隻去了解解決方案而不了解問題的根源是什麼,那麼我們就很難正确的定位問題并對症下藥。是以要寫好并發程式我們首先就要深入了解并發問題産生根源是什麼?

起因:如何最大化的利用CPU

CPU運算速度和IO速度的不平衡一直是計算機優化的一個課題,我們都知道CPU運算速度要以百倍千倍程度快于IO的速度,而在進行任務的執行的時候往往都會需要進行資料的IO,正因為這種速度上的差異,是以當CPU和IO一起協作的時候就産生問題了,CPU執行速度非常快,一個任務執行時候大部分時間都是在等待IO工作完成,在等待IO的過程中CPU是無法進行其它工作的,是以這樣就使得CPU的資源根本無法合理的運用起來。

并發理論基礎:并發問題産生的三大根源

CPU就相當于我們計算機的大腦,如何把CPU資源合理的利用起來就直接關系到我們計算機的效率和性能,是以為了這個課題計算機分别從緩存、任務切換、指令排序優化這幾個方向進行了優化 。

一、程序和線程的産生

在最原始的系統裡計算機記憶體中隻能允許運作一個程式,這個時候的CPU的能力完全是過剩的,因為CPU在接收到一個任務之後絕大部分時間都是處在IO等待中,CPU根本就利用不起來,是以這個時候就需要一種同時運作多個程式的方法,這樣的話當CPU執行一個任務IO等待的時候可以切換到另外一個任務上去執行指令,不必在IO上浪費時間,那麼CPU就能很大程度的利用起來,是以基于這種思路就産生了程序和線程。

有了程序後,一個記憶體可以劃分出不同的記憶體區域分别由多個程序管理,當一個程序IO阻塞的時候可以切換到另外一個程序執行指令,為了合理公平的把CPU配置設定到各個程序,CPU把自己的時間分為若幹個機關的片段,每在一個程序上執行完一個機關的時間就切換到另外一個程序上去執行指令,這就是CPU的時間片概念。有了程序後我們的電腦就可以同時運作多個程式了,我們可以一邊看着電影一邊聊天,在CPU的使用率又進一步提升了CPU的使用率。

因為程序做任務切換需要切換記憶體映射位址,而一個程序建立的所有線程,都是共享一個記憶體空間的,是以線程做任務切換成本就很低了,現代的作業系統都基于更輕量的線程來排程,現在我們提到的“任務切換”都是指“線程切換”。

并發理論基礎:并發問題産生的三大根源

并發問題根源之一:CPU切換線程執導緻的原子性問題

首先我們先了解什麼叫原子性,原子性就指是把一個操作或者多個操作視為一個整體,在執行的過程不能被中斷的特性叫原子性。

因為IO、記憶體、CPU緩存他們的操作速度有着巨大的差距,假如CPU需要把CPU緩存裡的一個變量寫入到磁盤裡面,CPU可以馬上發出一條對應的指令,但是指令發出後的很長時間CPU都在等待IO的結束,而在這個等待的過程中CPU是空閑的。

是以為了提升CPU的使用率,作業系統就有了程序和時間片的概念,同一個程序裡的所有線程都共享一個記憶體空間,CPU每執行一個時間段就會切換到另外一個程序處理指令,而這執行的時間長度是是以時間片(比如每個時間片為1毫秒)為機關的,通過這種方式讓CPU切換着不同的程序執行,讓CPU更好的利用起來,同時也讓我們不同的程序可以同時運作,我們可以一邊操作word文檔,一邊用QQ聊天。

後來作業系統又在CPU切換程序執行的基礎上做了進一步的優化,以更細的次元“線程”來切換任務執行,更加提高了CPU的使用率。但正是這種CPU可以在不同線程中切換執行的方式會使得我們程式執行的過程中産生原行性問題。

比如說我們以一個變量指派為例:

語句1:Int number=0;
語句2:number=number+1;      

在執行語句2的時候,我們的直覺number=number+1 是一個不可分割的整體,但是實際CPU操作過程中并非如此,我們的編譯器會把number=number+1 拆分成多個指令交給CPU執行。

number=number+1的指令可能如下:

  • 指令1:CPU把number從記憶體拷貝到CPU緩存。
  • 指令2:把number進行+1的操作。
  • 指令3:把number回寫到記憶體。

在這個時候如果有多線程同時去操作number變量,就很有可能出現問題,因為CPU會在執行上面任何一個指令的時候切換線程執行指令,這個時候就可能出現執行結果與我們預期結果不符合的情況。

比如如果現在有兩個線程都在執行number=number+1,結果CPU執行流程可能會如下:

并發理論基礎:并發問題産生的三大根源

執行細節:

1、CPU先執行線程A的執行,把number=0拷貝到CUP寄存器。

2、然後CPU切換到線程B執行指令。

3、線程B 把number=0拷貝到CUP寄存器。

4、線程B 執行number=number+1 操作得到number=1。

5、線程B把number執行結果回寫到緩存裡面。

6、然後CPU切換到線程A執行指令。

7、線程A執行number=number+1 操作得到numbe=1。

8、線程A把number執行結果回寫到緩存裡面。

9、最後記憶體裡面number的值為1。

二、高速緩存的産生

為了減少CPU等待IO的時間,讓CPU有更多的時間是花在運算上,最簡單的思路就是減少IO等待的時間,基于這個思路是以就有了高速緩存增加了高速緩存(L1,L2,L3,主存)。

并發理論基礎:并發問題産生的三大根源

在計算機系統中,CPU高速緩存是用于減少處理器通路記憶體所需的時間,其容量遠小于記憶體,但其通路速度卻是記憶體IO的幾十上百倍。當處理器發出記憶體通路請求時,會先檢視高速緩存内是否有請求資料。如果存在(命中),則不需要通路記憶體直接傳回該資料;如果不存在(失效),則要先把記憶體中的相應資料載入緩存,再将其傳回處理器。

并發理論基礎:并發問題産生的三大根源

并發問題根源之二:緩存導緻的可見性問題

在有了高速緩存之後,CPU的執行操作資料的過程會是這樣的,CPU首先會從記憶體把資料拷貝到CPU緩存區。

然後CPU再對緩存裡面的資料進行更新等操作,最後CPU把緩存區裡面的資料更新到記憶體。

磁盤、記憶體、CPU緩存會按如下形式協作。

并發理論基礎:并發問題産生的三大根源

緩存導緻的可見性問題就是指我們在操作CPU緩存過程中,由于多個CPU緩存之間獨立不可見的特性,導緻共享變量的操作結果無法預期。在單核CPU時代,因為隻有一個核心控制器,是以隻會有一個CPU緩存區,這時各個線程通路的CPU緩存也都是同一個,在這種情況一個線程把共享變量更新到CPU緩存後另外一個線程是可以馬上看見的,因為他們操作的是同一個緩存,是以他們操作後的結果不存在可見性問題。

并發理論基礎:并發問題産生的三大根源

而随着CPU的發展,CPU逐漸發展成了多核,CPU可以同時使用多個核心控制器執行線程任務,當然CPU處理同時處理線程任務的速度也越來越快了,但随之也産生了一個問題,多核CPU每個核心控制器工作的時候都會有自己獨立的CPU緩存,每個核心控制器都執行任務的時候都是操作的自己的CPU緩存,CPU1與CPU2它們之間的緩存是互相不可見的。

這種情況下多個線程操作共享變量就因為緩存不可見而帶來問題,多線程的情況下線程并不一定是在同一個CUP上執行,它們如果同時操作一個共享變量,但因為在不同的CPU執行是以他們隻能檢視和更新自己CPU緩存裡的變量值,線程各自的執行結果對于别的線程來說是不可見的,是以在并發的情況下會因為這種緩存不可見的情況會導緻問題出現。

并發理論基礎:并發問題産生的三大根源

比如下面的程式:

兩個線程同時調用addNumber() 方法對number屬性進行+1 ,循環10W次,等兩個線程執行結束後,我們的預期結果number的值應該是20000,可是我們在多核CPU的環境下執行結果并非我們預期的值。

public class TestCase {
 
  private  int number=0;
 
  public void addNumber(){
  for (int i=0;i<100000;i++){
  number=number+1;
         }
 
     }
 
  public static void main(String[] args) throws Exception {
         TestCase testCase=new TestCase();
          Thread threadA=new Thread(new Runnable() {
  @Override
  public void run() {
  testCase.addNumber();
              }
          });
 
         Thread threadB=new Thread(new Runnable() {
  @Override
  public void run() {
  testCase.addNumber();
             }
         });
         threadA.start();
         threadB.start();
         threadA.join();
         threadB.join();
        System.out.println("number="+testCase.number);
     }
 }
      

列印結果:

并發理論基礎:并發問題産生的三大根源

三、指令優化(重排序)

程序和線程本質上是增加并行的任務數量來提升CPU的使用率,緩存是通過把IO時間減少來提升CPU的使用率,而指令順序優化的初衷的初衷就是想通過調整CPU指令的執行順序和異步化的操作來提升CPU執行指令任務的效率。

指令順序優化可能發生在編譯、CPU指令執行、緩存優化幾個階,其優化原則就是隻要能保證重排序後不影響單線程的運作結果,那麼就允許指令重排序的發生。其重排序的大體邏輯就是優先把CPU比較耗時的指令放到最先執行,然後在這些指令執行的空餘時間來執行其他指令,就像我們做菜的時候會把熟的最慢的菜最先開始煮,然後在這個菜熟的時間段去做其它的菜,通過這種方式減少CPU的等待,更好的利用CPU的資源。

并發問題根源之三:指令優化導緻的重排序問題

下面的程式代碼如果init()方法的代碼經過了指令重排序後,兩個方法在兩個不同的線程裡面調用就可能出現問題。

private static int value;
     private static boolean flag;
 
     public static  void  init(){
         value=8;     //語句1
         flag=true;  //語句2
     }
 
     public static void getValue(){
         if(flag){
             System.out.println(value);
         }
     }      

根據上面代碼,如果程式代碼運作都是按順序的,那麼getValue() 中列印的value值必定是等于8的,不過如果init()方法經過了指令重排序,那麼結果就不一定了。根據重排序原則,init()方法進行指令重排序重排序後并不會影響其運作結果,因為語句1和語句2之間沒有依賴關系。 是以進行重排序後代碼執行順序可能如下。

flag=true;  //語句2  
  value=8;     //語句1