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

話題:Worker Thread模式:如何避免重複創建線程?

在上一篇文章中,我們介紹了一種最簡單的分工模式——Thread-Per-Message模式,對應到現實世界,其實就是委託代辦。這種分工模式如果用Java Thread實現,頻繁地創建、銷燬線程非常影響性能,同時無限制地創建線程還可能導致OOM,所以在Java領域使用場景就受限了。

要想有效避免線程的頻繁創建、銷燬以及OOM問題,就不得不提今天我們要細聊的,也是Java領域使用最多的Worker Thread模式。

Worker Thread模式及其實現

Worker Thread模式可以類比現實世界裏車間的工作模式:車間裏的工人,有活兒了,大家一起幹,沒活兒了就聊聊天等着。你可以參考下面的示意圖來理解,Worker Thread模式中Worker Thread對應到現實世界裏,其實指的就是車間裏的工人。不過這裏需要注意的是,車間裏的工人數量往往是確定的。
在這裏插入圖片描述
那在編程領域該如何模擬車間的這種工作模式呢?或者說如何去實現Worker Thread模式呢?通過上面的圖,你很容易就能想到用阻塞隊列做任務池,然後創建固定數量的線程消費阻塞隊列中的任務。其實你仔細想會發現,這個方案就是Java語言提供的線程池。

線程池有很多優點,例如能夠避免重複創建、銷燬線程,同時能夠限制創建線程的上限等等。學習完上一篇文章後你已經知道,用Java的Thread實現Thread-Per-Message模式難以應對高併發場景,原因就在於頻繁創建、銷燬Java線程的成本有點高,而且無限制地創建線程還可能導致應用OOM。線程池,則恰好能解決這些問題。

那我們還是以echo程序爲例,看看如何用線程池來實現。

下面的示例代碼是用線程池實現的echo服務端,相比於Thread-Per-Message模式的實現,改動非常少,僅僅是創建了一個最多線程數爲500的線程池es,然後通過es.execute()方法將請求處理的任務提交給線程池處理。

ExecutorService es = Executors.newFixedThreadPool(500);

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();
}

正確地創建線程池

Java的線程池既能夠避免無限制地創建線程導致OOM,也能避免無限制地接收任務導致OOM。只不過後者經常容易被我們忽略,例如在上面的實現中,就被我們忽略了。所以強烈建議你用創建有界的隊列來接收任務。

當請求量大於有界隊列的容量時,就需要合理地拒絕請求。如何合理地拒絕呢?這需要你結合具體的業務場景來制定,即便線程池默認的拒絕策略能夠滿足你的需求,也同樣建議你在創建線程池時,清晰地指明拒絕策略。

同時,爲了便於調試和診斷問題,我也強烈建議你在實際工作中給線程賦予一個業務相關的名字

綜合以上這三點建議,echo程序中創建線程可以使用下面的示例代碼。

ExecutorService es = new ThreadPoolExecutor(
  50, 500,
  60L, TimeUnit.SECONDS,
  //注意要創建有界隊列
  new LinkedBlockingQueue<Runnable>(2000),
  //建議根據業務需求實現ThreadFactory
  r->{
    return new Thread(r, "echo-"+ r.hashCode());
  },
  //建議根據業務需求實現RejectedExecutionHandler
  new ThreadPoolExecutor.CallerRunsPolicy());

避免線程死鎖

使用線程池過程中,還要注意一種線程死鎖的場景。如果提交到相同線程池的任務不是相互獨立的,而是有依賴關係的,那麼就有可能導致線程死鎖。實際工作中,我就親歷過這種線程死鎖的場景。具體現象是應用每運行一段時間偶爾就會處於無響應的狀態,監控數據看上去一切都正常,但是實際上已經不能正常工作了。

這個出問題的應用,相關的邏輯精簡之後,如下圖所示,該應用將一個大型的計算任務分成兩個階段,第一個階段的任務會等待第二階段的子任務完成。在這個應用裏,每一個階段都使用了線程池,而且兩個階段使用的還是同一個線程池。
在這裏插入圖片描述
我們可以用下面的示例代碼來模擬該應用,如果你執行下面的這段代碼,會發現它永遠執行不到最後一行。執行過程中沒有任何異常,但是應用已經停止響應了。

//L1、L2階段共用的線程池
ExecutorService es = Executors.newFixedThreadPool(2);

//L1階段的閉鎖    
CountDownLatch l1=new CountDownLatch(2);
for (int i=0; i<2; i++){
  System.out.println("L1");
  //執行L1階段任務
  es.execute(()->{
    //L2階段的閉鎖 
    CountDownLatch l2=new CountDownLatch(2);
    //執行L2階段子任務
    for (int j=0; j<2; j++){
      es.execute(()->{
        System.out.println("L2");
        l2.countDown();
      });
    }
    
    //等待L2階段任務執行完
    l2.await();
    l1.countDown();
  });
}
//等着L1階段任務執行完
l1.await();
System.out.println("end");

當應用出現類似問題時,首選的診斷方法是查看線程棧。下圖是上面示例代碼停止響應後的線程棧,你會發現線程池中的兩個線程全部都阻塞在 l2.await(); 這行代碼上了,也就是說,線程池裏所有的線程都在等待L2階段的任務執行完,那L2階段的子任務什麼時候能夠執行完呢?永遠都沒那一天了,爲什麼呢?因爲線程池裏的線程都阻塞了,沒有空閒的線程執行L2階段的任務了。
在這裏插入圖片描述
原因找到了,那如何解決就簡單了,最簡單粗暴的辦法就是將線程池的最大線程數調大,如果能夠確定任務的數量不是非常多的話,這個辦法也是可行的,否則這個辦法就行不通了。其實**這種問題通用的解決方案是爲不同的任務創建不同的線程池。**對於上面的這個應用,L1階段的任務和L2階段的任務如果各自都有自己的線程池,就不會出現這種問題了。

最後再次強調一下:提交到相同線程池中的任務一定是相互獨立的,否則就一定要慎重。

總結

我們曾經說過,解決併發編程裏的分工問題,最好的辦法是和現實世界做對比。對比現實世界構建編程領域的模型,能夠讓模型更容易理解。上一篇我們介紹的Thread-Per-Message模式,類似於現實世界裏的委託他人辦理,而今天介紹的Worker Thread模式則類似於車間裏工人的工作模式。如果你在設計階段,發現對業務模型建模之後,模型非常類似於車間的工作模式,那基本上就能確定可以在實現階段採用Worker Thread模式來實現。

Worker Thread模式和Thread-Per-Message模式的區別有哪些呢?從現實世界的角度看,你委託代辦人做事,往往是和代辦人直接溝通的;對應到編程領域,其實現也是主線程直接創建了一個子線程,主子線程之間是可以直接通信的。而車間工人的工作方式則是完全圍繞任務展開的,一個具體的任務被哪個工人執行,預先是無法知道的;對應到編程領域,則是主線程提交任務到線程池,但主線程並不關心任務被哪個線程執行。

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

不過使用線程池還是需要格外謹慎的,除了今天重點講到的如何正確創建線程池、如何避免線程死鎖問題,還需要注意前面我們曾經提到的ThreadLocal內存泄露問題。同時對於提交到線程池的任務,還要做好異常處理,避免異常的任務從眼前溜走,從業務的角度看,有時沒有發現異常的任務後果往往都很嚴重。

Demo

1
public class Channel {//流水線上的貨物,流水線工人從流水線上拿貨物工作

    private final static int MAX_REQUEST = 100;

    private final Request[] requestQueue;

    private int head;//隊頭

    private int tail;//隊尾

    private int count;//流水線當前的數量

    private final WorkerThread[] workerPool;//流水線的工人

    public Channel(int workers) {
        this.requestQueue = new Request[MAX_REQUEST];
        this.head = 0;
        this.tail = 0;
        this.count = 0;
        this.workerPool = new WorkerThread[workers];//線程
        this.init();
    }

    private void init() {
        for (int i = 0; i < workerPool.length; i++) {
            workerPool[i] = new WorkerThread("Worker-" + i, this);
        }
    }

    /**
     * push switch to start all of worker to work.  一推開關,流水線工人開始工作
     */
    public void startWorker() {//給外面的人調用
        Arrays.asList(workerPool).forEach(WorkerThread::start);
    }

    public synchronized void put(Request request) {//放到流水線上面去,
        while (count >= requestQueue.length) {
            try {
                this.wait();
            } catch (Exception e) {
            }
        }

        this.requestQueue[tail] = request;
        this.tail = (tail + 1) % requestQueue.length;//簡單算法,每次移動到前一個位置
        this.count++;
        this.notifyAll();
    }

    public synchronized Request take() {
        while (count <= 0) {
            try {
                this.wait();
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }

        Request request = this.requestQueue[head];
        this.head = (this.head + 1) % this.requestQueue.length;//
        this.count--;
        this.notifyAll();
        return request;
    }
}
2
public class Request {

    private final String name;

    private final int number;

    public Request(final String name, final int number) {
        this.name = name;
        this.number = number;
    }

    public void execute() {
        System.out.println(Thread.currentThread().getName() +
         " executed " + this);//哪位工人放的,拿到該貨再執行這個方法
    }

    @Override
    public String toString() {
        return "Request=> No. " + number + " Name. " + name;
    }
}
3
public class WorkerThread extends Thread {

    private final Channel channel;

    private static final Random random = new Random(System.currentTimeMillis());

    public WorkerThread(String name, Channel channel) {
        super(name);
        this.channel = channel;
    }

    @Override
    public void run() {
        while (true) {
            channel.take().execute();
            try {
                Thread.sleep(random.nextInt(1_000));
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
    }
}
4
public class WorkerClient {
    public static void main(String[] args) {
        final Channel channel = new Channel(5);
        channel.startWorker();//拉咂開工

        //搬運工開始搬貨物到流水線上面
        new TransportThread("Alex", channel).start();
        new TransportThread("Jack", channel).start();
        new TransportThread("William", channel).start();
    }
}
5
public class TransportThread extends Thread {
    private final Channel channel;//往流水線放

    private static final Random random = new Random(System.currentTimeMillis());
 
    //裝配工人,搬運工,放到流水線上面
    public TransportThread(String name, Channel channel) {
        super(name);
        this.channel = channel;
    }

    @Override
    public void run() {
        try {
            for (int i = 0; true; i++) {
                Request request = new Request(getName(), i);
                this.channel.put(request);
                Thread.sleep(random.nextInt(1_000));
            }
        } catch (Exception e) {
        }
    }
}
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章