1. 程式人生 > >【併發程式設計】併發程式設計中你需要知道的基礎概念

【併發程式設計】併發程式設計中你需要知道的基礎概念

本部落格系列是學習併發程式設計過程中的記錄總結。由於文章比較多,寫的時間也比較散,所以我整理了個目錄貼(傳送門),方便查閱。

併發程式設計系列部落格傳送門


多執行緒是Java程式設計中一塊非常重要的內容,其中涉及到很多概念。這些概念我們平時經常掛在嘴上,但是真的要讓你介紹下這些概念,你可能還真的講不清楚。這篇部落格就總結下多執行緒程式設計中經常用到的概念,理解這些概念能幫助我們更好地掌握多執行緒程式設計。

程序(Process)與執行緒(Thread)

程序和執行緒是最常提到的概念了。在linux中,執行緒與程序最大的區別就是是否共享同一塊地址空間,而且共享同一塊地址空間的那一組執行緒將顯現相同的PID號。下面介紹下兩者的概念:

  • 程序是系統資源分配的最小單元,可以簡單地理解為系統中執行的一個程式就是一個程序。
  • 執行緒是CPU排程的最小單元,是程序中的一個個執行流程。
  • 一個程序至少包含一個執行緒,可以包含多個執行緒,這些執行緒共享這個程序的資源。同時每個執行緒都擁有獨立的執行棧和程式計數器,執行緒切換開銷小。
  • 多程序指的是作業系統同時執行多個程式,如當前作業系統中同時執行著QQ、IE、微信等程式。
  • 多執行緒指的是同一程序中同時執行多個執行緒,如迅雷執行時,可以開啟多個執行緒,同時進行多個檔案的下載。

談到執行緒和程序,又勢必會涉及到執行緒號和程序號的概念。下面列舉了各個ID的概念。

  • pid: 程序ID。
  • tgid: 執行緒組ID,也就是執行緒組leader的程序ID,等於pid。
  • lwp: 執行緒ID。在使用者態的命令(比如ps)中常用的顯示方式。
  • tid: 執行緒ID,等於lwp。tid在系統提供的介面函式中更常用,比如syscall(SYS_gettid)和syscall(__NR_gettid)。

並行(Parallel)、併發(Concurrent)

  • 併發:是指多個執行緒任務在同一個CPU上快速地輪換執行,由於切換的速度非常快,給人的感覺就是這些執行緒任務是在同時進行的,但其實併發只是一種邏輯上的同時進行;
  • 並行:是指多個執行緒任務在不同CPU上同時進行,是真正意義上的同時執行。

下面貼上一張圖來解釋下這兩個概念:

上圖中的咖啡就可以看成是CPU,上面的只有一個咖啡機,相當於只有一個CPU。想喝咖啡的人只有等前面的人制作完咖啡才能製作自己的開發,也就是同一時間只能有一個人在製作咖啡,這是一種併發模式。下面的圖中有兩個咖啡機,相當於有兩個CPU,同一時刻可以有兩個人同時製作咖啡,是一種並行模式。

我們發現並行程式設計中,很重要的一個特點是系統具有多核CPU。要是系統是單核的,也就談不上什麼並行程式設計了。

執行緒安全

這個概念可能是在多執行緒程式設計中提及最多的一個概念了。在面試過程中,我試著問過幾個面試者,但是幾乎沒人能將這個概念解釋的很好的。

關於這個概念,我覺得好多人都有一個誤區,包括我自己一開始也是這樣的。我一開始認為執行緒安全講的是某個共享變數執行緒安全,其實我們所說的執行緒安全是指某段程式碼或者是某個方法是執行緒安全的。執行緒安全的準確定義應該是這樣的:

如果執行緒的隨機排程順序不影響某段程式碼的最後執行結果,那麼我們認為這段程式碼是執行緒安全的。

為了保證程式碼的執行緒安全,Java中推出了很多好用的工具類或者關鍵字,比如volatile、synchronized、ThreadLocal、鎖、併發集合、執行緒池和CAS機制等。這些工具並不是在每個場景下都能滿足我們多執行緒程式設計的需求,並不是在每個場景下都有很高的效率,需要我們程式設計師根據具體的場景來選擇最適合的技術,這也許就是我們程式設計師存在的價值所在。(我一直覺得如果有一個技術能很好的解決大多數場景下的問題,那麼這個領域肯定是可以做成機器自動化的。那麼對於這個領域就不太需要有多少人蔘與了。)

死鎖

執行緒1佔用了鎖A,等待鎖B,執行緒2佔用了鎖B,等待鎖A,這種情況下就造成了死鎖。在死鎖狀態下,相關的程式碼將不能再提供服務。

private void deadLock() {
      Thread t1 = new Thread(new Runnable() {
        @Override
        public void run() {
            synchronized (lock1) {
              try {
                Thread.currentThread().sleep(2000);
              } catch (InterruptedException e) {
                e.printStackTrace();
              }
              synchronized (lock2) {
                System.out.println("1");
              }
            }
        }
      });
      Thread t2 = new Thread(new Runnable() {
        @Override
        public void run() {
            synchronized (lock2) {
              synchronized (lock1) {
                System.out.println("2");
              }
            }
        }
      });
      t1.start();
      t2.start();
    }

這段程式碼只是演示死鎖的場景,在現實中你可能不會寫出這樣的程式碼。但是,在一些更為複雜的場景中,你可能會遇到這樣的問題,比如t1拿到鎖之後,因為一些異常情況沒有釋放鎖(死迴圈)。又或者是t1拿到一個數據庫鎖,釋放鎖的時候丟擲了異常,沒釋放掉。

如果你懷疑程式碼中有執行緒出現了死鎖,你可以dump執行緒,然後檢視執行緒狀態有沒有Blocked的執行緒(java.lang.Thread.State: BLOCKED)


"Thread-2" prio=5 tid=7fc0458d1000 nid=0x116c1c000 waiting for monitor entry [116c1b000] 
    java.lang.Thread.State: BLOCKED (on object monitor) 
     at com.ifeve.book.forkjoin.DeadLockDemo$2.run(DeadLockDemo.java:42) 
     - waiting to lock <7fb2f3ec0> (a java.lang.String) 
     - locked <7fb2f3ef8> (a java.lang.String) 
     at java.lang.Thread.run(Thread.java:695)
     
     
"Thread-1" prio=5 tid=7fc0430f6800 nid=0x116b19000 waiting for monitor entry [116b18000] 
    java.lang.Thread.State: BLOCKED (on object monitor) 
     at com.ifeve.book.forkjoin.DeadLockDemo$1.run(DeadLockDemo.java:31) 
     - waiting to lock <7fb2f3ef8> (a java.lang.String) 
     - locked <7fb2f3ec0> (a java.lang.String) 
     at java.lang.Thread.run(Thread.java:695)

避免死鎖的幾個方式:

  • 儘量不要一個執行緒同時佔用多個鎖;
  • 避免一個執行緒在鎖內同時佔用多個資源,儘量保證每個鎖只佔用一個資源。
  • 嘗試使用定時鎖,使用lock.tryLock(timeout)來替代使用內部鎖機制。
  • 對於資料庫鎖,加鎖和解鎖必須在一個數據庫連線裡,否則會出現解鎖失敗的情況。

飢餓

飢餓是指某一個或者多個執行緒因為種種原因無法獲得所需要的資源,導致一直無法執行。比如它的執行緒優先順序可能太低,而高優先順序的執行緒不斷搶佔它需要的資源,導致低優先順序執行緒無法工作。

在自然界中,母鳥給雛鳥餵食時很容易出現這種情況:由於雛鳥很多,食物有限,雛鳥之間的食物競爭可能非常厲害,經常搶不到食物的雛鳥有可能會被餓死。執行緒的飢餓非常類似這種情況。

此外,某一個執行緒一直佔著關鍵資源不放,導致其他需要這個資源的執行緒無法正常執行,這種情況也是飢餓的一種。與死鎖相比,飢餓還是有可能在未來一段時間內解決的(比如,高優先順序的執行緒已經完成任務,不再瘋狂執行)。

活鎖

活鎖是一種非常有趣的情況。不知道大家是否遇到過這麼一種場景,當你要坐電梯下樓時,電梯到了,門開了,這時你正準備出去。但很不巧的是,門外一個人擋著你的去路,他想進來。於是,你很禮貌地靠左走,避讓對方。同時,對方也非常禮貌地靠右走,希望避讓你。結果,你們倆就又撞上了。於是乎,你們都意識到了問題,希望儘快避讓對方,你立即向右邊走,同時,他立即向左邊走。結果,又撞上了!不過介於人類的智慧,我相信這個動作重複兩三次後,你應該可以順利解決這個問題。因為這個時候,大家都會本能地對視,進行交流,保證這種情況不再發生。

但如果這種情況發生在兩個執行緒之間可能就不會那麼幸運了。如果執行緒的智力不夠,且都秉承著“謙讓”的原則,主動將資源釋放給他人使用,那麼就會導致資源不斷地在兩個執行緒間跳動,而沒有一個執行緒可以同時拿到所有資源正常執行。這種情況就是活鎖。

同步(Synchronous)和非同步(Asynchronous)

這邊討論的同步和非同步指的是同步方法和非同步方法。

同步方法是指呼叫這個方法後,呼叫方必須等到這個方法執行完成之後才能繼續往下執行。
非同步方法是指呼叫這個方法後會立馬返回,呼叫方能立馬往下繼續執行。被呼叫的非同步方法其實是由另外的執行緒進行執行的,如果這個非同步方法有返回值的話可以通過某種通知的方式告知呼叫方。

實現非同步方法的方式:

  • 回撥函式模式:一個方法被呼叫後立馬返回,呼叫結果通過回撥函式返回給呼叫方;
  • MQ(釋出/訂閱):請求方將請求傳送到MQ,請求處理方監聽MQ處理這些請求,並將請求處理結果也返回給某個MQ,呼叫方監聽這個Queue獲取處理結果;
  • 多執行緒處理模式:系統建立其他執行緒處理呼叫請求,比如Spring中的@Async註解標註的方法就是這種方法。

臨界區

涉及讀寫共享資源的程式碼片段叫“臨界區”。

比如下面程式碼中,1處和2處就是一個程式碼臨界區。

private static class BankAccount{
        String accountName;
        double balance;

        public BankAccount(String accountName,double balance){
            this.accountName = accountName;
            this.balance = balance;
        }

        public synchronized   double deposit(double amount){
            balance = balance + amount; //1
            return balance;
        }

        public synchronized  double  withdraw(double amount){
            balance = balance - amount; //2
            return balance;
        }

    }

多執行緒程式設計的優勢和挑戰

使用併發程式設計的目的是讓程式執行的更快(更大限度的使用CPU資源,讓程式執行更快),但是在進行併發程式設計的過程也會遇到一些挑戰。

PS:多執行緒併發程式設計可以讓我們最大限度的使用系統的CPU資源,以達到讓程式執行更快的目的(不是所有情況下多執行緒都更快)。但是一個硬幣具有兩面性,引入多執行緒程式設計會給我們帶來其他的問題,比如說執行緒的上下文切換問題、共享變數的執行緒安全問題、執行緒間通訊問題、執行緒死鎖問題和硬體資源對多執行緒的影響等問題。其實研究多執行緒併發程式設計就是在研究這對矛盾體,怎麼在享受多執行緒併發程式設計給我們帶來便利的同時又能避開多執行緒帶來的坑。JDK中給我們提供很多多執行緒相關的類

參考

  • http://blog.chinaunix.net/uid-31404751-id-5753869.html
  • https://blog.csdn.net/hanchao5272/article/details/79513153
  • 《實戰Java高併發程式設計》