15-併發設計模式

併發(多線程)設計模式不同於傳統設計模式,更關注的是併發編程中特定場景的解決方案。對於併發設計模式同學們務必理解。

終止線程的設計模式

思考:在一個線程 T1 中如何正確安全的終止線程 T2?

錯誤思路1:使用線程對象的 stop() 方法停止線程

stop 方法會真正殺死線程,如果這時線程鎖住了共享資源,那麼當它被殺死後就再也沒有機會釋放鎖, 其它線程將永遠無法獲取鎖 。

錯誤思路2:使用 System.exit(int) 方法停止線程

目的僅是停止一個線程,但這種做法會讓整個程序都停止

正確思路:利用Java線程的中斷機制

Two-phase Termination(兩階段終止)模式——優雅的終止線程

將終止過程分成兩個階段,其中第一個階段主要是線程 T1 向線程 T2發送終止指令,而第二階段則是線程 T2響應終止指令。

Java 線程進入終止狀態的前提是線程進入 RUNNABLE 狀態,而利用java線程中斷機制的interrupt() 方法,可以讓線程從休眠狀態轉換到RUNNABLE 狀態。RUNNABLE 狀態轉換到終止狀態,優雅的方式是讓 Java 線程自己執行完 run() 方法,所以一般我們採用的方法是設置一個標誌位,然後線程會在合適的時機檢查這個標誌位,如果發現符合終止條件,則自動退出 run() 方法。

兩階段終止模式是一種應用很廣泛的併發設計模式,在 Java 語言中使用兩階段終止模式來優雅地終止線程,需要注意兩個關鍵點:一個是僅檢查終止標誌位是不夠的,因爲線程的狀態可能處於休眠態;另一個是僅檢查線程的中斷狀態也是不夠的,因爲我們依賴的第三方類庫很可能沒有正確處理中斷異常,例如第三方類庫在捕獲到 Thread.sleep() 方法拋出的中斷異常後,沒有重新設置線程的中斷狀態,那麼就會導致線程不能夠正常終止。所以我們可以自定義線程的終止標誌位用於終止線程。

使用場景

  1. 安全地終止線程,比如釋放該釋放的資源;
  2. 要確保終止處理邏輯在線程結束之前一定會執行時,可使用該方法;

避免共享的設計模式

Immutability模式,Copy-on-Write模式,Thread-Specific Storage模式本質上都是爲了避免共享。

  • 使用時需要注意Immutability模式的屬性的不可變性
  • Copy-on-Write模式需要注意拷貝的性能問題
  • Thread-Specific Storage模式需要注意異步執行問題。

Immutability模式——想破壞也破壞不了

“多個線程同時讀寫同一共享變量存在併發問題”,這裏的必要條件之一是讀寫,如果只有讀,而沒有寫,是沒有併發問題的。解決併發問題,其實最簡單的辦法就是讓共享變量只有讀操作,而沒有寫操作。這個辦法如此重要,以至於被上升到了一種解決併發問題的設計模式:不變性(Immutability)模式。所謂不變性,簡單來講,就是對象一旦被創建之後,狀態就不再發生變化。換句話說,就是變量一旦被賦值,就不允許修改了(沒有寫操作);沒有修改操作,也就是保持了不變性。

如何實現

將一個類所有的屬性都設置成 final 的,並且只允許存在只讀方法,那麼這個類基本上就具備不可變性了。更嚴格的做法是這個類本身也是 final 的,也就是不允許繼承。

jdk中很多類都具備不可變性,例如經常用到的 String 和 Long、Integer、Double 等基礎類型的包裝類都具備不可變性,這些對象的線程安全性都是靠不可變性來保證的。它們都嚴格遵守了不可變類的三點要求:類和屬性都是 final 的,所有方法均是隻讀的。

使用 Immutability 模式的注意事項

在使用 Immutability 模式的時候,需要注意以下兩點:

  • 對象的所有屬性都是 final 的,並不能保證不可變性;
  • 不可變對象也需要正確發佈。

在使用 Immutability 模式的時候一定要確認保持不變性的邊界在哪裏,是否要求屬性對象也具備不可變性。

下面的代碼中,Bar 的屬性 foo 雖然是 final 的,依然可以通過 setAge() 方法來設置 foo 的屬性 age。

class Foo{
  int age=0;
  int name="abc";
}
final class Bar {
  final Foo foo;
  void setAge(int a){
    foo.age=a;
  }
}

可變對象雖然是線程安全的,但是並不意味着引用這些不可變對象的對象就是線程安全的。

下面的代碼中,Foo 具備不可變性,線程安全,但是類 Bar 並不是線程安全的,類 Bar 中持有對 Foo 的引用 foo,對 foo 這個引用的修改在多線程中並不能保證可見性和原子性。

//Foo線程安全
final class Foo{
  final int age=0;
  final String name="abc";
}
//Bar線程不安全
class Bar {
  Foo foo;
  void setFoo(Foo f){
    this.foo=f;
  }
}

Copy-on-Write模式

Java 裏 String 在實現 replace() 方法的時候,並沒有更改原字符串裏面 value[]數組的內容,而是創建了一個新字符串,這種方法在解決不可變對象的修改問題時經常用到。它本質上是一種 Copy-on-Write 方法。所謂 Copy-on-Write,經常被縮寫爲 COW 或者 CoW,顧名思義就是寫時複製。

不可變對象的寫操作往往都是使用 Copy-on-Write 方法解決的,當然 Copy-on-Write 的應用領域並不侷限於 Immutability 模式。

Copy-on-Write 纔是最簡單的併發解決方案,很多人都在無意中把它忽視了。它是如此簡單,以至於 Java 中的基本數據類型 String、Integer、Long 等都是基於 Copy-on-Write 方案實現的。

Copy-on-Write 缺點就是消耗內存,每次修改都需要複製一個新的對象出來,好在隨着自動垃圾回收(GC)算法的成熟以及硬件的發展,這種內存消耗已經漸漸可以接受了。所以在實際工作中,如果寫操作非常少(讀多寫少的場景),可以嘗試使用 Copy-on-Write。

應用場景

在Java中,CopyOnWriteArrayListCopyOnWriteArraySet 這兩個 Copy-on-Write 容器,它們背後的設計思想就是 Copy-on-Write;通過 Copy-on-Write 這兩個容器實現的讀操作是無鎖的,由於無鎖,所以將讀操作的性能發揮到了極致。

Copy-on-Write 在操作系統領域也有廣泛的應用。類 Unix 的操作系統中創建進程的 API 是 fork(),傳統的 fork() 函數會創建父進程的一個完整副本,例如父進程的地址空間現在用到了 1G 的內存,那麼 fork() 子進程的時候要複製父進程整個進程的地址空間(佔有 1G 內存)給子進程,這個過程是很耗時的。而 Linux 中fork() 子進程的時候,並不複製整個進程的地址空間,而是讓父子進程共享同一個地址空間;只用在父進程或者子進程需要寫入的時候纔會複製地址空間,從而使父子進程擁有各自的地址空間。

Copy-on-Write 最大的應用領域還是在函數式編程領域。函數式編程的基礎是不可變性(Immutability),所以函數式編程裏面所有的修改操作都需要 Copy-on-Write 來解決。

像一些RPC框架還有服務註冊中心,也會利用Copy-on-Write設計思想維護服務路由表。路由表是典型的讀多寫少,而且路由表對數據的一致性要求並不高,一個服務提供方從上線到反饋到客戶端的路由表裏,即便有 5 秒鐘延遲,很多時候也都是能接受的。

Thread-Specific Storage 模式——沒有共享就沒有傷害

Thread-Specific Storage(線程本地存儲) 模式是一種即使只有一個入口,也會在內部爲每個線程分配特有的存儲空間的模式。在 Java 標準類庫中,ThreadLocal 類實現了該模式。

線程本地存儲模式本質上是一種避免共享的方案,由於沒有共享,所以自然也就沒有併發問題。如果你需要在併發場景中使用一個線程不安全的工具類,最簡單的方案就是避免共享。避免共享有兩種方案,一種方案是將這個工具類作爲局部變量使用,另外一種方案就是線程本地存儲模式。這兩種方案,局部變量方案的缺點是在高併發場景下會頻繁創建對象,而線程本地存儲方案,每個線程只需要創建一個工具類的實例,所以不存在頻繁創建對象的問題。

應用場景

SimpleDateFormat 不是線程安全的,那如果需要在併發場景下使用它,有一個辦法就是用 ThreadLocal 來解決。

tatic class SafeDateFormat {
  //定義ThreadLocal變量
  static final ThreadLocal<DateFormat> tl=ThreadLocal.withInitial(
    ()-> new SimpleDateFormat("yyyy-MM-dd HH:mm:ss"));
      
  static DateFormat get(){
    return tl.get();
  }
}
//不同線程執行下面代碼,返回的df是不同的

注意:在線程池中使用ThreadLocal 需要避免內存泄漏和線程安全的問題

ExecutorService es;
ThreadLocal tl;
es.execute(()->{
  //ThreadLocal增加變量
  tl.set(obj);
  try {
    // 省略業務邏輯代碼
  }finally {
    //手動清理ThreadLocal 
    tl.remove();
  }
)

多線程版本的if模式

Guarded Suspension模式和Balking模式屬於多線程版本的if模式

  • Guarded Suspension模式需要注意性能。
  • Balking模式需要注意競態問題。

Guarded Suspension模式——等我準備好哦

Guarded Suspension 模式是通過讓線程等待來保護實例的安全性,即守護-掛起模式。在多線程開發中,常常爲了提高應用程序的併發性,會將一個任務分解爲多個子任務交給多個線程並行執行,而多個線程之間相互協作時,仍然會存在一個線程需要等待另外的線程完成後繼續下一步操作。而Guarded Suspension模式可以幫助我們解決上述的等待問題。

Guarded Suspension 模式允許多個線程對實例資源進行訪問,但是實例資源需要對資源的分配做出管理。

Guarded Suspension 模式也常被稱作 Guarded Wait 模式、Spin Lock 模式(因爲使用了 while 循環去等待),它還有一個更形象的非官方名字:多線程版本的 if。

  • 有一個結果需要從一個線程傳遞到另一個線程,讓他們關聯同一個 GuardedObject

  • 如果有結果不斷從一個線程到另一個線程那麼可以使用消息隊列

  • JDK 中,join 的實現、Future 的實現,採用的就是此模式

  • 因爲要等待另一方的結果,因此歸類到同步模式

  • 等待喚醒機制的規範實現。此模式依賴於Java線程的阻塞喚醒機制:

      • sychronized+wait/notify/notifyAll
      • reentrantLock+Condition(await/singal/singalAll)
      • cas+park/unpark
阻塞喚醒機制底層原理: linux  pthread_mutex_lock/unlock  pthread_cond_wait/singal

解決線程之間的協作不可避免會用到阻塞喚醒機制

image

應用場景

  • 多線程環境下多個線程訪問相同實例資源,從實例資源中獲得資源並處理;
  • 實例資源需要管理自身擁有的資源,並對請求線程的請求作出允許與否的判斷;

Guarded Suspension模式的實現

public class GuardedObject<T> {
    //結果
    private T obj;
    //獲取結果
    public T get(){
        synchronized (this){
            //沒有結果等待   防止虛假喚醒
            while (obj==null){
                try {
                    this.wait();
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
            return obj;
        }
    }
    //產生結果
    public void complete(T obj){
        synchronized (this){
            //獲取到結果,給obj賦值
            this.obj = obj;
            //喚醒等待結果的線程
            this.notifyAll();
        }
    }
}

Balking模式——不需要就算了

Balking是“退縮不前”的意思。如果現在不適合執行這個操作,或者沒必要執行這個操作,就停止處理,直接返回。當流程的執行順序依賴於某個共享變量的場景,可以歸納爲多線程if模式。Balking 模式常用於一個線程發現另一個線程已經做了某一件相同的事,那麼本線程就無需再做了,直接結束返回。

Balking模式是一種多個線程執行同一操作A時可以考慮的模式;在某一個線程B被阻塞或者執行其他操作時,其他線程同樣可以完成操作A,而當線程B恢復執行或者要執行操作A時,因A已被執行,而無需線程B再執行,從而提高了B的執行效率。

Balking模式和Guarded Suspension模式一樣,存在守護條件,如果守護條件不滿足,則中斷處理;這與Guarded Suspension模式不同,Guarded Suspension模式在守護條件不滿足的時候會一直等待至可以運行。

常見的應用場景

  • sychronized輕量級鎖膨脹邏輯, 只需要一個線程膨脹獲取monitor對象
  • DCL單例實現
  • 服務組件的初始化

如何實現Balking模式

  • 鎖機制 (synchronized reentrantLock)
  • cas
  • 對於共享變量不要求原子性的場景,可以使用volatile

需要快速放棄的一個最常見的場景是各種編輯器提供的自動保存功能。自動保存功能的實現邏輯一般都是隔一定時間自動執行存盤操作,存盤操作的前提是文件做過修改,如果文件沒有執行過修改操作,就需要快速放棄存盤操作。

boolean changed=false;
// 自動存盤操作
void autoSave(){
  synchronized(this){
    if (!changed) {
      return;
    }
    changed = false;
  }
  // 執行存盤操作
  // 省略且實現
  this.execSave();
}
// 編輯操作
void edit(){
  // 省略編輯邏輯
  ......
  change();
}
// 改變狀態
void change(){
  synchronized(this){
    changed = true;
  }
}

Balking 模式有一個非常典型的應用場景就是單次初始化。

boolean inited = false;
synchronized void init(){
    if(inited){
      return;
    }
    //省略doInit的實現
    doInit();
    inited=true;
}

多線程分工模式

Thread-Per-Message 模式、Worker Thread 模式和生產者 - 消費者模式屬於多線程分工模式。

  • Thread-Per-Message 模式需要注意線程的創建,銷燬以及是否會導致OOM。
  • Worker Thread 模式需要注意死鎖問題,提交的任務之間不要有依賴性。
  • 生產者 - 消費者模式可以直接使用線程池來實現

Thread-Per-Message 模式——最簡單實用的分工方法

Thread-Per-Message 模式就是爲每個任務分配一個獨立的線程,這是一種最簡單的分工方法。

應用場景

Thread-Per-Message 模式的一個最經典的應用場景是網絡編程裏服務端的實現,服務端爲每個客戶端請求創建一個獨立的線程,當線程處理完請求後,自動銷燬,這是一種最簡單的併發處理網絡請求的方法。

final ServerSocketChannel  ssc= ServerSocketChannel.open().bind(new InetSocketAddress(8080));
//處理請求    
try {
  while (true) {
    // 接收請求
    SocketChannel sc = ssc.accept();
    // 每個請求都創建一個線程
    new Thread(()->{
      try {
        // 讀Socket
        ByteBuffer rb = ByteBuffer.allocateDirect(1024);
        sc.read(rb);
        //模擬處理請求
        Thread.sleep(2000);
        // 寫Socket
        ByteBuffer wb = (ByteBuffer)rb.flip();
        sc.write(wb);
        // 關閉Socket
        sc.close();
      }catch(Exception e){
        throw new UncheckedIOException(e);
      }
    }).start();
  }
} finally {
  ssc.close();
}   

Thread-Per-Message 模式作爲一種最簡單的分工方案,Java 中使用會存在性能缺陷。在 Java 中的線程是一個重量級的對象,創建成本很高,一方面創建線程比較耗時,另一方面線程佔用的內存也比較大。所以爲每個請求創建一個新的線程並不適合高併發場景。爲了解決這個缺點,Java 併發包裏提供了線程池等工具類。

在其他編程語言裏,例如 Go 語言,基於輕量級線程實現 Thread-Per-Message 模式就完全沒有問題。

對於一些併發度沒那麼高的異步場景,例如定時任務,採用 Thread-Per-Message 模式是完全沒有問題的。

Worker Thread模式——如何避免重複創建線程

要想有效避免線程的頻繁創建、銷燬以及 OOM 問題,就不得不提 Java 領域使用最多的 Worker Thread 模式。Worker Thread 模式可以類比現實世界裏車間的工作模式:車間裏的工人,有活兒了,大家一起幹,沒活兒了就聊聊天等着。Worker Thread 模式中 Worker Thread 對應到現實世界裏,其實指的就是車間裏的工人。

Worker Thread 模式實現

之前的服務端例子用線程池實現

ExecutorService es = Executors.newFixedThreadPool(200);
final ServerSocketChannel ssc = ServerSocketChannel.open().bind(new InetSocketAddress(8080));
//處理請求    
try {
  while (true) {
    // 接收請求
    SocketChannel sc = ssc.accept();
    // 將請求處理任務提交給線程池
    es.execute(()->{
      try {
        // 讀Socket
        ByteBuffer rb = ByteBuffer.allocateDirect(1024);
        sc.read(rb);
        //模擬處理請求
        Thread.sleep(2000);
        // 寫Socket
        ByteBuffer wb = 
          (ByteBuffer)rb.flip();
        sc.write(wb);
        // 關閉Socket
        sc.close();
      }catch(Exception e){
        throw new UncheckedIOException(e);
      }
    });
  }
} finally {
  ssc.close();
  es.shutdown();
}

應用場景

Worker Thread 模式能避免線程頻繁創建、銷燬的問題,而且能夠限制線程的最大數量。Java 語言裏可以直接使用線程池來實現 Worker Thread 模式,線程池是一個非常基礎和優秀的工具類,甚至有些大廠的編碼規範都不允許用 new Thread() 來創建線程,必須使用線程池。

生產者 - 消費者模式——用流水線的思想提高效率

Worker Thread 模式類比的是工廠裏車間工人的工作模式。但其實在現實世界,工廠裏還有一種流水線的工作模式,類比到編程領域,就是生產者 - 消費者模式。

生產者 - 消費者模式的核心是一個任務隊列,生產者線程生產任務,並將任務添加到任務隊列中,而消費者線程從任務隊列中獲取任務並執行。

image

public class Test {
    public static void main(String[] args) {
        // 生產者線程池
        ExecutorService producerThreads = Executors.newFixedThreadPool(3);
        // 消費者線程池
        ExecutorService consumerThreads = Executors.newFixedThreadPool(2);
        // 任務隊列,長度爲10
        ArrayBlockingQueue<Task> taskQueue = new ArrayBlockingQueue<Task>(10);
        // 生產者提交任務
        producerThreads.submit(() -> {
            try {
                taskQueue.put(new Task("任務"));
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        });
        // 消費者處理任務
        consumerThreads.submit(() -> {
            try {
                Task task = taskQueue.take();
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        });
    }
    static class Task {
        // 任務名稱
        private String taskName;
        public Task(String taskName) {
            this.taskName = taskName;
        }
    }
}

生產者 - 消費者模式的優點

支持異步處理

場景:用戶註冊後,需要發註冊郵件和註冊短信。傳統的做法有兩種 1.串行的方式;2.並行方式

image

引入消息隊列,將不是必須的業務邏輯異步處理

image

解耦

場景:用戶下單後,訂單系統需要通知庫存系統扣減庫存。

image

可以消除生產者生產與消費者消費之間速度差異

image

在計算機當中,創建的線程越多,CPU進行上下文切換的成本就越大,所以我們在編程的時候創建的線程並不是越多越好,而是適量即可,採用生產者和消費者模式就可以很好的支持我們使用適量的線程來完成任務。

如果在某一段業務高峯期的時間裏生產者“生產”任務的速率很快,而消費者“消費”任務速率很慢,由於中間的任務隊列的存在,也可以起到緩衝的作用,我們在使用MQ中間件的時候,經常說的削峯填谷也就是這個意思。

image

過飽問題解決方案

在實際生產項目中會有些極端的情況,導致生產者/消費者模式可能出現過飽的問題。單位時間內,生產者生產的速度大於消費者消費的速度,導致任務不斷堆積到阻塞隊列中,隊列堆滿只是時間問題。

思考:是不是隻要保證消費者的消費速度一直比生產者生產速度快就可以解決過飽問題?

image

我們只要在業務可以容忍的最長響應時間內,把堆積的任務處理完,那就不算過飽。

什麼是業務容忍的最長響應時間?

比如埋點數據統計前一天的數據生成報表,第二天老闆要看的,你前一天的數據第二天還沒處理完,那就不行,這樣的系統我們就要保證,消費者在24小時內的消費能力要比生產者高才行。

場景一:消費者每天能處理的量比生產者生產的少;如生產者每天1萬條,消費者每天只能消費5千條。

解決辦法:消費者加機器

原因:生產者沒法限流,因爲要一天內處理完,只能消費者加機器

image

場景二:消費者每天能處理的量比生產者生產的多。系統高峯期生產者速度太快,把隊列塞爆了

解決辦法:適當的加大隊列

原因:消費者一天的消費能力已經高於生產者,那說明一天之內肯定能處理完,保證高峯期別把隊列塞滿就好

image

場景三:消費者每天能處理的量比生產者生產的多。條件有限或其他原因,隊列沒法設置特別大。系統高峯期生產者速度太快,把隊列塞爆了

解決辦法:生產者限流

原因:消費者一天的消費能力高於生產者,說明一天內能處理完,隊列又太小,那隻能限流生產者,讓高峯期塞隊列的速度慢點

image

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章