淺談多執行緒

Alpaca發表於2019-01-19

概述

最近遇到了些併發的問題,恰巧也有朋友問我類似的問題,無奈併發基礎知識過弱,只大概瞭解使用一些同步機制和併發工具包類,沒有形成一個完整的知識體系,並不能給出一個良好的解決方案。深知自己就是個弟弟,趁著週末有空,就趕緊把之前買的併發程式設計實戰拿起來,擦擦灰,惡補一下….

併發簡史

在介紹併發前,我們先來簡單的瞭解下計算機的發展歷史。早期的計算機是不包含作業系統的,他們可以使用計算機上所有的資源,計算機從頭到尾也就只執行著一個程式。在這種裸機環境下,編寫和執行程式將變的非常麻煩,並且只執行一個程式對於計算機來說也是一種嚴重的浪費。為了解決這個問題,作業系統閃亮登場,計算機每次都能執行多個程式,每個程式都是一個單獨的程式。作業系統為每一個程式分配各種資源,比如:記憶體、檔案控制程式碼等。如果需要的話,不同的程式之間可以通過通訊機制來交換資料。


作業系統的出現,主要給我們解決了這幾個問題,資源利用率的提高,程式之間的公平性和便利性。

  • 資源利用率

有些情況下,程式必須等待某個外部操作完成才能繼續進行。比如當我們向計算機複製資料的時候,此時只有io在工作,如果在等待複製的時間,計算機可以執行其他程式,無疑大大提高了資源的利用率。

  • 公平性

作業系統常見的一種方式就是通過粗粒度的時間分片來使使用者和程式能共享計算機資源,而不是一個程式從頭執行到尾,然後再啟動下一個程式。想一想,你可以用著自己的個人pc,打著遊戲,聽著歌,和女朋友聊著天,計算機資源會來回切換,只不過因為速度很快,給我們的感覺就像是同時發生一樣,這一切都要歸功於作業系統的調配。

  • 便利性

一般來說,在計算多個任務時,應該編寫多個程式,每個程式在執行一個任務時並在需要時進行通訊,這比只編寫一個程式來計算所有任務更容易實現。


執行緒的出現和程式的出現是一個道理的,只不過一個調配的是一個程式內的資源問題,另一個是調配一臺計算機之間的資源。程式允許存在多個執行緒,並且執行緒之間會共享程式範圍內的資源(記憶體和檔案控制程式碼),但每個執行緒都有自己的程式計數器、棧等,而且同一個程式的多個執行緒可以同時被排程到多個cpu上執行。
執行緒被稱為輕量級程式。在大多數作業系統中,執行緒都是最基本的排程單位。如果沒有統一的協同機制,執行緒將彼此獨立執行,由於同一個程式上的所有執行緒都將共享程式的記憶體空間,它們將訪問相同的變數並在同一個堆上分配物件,這就需要一個更細粒度的資料共享機制,不然將造成不可預測的後果。

執行緒的優勢

  • 執行緒可以充分發揮多處理器的強大能力
  • 避免單個執行緒阻塞而導致整個程式停頓
  • 非同步事件的簡化處理

執行緒的風險

  • 安全性問題
class ThreadSafeTest{
    static int count;

    public static void main(String[] args) throws InterruptedException {
        CountDownLatch countDownLatch = new CountDownLatch(1);

      for (int i=0;i<100;i++){
          new Thread(new Runnable() {
              @Override
              public void run() {
                  try {
                      countDownLatch.await();
                  } catch (InterruptedException e) {
                      e.printStackTrace();
                  }
                  for (int x=0;x<100;x++){
                      count++;
                  }
              }
          }).start();
      }

        countDownLatch.countDown();
        Thread.sleep(5000);
        System.out.println("count:"+count);
    }
    }
    
    輸出結果:count:9635

執行結果:count:9955.我們的期待結果是10000,並且我們多次的執行結果還可能不一樣。這個問題主要在於count++並不是一個原子操作,它可以分為讀取count,count+1和計算結果寫回。如果在缺少同步的情況下,我們無法保證多執行緒情況下結果的正確性.

  • 活躍性問題
    安全性的含義是永遠不會發生錯誤的事情,而活躍性的含義將是正確的事情最終會發生。當某個操作無法繼續執行下去,就會發生活躍性的問題。在穿行程式中,無意中造成的無限迴圈就是活躍性問題之一。此外分別還有死鎖、飢餓以及活鎖問題。
    死鎖:執行緒A在等待執行緒B釋放其擁有的資源,而執行緒B在等待執行緒A釋放其擁有的資源,這樣僵持不下,那麼執行緒A、B就會永遠等下去。
    飢餓:最常見的飢餓問題就是CPU時鐘週期問題。如果在java程式中存在持有鎖時執行一些無法結束的結構(無限迴圈或者是等待某個資源發生阻塞),那麼很可能將導致飢餓,因為其他需要這個鎖的執行緒將無法得到它。
    活鎖:活鎖不會阻塞執行緒,但也不能繼續執行。假如程式不能正確的執行某個操作,因為事務回滾,並將其放到佇列的頭部。由於這條事務回滾的訊息被放回到佇列頭部,處理器將反覆呼叫,並返回相同的結果。
  • 效能問題
    效能問題和活躍性問題是密切相關的。活躍性意味著某件正確的事情最終會發生,但是我們一般更希望正確的事情儘快的發生。效能問題包括多個方面:服務時間過長、響應不靈敏、吞吐率過低、資源消耗過高、和可伸縮性較差等。在多執行緒程式中,,還存在由於使用多執行緒而引入的其他問題。在多執行緒程式中,當執行緒排程器臨時掛起活躍執行緒並轉而執行另一個執行緒時,就會頻繁的出現上下文切換操作,這種操作將帶來極大的開銷(儲存和恢復執行上下文,丟失區域性性,並且CPU時鐘週期將更多地花費線上程排程上而不是執行緒執行上)。並且,當多個執行緒共享資料時,必須使用同步機制,而這些機制往往會抑制某些編譯器優化,使記憶體緩衝區的資料無效,以及增加共享記憶體匯流排的同步流量。

執行緒安全性

  • 什麼是執行緒安全性?

當多個執行緒訪問某個類時,不管執行時環境採用何種排程方式或者這些執行緒將如何交替執行,並且在主調程式碼中不需要任何額外的同步或協同,這個類都能表現出正確的行為,那麼就稱這個類是執行緒安全的。

從ThreadSafeTest例子我們可以清楚執行緒安全性可能是非常複雜的,再沒有充足同步的情況下,多個執行緒中的操作執行順序是不可預測的,可能會發生奇怪的結果。

  • 無狀態物件一定是執行緒安全的**
    相信大家都對servlet有過了解,它是一個框架,其作用大概就是接收請求,處理引數,分發請求和返回結果。servlet是執行緒安全的,因為它是無狀態的。我們來自定義個servlet:
public class NoStateCalculate implements Servlet {

    @Override
    public void service(ServletRequest req, ServletResponse res) throws ServletException, IOException {
        //解析資料樣本,計算結果calculate
        BigInteger calculate = calculate(req);
        Integer data= getData(calculate);
        res.getOutputStream().print(data);
    }
}    

這個servlet從請求中提取引數並計算結果calculate,然後去資料庫中查詢對應的資料data,最終將其寫入到輸出中。它是一個無狀態的類,它不包含任何域,也不包含其他任何域的引用,所有的臨時狀態都存在於執行緒棧上的區域性變數表中,並且只能由正在執行的執行緒訪問,執行緒之間不會相互影響,因此可以說執行緒之間沒有共享狀態。由於多執行緒訪問無狀態物件的行為不會影響到其他執行緒中操作的正確性,因此無狀態物件一定是執行緒安全的。

  • 原子性

ThreadSafeTest例子並不是一個執行緒安全的例子,原因是將有100個執行緒同時呼叫count++,而count++又不是一個原子性的操作,其結果將可能是不正確的。

競態條件:當某個計算的正確性取決於多個執行緒的交替執行時序時,那麼就會發生競態條件(正確的結果依賴於運氣)。
複合操作:count++就是一組複合操作,要避免競態條件問題,就必須將操作原子化。

讓我們對ThreadSafeTest進行改造,使用jdk提供的原子變數類AtomicInteger:

public class ThreadSafeTest {
    static AtomicInteger count =new AtomicInteger(0);

    public static void main(String[] args) throws InterruptedException {
        final CountDownLatch countDownLatch = new CountDownLatch(1);

        for (int i=0;i<100;i++){
            new Thread(new Runnable() {
                @Override
                public void run() {
                    try {
                        countDownLatch.await();
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    }
                    for (int x=0;x<100;x++){
                        count.incrementAndGet();
                    }
                }
            }).start();
        }

        countDownLatch.countDown();
        Thread.sleep(5000);
        System.out.println("count:"+count);
    }

}

通過AtomicInteger可以將單個整數變數的操作原子化,使其變成執行緒安全的。當共享狀態變數多於一個時,這種機制就不能解決問題了,應該通過鎖機制保證操作的原子性。

  • 加鎖機制

內建鎖:java提供了synchronized內建鎖來支援原子性。它可以修飾在方法上,程式碼塊上,其中同步程式碼塊和普通方法的鎖就是方法呼叫所在的物件,而靜態方法的synchronized則以當前的Class物件作為鎖。執行緒在進入同步程式碼之前會自動獲得鎖,退出同步程式碼塊時自動釋放鎖。synchronized同時也是一種互斥鎖,最多隻有一個執行緒持有這種鎖,所以它可以保證同步程式碼操作的原子性。
重入鎖:當某個執行緒請求一個由其他執行緒持有的鎖時,發出的請求就會阻塞。然而內建鎖是可重入的,因此如果某個執行緒試圖獲得一個已經由他自己的持有的鎖,那麼這個請求就會成功。”重入”意味著獲取鎖的操作的粒度是執行緒,重入的一種實現方式就是為每個鎖關聯一個獲取計數值和一個所有者執行緒。當數值為0時就代表鎖沒有被任何執行緒獲得,當一個執行緒獲得鎖時,計數器會加1,當同一個執行緒再次獲得鎖時,將會在加1,以此來實現鎖的重入功能。
用鎖來保護狀態:

  • 活躍性和效能

java的語法糖提供了一個內建鎖synchronized,它可以很好地保證同步程式碼塊只有一個執行緒執行。但是如果synchronized使用的不當,將會帶來嚴重的活躍性和效能問題。其對應的優化技術有很多,避免死鎖,減小鎖粒度,鎖分段等等都可有效的解決活躍性問題和效能問題(留到以後再介紹)。

相關文章