1. 程式人生 > >【軟件構造】第十章 線程和分布式系統

【軟件構造】第十章 線程和分布式系統

ack pty 安全策略 tput 共享數據 原則 都是 比較 res

本章關註復雜軟件系統的構造。 本章關註復雜軟件系統的構造。 這裏的“復雜”包括三方面: 這裏的“復雜”包括三方面: (1)多線程序 (2)分布式程序 (3) GUI 程序

Outline

  • 並發編程
    • Shared memory
    • Message passing
  • 進程和線程
  • 線程的創建和啟動,runable
  • 時間分片、交錯執行、競爭條件
  • 線程的休眠、中斷
  • 線程安全的四種策略
    • 約束(Confinement)
    • 不變性
    • 使用線程安全的數據類型
    • 同步與鎖
  • 死鎖
  • 以註釋的形式撰寫線程安全策略

Notes

## 並發編程

【並發(concurrency)】

  • 定義:指的是多線程場景下對共享資源的爭奪運行
  • 並發的應用背景:
    • 網絡上的多臺計算機
    • 一臺計算機上的多個應用
    • 一個CPU上的多核處理器
  • 為什麽要有並發:
    • 摩爾定律失效、“核”變得越來越多
    • 為了充分利用多核和多處理器需要將程序轉化為並行執行
  • 並發編程的兩種模式:
    • 共享內存:在內存中讀寫共享數據
    • 信息傳遞(Message Passing):通過channel交換消息

【共享內存】

  • 共享內存這種方式比較常見,我們經常會設置一個共享變量,然後多個線程去操作同一個共享變量。從而達到線程通訊的目的。
  • 例子:
    • 兩個處理器,共享內存
    • 同一臺機器上的兩個程序,共享文件系統
    • 同一個Java程序內的兩個線程,共享Java對象

技術分享圖片

【信息傳遞】

  • 消息傳遞方式采取的是線程之間的直接通信,不同的線程之間通過顯式的發送消息來達到交互目的
  • 接收方將收到的消息形成隊列逐一處理,消息發送者繼續發送(異步方式)
  • 消息傳遞機制也無法解決競爭條件問題
  • 仍然存在消息傳遞時間上的交錯
  • 例子:
    • 網絡上的兩臺計算機,通過網絡連接通訊
    • 瀏覽器和Web服務器,A請求頁面,B發送頁面數據給A
    • 即時通訊軟件的客戶端和服務器
    • 同一臺計算機上的兩個程序,通過管道連接進行通訊

技術分享圖片

並發模型 通信機制 同步機制
共享內存

線程之間共享程序的公共狀態,線程之間通過寫-讀內存中的公共狀態來隱式進行通信。

同步是顯式進行的。程序員必須顯式指定某個方法或某段代碼需要在線程之間互斥執行。

消息傳遞

線程之間沒有公共狀態,線程之間必須通過明確的發送消息來顯式進行通信。

由於消息的發送必須在消息的接收之前,因此同步是隱式進行的。

## 進程和線程

  • 進程:是執行中一段程序,即一旦程序被載入到內存中並準備執行,它就是一個進程。進程是表示資源分配的的基本概念,又是調度運行的基本單位,是系統中的並發執行的單位。
    • 程序運行時在內存中分配自己獨立的運行空間
    • 進程擁有整臺計算機的資源
    • 多進程之間不共享內存
    • 進程之間通過消息傳遞進行協作
    • 一般來說,進程==程序==應用(但一個應用中可能包含多個進程)
    • OS支持的IPC機制(pipe/socket)支持進程間通信(IPC不僅是本機的多個進程之間, 也可以是不同機器的多個進程之間)
    • JVM通常運行單一進程,但也可以創建新的進程。
  • 線程:它是位於進程中,負責當前進程中的某個具備獨立運行資格的空間。
    • 線程有自己的堆棧和局部變量,但是多個線程共享內存空間
    • 進程=虛擬機;線程=虛擬CPU
    • 程序共享、資源共享,都隸屬於進程
    • 很難獲得線程私有的內存空間
    • 線程需要同步:在改變對象時要保持lock狀態
    • 清理線程是不安全的
  • 進程是負責整個程序的運行,而線程是程序中具體的某個獨立功能的運行。
  • 一個進程中至少應該有一個線程。
  • 主線程可以創建其他的線程。

## 線程的創建和啟動,runable

【方式1:繼承Thread類】

  • 方法:用Thread類實現了Runnable接口,但它其中的run方法什麽都沒做,所以用一個類做Thread的子類,提供它自己實現的run方法。用Thread.start()來開始一個新的線程。
  • 創建:A類 a = new A類();
  • 啟動: a.start();
  • 步驟:
    • 定義一個類A繼承於java.lang.Thread類.
    • 在A類中覆蓋Thread類中的run方法.
    • 我們在run方法中編寫需要執行的操作:run方法裏的代碼,線程執行體.
    • 在main方法(線程)中,創建線程對象,並啟動線程.
  • 栗子:
 1 //1):定義一個類A繼承於java.lang.Thread類.  
 2 class MusicThread extends Thread{  
 3     //2):在A類中覆蓋Thread類中的run方法.  
 4     public void run() {  
 5         //3):在run方法中編寫需要執行的操作  
 6         for(int i = 0; i < 50; i ++){  
 7             System.out.println("播放音樂"+i);  
 8         }  
 9     }  
10 }  
11   
12 public class ExtendsThreadDemo {  
13     public static void main(String[] args) {  
14           
15         for(int j = 0; j < 50; j ++){  
16             System.out.println("運行遊戲"+j);  
17             if(j == 10){  
18                 //4):在main方法(線程)中,創建線程對象,並啟動線程.  
19                 MusicThread music = new MusicThread();  
20                 music.start();  
21             }  
22         }  
23     }  
25 }  

【方式2:實現Runable接口】

  • 創建:Thread t = new Thread(new A());
  • 調用:t.start();
  • 步驟:
    • 定義一個類A實現於java.lang.Runnable接口,註意A類不是線程類.
    • 在A類中覆蓋Runnable接口中的run方法.
    • 我們在run方法中編寫需要執行的操作:run方法裏的,線程執行體.
    • 在main方法(線程)中,創建線程對象,並啟動線程.
 1 //1):定義一個類A實現於java.lang.Runnable接口,註意A類不是線程類.  
 2 class MusicImplements implements Runnable{  
 3     //2):在A類中覆蓋Runnable接口中的run方法.  
 4     public void run() {  
 5         //3):在run方法中編寫需要執行的操作  
 6         for(int i = 0; i < 50; i ++){  
 7             System.out.println("播放音樂"+i);  
 8         }  
 9           
10     }  
11 }  
12   
13 public class ImplementsRunnableDemo {  
14     public static void main(String[] args) {  
15         for(int j = 0; j < 50; j ++){  
16             System.out.println("運行遊戲"+j);  
17             if(j == 10){  
18                 //4):在main方法(線程)中,創建線程對象,並啟動線程  
19                 MusicImplements mi = new MusicImplements();  
20                 Thread t = new Thread(mi);  
21                 t.start();  
22             }  
23         }  
24     }     

  • 實現Runnable接口相比繼承Thread類有如下好處:
    • 避免點繼承的局限,一個類可以繼承多個接口。
    • 適合於資源的共享
  • 創建並運行一個線程所犯的常見錯誤是調用線程的 run()方法而非 start()方法,如下所示:
Thread newThread = new Thread(MyRunnable());
newThread.run();  //should be start();

  起初並不會感覺到有什麽不妥,因為 run()方法的確如你所願的被調用了。但是,事實上,run()方法並非是由剛創建的新線程所執行的,而是被創建新線程的當前線程所執行了。也就是被執行上面兩行代碼的線程所執行的。想要讓創建的新線程執行 run()方法,必須調用新線程的 start 方法。

## 時間分片、交錯執行、競爭條件

【時間分片】

  • 雖然有多線程,但只有一個核,每個時刻只能執行一個線程。
    • 通過時間分片,再多個線程/進程之間共享處理器
  • 即使是多核CPU,進程/線程的數目也往往大於核的數目
  • 通過時間分片,在多個進程/線程之間共享處理器。(時間分片是由OS自動調度的)
  • 當線程數多於處理器數量時,並發性通過時間片來模擬,處理器切換處理不同的線程

【交錯執行】

  顧名思義,就是說在線程運行的過程中,多個線程同時運行相互交錯。而且,由於線程運行一般不是連續的,那麽就會導致線程間的交錯。可以說,所有線程安全問題的本質都是線程交錯的問題。

【競爭條件】

  競爭是發生在線程交錯的基礎上的。當多個線程對同一對象進行讀寫訪問時,就可能會導致競爭的問題。程序中可能出現的一種問題就是,讀寫數據發生了不同步。例如,我要用一個數據,在該數據修改還沒寫回內存中時就讀取出來了,那麽就會導致程序出現問題。

  程序運行時有一種情況,就是程序如果要正確運行,必須保證A線程在B線程之前完成(正確性意味著程序運行滿足其規約)。當發生這種情況時,就可以說A與B發生競爭關系。

  • 計算機運行過程中,並發、無序、大量的進程在使用有限、獨占、不可搶占的資源,由於進程無限,資源有限,產生矛盾,這種矛盾稱為競爭(Race)。
  • 由於兩個或者多個進程競爭使用不能被同時訪問的資源,使得這些進程有可能因為時間上推進的先後原因而出現問題,這叫做競爭條件(Race Condition)。
  • 競爭條件分為兩類:
    -Mutex(互斥):兩個或多個進程彼此之間沒有內在的制約關系,但是由於要搶占使用某個臨界資源(不能被多個進程同時使用的資源,如打印機,變量)而產生制約關系。
    -Synchronization(同步):兩個或多個進程彼此之間存在內在的制約關系(前一個進程執行完,其他的進程才能執行),如嚴格輪轉法。
  • 解決互斥方法:
    Busy Waiting(忙等待):等著但是不停的檢查測試,不睡覺,知道能進行為止
    Sleep and Wakeup(睡眠與喚醒):引入Semapgore(信號量,包含整數和等待隊列,為進程睡覺而設置),喚醒由其他進程引發。
  • 臨界區(Critical Region):
    • 一段訪問臨界資源的代碼。
    • 為了避免出現競爭條件,進入臨界區要遵循四條原則:
      • 任何兩個進程不能同時進入訪問同一臨界資源的臨界區
      • 進程的個數,CPU個數性能等都是無序的,隨機的
      • 臨界區之外的進程不得阻塞其他進程進入臨界區
      • 任何進程都不應被長期阻塞在臨界區之外
  • 解決互斥的方法:
    ? 禁用中斷 Disabling interrupts
    ? 鎖變量 Lock variables (no)
    ? 嚴格輪轉 Strict alternation (no)
    ? Peterson’s solution (yes)
    ? The TSL instruction (yes)

## 線程的休眠、中斷

【Thread.sleep】

  • 在線程中允許一個線程進行暫時的休眠,直接使用Thread.sleep()方法即可。

    • 將某個線程休眠,意味著其他線程得到更多的執行機會
    • 進入休眠的線程不會失去對現有monitor或鎖的所有權
  • sleep定義格式:
public static void sleep(long milis,int nanos)
       throws InterruptedException

  首先,static,說明可以由Thread類名稱調用,其次throws表示如果有異常要在調用此方法處處理異常

所以sleep()方法要有InterruptedException 異常處理,而且sleep()調用方法通常為Thread.sleep(500) ;形式。

  • 實例:

技術分享圖片

【Thread.interrupt】

  • 一個線程可以被另一個線程中斷其操作的狀態,使用 interrupt()方法完成。
    • 通過線程的實例來調用interrupt()函數,向線程發出中斷信號
    • t.interrupt():在其他線程裏向t發出中斷信號
    • t.isInterrupted():檢查t是否已在中斷狀態中
  • 當某個線程被中斷後,一般來說應停止 其run()中的執行,取決於程序員在run()中處理
    • 一般來說,線 程在收到中斷信號時應該中斷,直接終止
    • 但是,線程收到其他線程發出來的中斷信號,並不意味著一定要“停止”
  • 實例:

技術分享圖片

  • 實例二:
package Thread1;
class MyThread implements Runnable{    // 實現Runnable接口
    public void run(){    // 覆寫run()方法
        System.out.println("1、進入run()方法") ;
        try{
                Thread.sleep(10000) ;    // 線程休眠10秒
                System.out.println("2、已經完成了休眠") ;
        }catch(InterruptedException e){
            System.out.println("3、休眠被終止") ;
            return ; // 返回調用處
        }
        System.out.println("4、run()方法正常結束") ;
    }
};
public class demo1{
    public static void main(String args[]){
        MyThread mt = new MyThread() ;    // 實例化Runnable子類對象
        Thread t = new Thread(mt,"線程");        // 實例化Thread對象
        t.start() ;    // 啟動線程
        try{
                Thread.sleep(2000) ;    // 線程休眠2秒
        }catch(InterruptedException e){
            System.out.println("3、休眠被終止") ;
        }
        t.interrupt() ;    // 中斷線程執行
    }
};

運行結果:

1、進入run()方法
3、休眠被終止

## 線程安全的四個策略

  • 線程安全的定義:ADT或方法在多線程中要執行正確,即無論如何執行,不許調度者做額外的協作,都能滿足正確性
  • 四種線程安全的策略:
    • Confinement 限制數據共享
    • Immutability 共享不可變數據
    • Threadsafe data type 共享線程安全的可 變數據
    • Synchronization 同步機制共享共享線程 不安全的可變數據,對外即為線程安全的ADT.

【Confinement 限制數據共享】

  • 核心思想:線程之間不共享mutable數據類型
    • 將可變數據限制在單一線程內部,避免競爭
    • 不允許任何縣城直接讀寫該數據
  • 在多線程環境中,取消全局變量,盡量避免使用不安全的靜態變量。
    • 限制數據共享主要是在線程內部使用局部變量,因為局部變量在每個函數的棧內,每個函數都有自己的棧結構,互不影響,這樣局部變量之間也互不影響。
    • 如果局部變量是一個指向對象的引用,那麽就需要檢查該對象是否被限制住,如果沒有被限制住(即可以被其他線程所訪問),那麽就沒有限制住數據,因此也就不能用這種方法來保證線程安全
  • 栗子:
public class Factorial {

    /**
     * Computes n! and prints it on standard output.
     * @param n must be >= 0
     */
    private static void computeFact(final int n) {
        BigInteger result = new BigInteger("1");
        for (int i = 1; i <= n; ++i) {
            System.out.println("working on fact " + n);
            result = result.multiply(new BigInteger(String.valueOf(i)));
        }
        System.out.println("fact(" + n + ") = " + result);
    }

    public static void main(String[] args) {
        new Thread(new Runnable() { // create a thread using an
            public void run() {     // anonymous Runnable
                computeFact(99);
            }
        }).start();
        computeFact(100);
    }
}

解釋:主函數開啟了兩個線程,調用的是相同函數。因為線程共享局部變量的類型,但每個函數調用有不同的棧,因此有不同的i,n,result。由於每個函數都有自己的局部變量,那麽每個函數就可以獨立運行,更新它們自己的函數值,線程之間不影響結果。

【Immutability 共享不可變數據】

不可變數據類型,指那些在整個程序運行過程中,指向內存的引用是一直不變的,通常使用final來修飾。不可變數據類型通常來講是線程安全的,但也可能發生意外。

但是,程序在運行過程中,有時為了優化程序結構,默默地將這個引用更改了。此時,客戶端程序員是不知道它被更改了,對於客戶端而言,這個引用還是不可變的,但其實已經被悄悄更改了。這時就會發生一些線程安全問題。

解決方案就是給這些不可變數據類型再增加一些限制:

  • 所有的方法和屬性都是私有的。
  • 不提供可變的方法,即不對外開放可以更改內部屬性的方法。
  • 沒有數據的泄露,即返回值而不是引用。
  • 不在其中存儲可變數據對象。

這樣就可以保證線程的安全了。

【Threadsafe data type(共享線程安全的可變數據)】

  • 方法:如果必須要用mutable的數據類型在多線程之間共享數據,要使用線程安全的數據類型。(在JDK中的類,文檔中明確指明了是否threadsafe)
  • 一般來說,JDK同時提供兩個相同功能的類,一個是threadsafe,另一個不是。原因:threadsafe的類一般性能上受影響。
  • List、Set、Map這些集合類都是線程不安全的,Java API為這些集合類提供了進一步的decorator
 private static Map<Integer,Boolean> cache = Collections.synchronizedMap(new HashMap<>());
 public static <T> Collection<T> synchronizedCollection(Collection<T> c);
 public static <T> Set<T> synchronizedSet(Set<T> s);
 public static <T> List<T> synchronizedList(List<T> list);
 public static <K,V> Map<K,V> synchronizedMap(Map<K,V> m);
 public static <T> SortedSet<T> synchronizedSortedSet(SortedSet<T> s);
 public static <K,V> SortedMap<K,V> synchronizedSortedMap(SortedMap<K,V> m);
  • ***在使用synchronizedMap(hashMap)之後,不要再把參數hashMap共享給其他線程,不要保留別名,一定要徹底銷毀.(可以用private static Map cache =Collections.synchronizedMap(new HashMap<>());的方式實例化集合類)
  • 即使在線程安全的集合類上,使用iterator也 是不安全的:
List<Type> c = Collections.synchronizedList(new
ArrayList<Type>());
synchronized(c) { // to be introduced later (the 4-th threadsafe way)
    for (Type e : c)
        foo(e);
}
  • 需要註意用java提供的包裝類包裝集合後,只是將集合的每個操作都看成了原子操作,也就保證了每個操作內部的正確性,但是在兩個操作之間不能保證集合類不被修改,因此需要用lock機制,例如

技術分享圖片

  如果在isEmpty和get中間,將元素移除,也就產生了競爭。

前三種策略的核心思想:避免共享 --> 即使共享,也只能讀/不可寫(immutable) -->即使可寫(mutable),共享的可寫數據應自己具備在多線程之間協調的能力,即“使用線程安全的mutable ADT”

【Synchronization 同步與鎖】

  • 為什麽要同步
    • java允許多線程並發控制,當多個線程同時操作一個可共享的資源變量時(如數據的增刪改查)
    • 將會導致數據不準確,相互之間產生沖突,因此加入同步鎖以避免在該線程沒有完成操作之前,被其他線程的調用,
    • 從而保證了該變量的唯一性和準確性。
  • 同步方法
    • 即有synchronized關鍵字修飾的方法。
    • 由於java的每個對象都有一個內置鎖,當用此關鍵字修飾方法時,內置鎖會保護整個方法。
    • 在調用該方法前,需要獲得內置鎖,否則就處於阻塞狀態。
    • 代碼如下:
      public synchronized void save(){} 
    • 註: synchronized關鍵字也可以修飾靜態方法,此時如果調用該靜態方法,將會鎖住整個類
  • 同步代碼塊
    • 在調用該方法前,需要獲得內置鎖,否則就處於阻塞狀態。
    • 被該關鍵字修飾的語句塊會自動被加上內置鎖,從而實現同步。
    • 代碼如:
      synchronized(object){    }
    • 註:同步是一種高開銷的操作,因此應該盡量減少同步的內容。
  • 使用鎖機制,獲得對數據的獨家mutation權,其他線程被阻塞,不得訪問
  • Lock是Java語言提供的內嵌機制,每個object都有相關聯的lock
  • 任何共享的mutable變量/對象必須被lock所保護
  • 涉及到多個mutable變量的時候,它們必須被同一個lock所保護

## 死鎖

## 以註釋的形式撰寫線程安全策略

【軟件構造】第十章 線程和分布式系統