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

話題:生產者-消費者模式:用流水線思想提高效率

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

生產者-消費者模式在編程領域的應用也非常廣泛,前面我們曾經提到,Java線程池本質上就是用生產者-消費者模式實現的,所以每當使用線程池的時候,其實就是在應用生產者-消費者模式。

當然,除了在線程池中的應用,爲了提升性能,併發編程領域很多地方也都用到了生產者-消費者模式,例如Log4j2中異步Appender內部也用到了生產者-消費者模式。所以今天我們就來深入地聊聊生產者-消費者模式,看看它具體有哪些優點,以及如何提升系統的性能。

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

生產者-消費者模式的核心是一個任務隊列,生產者線程生產任務,並將任務添加到任務隊列中,而消費者線程從任務隊列中獲取任務並執行。下面是生產者-消費者模式的一個示意圖,你可以結合它來理解。

在這裏插入圖片描述

從架構設計的角度來看,生產者-消費者模式有一個很重要的優點,就是解耦。解耦對於大型系統的設計非常重要,而解耦的一個關鍵就是組件之間的依賴關係和通信方式必須受限。在生產者-消費者模式中,生產者和消費者沒有任何依賴關係,它們彼此之間的通信只能通過任務隊列,所以生產者-消費者模式是一個不錯的解耦方案。

除了架構設計上的優點之外,生產者-消費者模式還有一個重要的優點就是支持異步,並且能夠平衡生產者和消費者的速度差異。在生產者-消費者模式中,生產者線程只需要將任務添加到任務隊列而無需等待任務被消費者線程執行完,也就是說任務的生產和消費是異步的,這是與傳統的方法之間調用的本質區別,傳統的方法之間調用是同步的。

你或許會有這樣的疑問,異步化處理最簡單的方式就是創建一個新的線程去處理,那中間增加一個“任務隊列”究竟有什麼用呢?我覺得主要還是用於平衡生產者和消費者的速度差異。我們假設生產者的速率很慢,而消費者的速率很高,比如是1:3,如果生產者有3個線程,採用創建新的線程的方式,那麼會創建3個子線程,而採用生產者-消費者模式,消費線程只需要1個就可以了。Java語言裏,Java線程和操作系統線程是一一對應的,線程創建得太多,會增加上下文切換的成本,所以Java線程不是越多越好,適量即可。而生產者-消費者模式恰好能支持你用適量的線程。

支持批量執行以提升性能

前面講過輕量級的線程,如果使用輕量級線程,就沒有必要平衡生產者和消費者的速度差異了,因爲輕量級線程本身就是廉價的,那是否意味着生產者-消費者模式在性能優化方面就無用武之地了呢?當然不是,有一類併發場景應用生產者-消費者模式就有奇效,那就是批量執行任務

例如,我們要在數據庫裏INSERT 1000條數據,有兩種方案:第一種方案是用1000個線程併發執行,每個線程INSERT一條數據;第二種方案是用1個線程,執行一個批量的SQL,一次性把1000條數據INSERT進去。這兩種方案,顯然是第二種方案效率更高,其實這樣的應用場景就是我們上面提到的批量執行場景。

在 兩階段終止模式 文章中,我們提到一個監控系統動態採集的案例,其實最終回傳的監控數據還是要存入數據庫的(如下圖)。但被監控系統往往有很多,如果每一條回傳數據都直接INSERT到數據庫,那麼這個方案就是上面提到的第一種方案:每個線程INSERT一條數據。很顯然,更好的方案是批量執行SQL,那如何實現呢?這就要用到生產者-消費者模式了。

在這裏插入圖片描述
利用生產者-消費者模式實現批量執行SQL非常簡單:將原來直接INSERT數據到數據庫的線程作爲生產者線程,生產者線程只需將數據添加到任務隊列,然後消費者線程負責將任務從任務隊列中批量取出並批量執行。

在下面的示例代碼中,我們創建了5個消費者線程負責批量執行SQL,這5個消費者線程以 while(true){} 循環方式批量地獲取任務並批量地執行。需要注意的是,從任務隊列中獲取批量任務的方法pollTasks()中,首先是以阻塞方式獲取任務隊列中的一條任務,而後則是以非阻塞的方式獲取任務;之所以首先採用阻塞方式,是因爲如果任務隊列中沒有任務,這樣的方式能夠避免無謂的循環。

//任務隊列
BlockingQueue<Task> bq=new LinkedBlockingQueue<>(2000);

//啓動5個消費者線程
//執行批量任務  
void start() {
  ExecutorService es=xecutors.newFixedThreadPool(5);
  
  for (int i=0; i<5; i++) {
    es.execute(()->{
      try {
        while (true) {
          //獲取批量任務
          List<Task> ts=pollTasks();
          //執行批量任務
          execTasks(ts);
        }
      } catch (Exception e) {
        e.printStackTrace();
      }
    });
  }
}

//從任務隊列中獲取批量任務
List<Task> pollTasks() throws InterruptedException{
  List<Task> ts=new LinkedList<>();
  //阻塞式獲取一條任務
  Task t = bq.take();
  while (t != null) {
    ts.add(t);
    //非阻塞式獲取一條任務
    t = bq.poll();
  }
  return ts;
}

//批量執行任務
execTasks(List<Task> ts) {
  //省略具體代碼無數
}

支持分階段提交以提升性能

利用生產者-消費者模式還可以輕鬆地支持一種分階段提交的應用場景。我們知道寫文件如果同步刷盤性能會很慢,所以對於不是很重要的數據,我們往往採用異步刷盤的方式。有一個項目,其中的日誌組件是自己實現的,採用的就是異步刷盤方式,刷盤的時機是:

  1. ERROR級別的日誌需要立即刷盤;
  2. 數據積累到500條需要立即刷盤;
  3. 存在未刷盤數據,且5秒鐘內未曾刷盤,需要立即刷盤。

這個日誌組件的異步刷盤操作本質上其實就是一種分階段提交。下面我們具體看看用生產者-消費者模式如何實現。在下面的示例代碼中,可以通過調用 info()和error() 方法寫入日誌,這兩個方法都是創建了一個日誌任務LogMsg,並添加到阻塞隊列中,調用 info()和error() 方法的線程是生產者;而真正將日誌寫入文件的是消費者線程,在Logger這個類中,我們只創建了1個消費者線程,在這個消費者線程中,會根據刷盤規則執行刷盤操作,邏輯很簡單,這裏就不贅述了。

class Logger {
  //任務隊列  
  final BlockingQueue<LogMsg> bq = new BlockingQueue<>();
  //flush批量  
  static final int batchSize=500;
  //只需要一個線程寫日誌
  ExecutorService es = Executors.newFixedThreadPool(1);
  
  //啓動寫日誌線程
  void start(){
    File file = File.createTempFile("foo", ".log");
    final FileWriter writer = new FileWriter(file);
    
    this.es.execute(()->{
      try {
        //未刷盤日誌數量
        int curIdx = 0;
        long preFT = System.currentTimeMillis();
        while (true) {
          LogMsg log = bq.poll(5, TimeUnit.SECONDS);
          //寫日誌
          if (log != null) {
            writer.write(log.toString());
            ++curIdx;
          }
          //如果不存在未刷盤數據,則無需刷盤
          if (curIdx <= 0) {
            continue;
          }
          
          //根據規則刷盤
          if (log!=null && log.level==LEVEL.ERROR ||
              curIdx == batchSize ||
              System.currentTimeMillis()-preFT>5000){
            writer.flush();
            curIdx = 0;
            preFT=System.currentTimeMillis();
          }
        }
      }catch(Exception e){
        e.printStackTrace();
      } finally {
        try {
          writer.flush();
          writer.close();
        }catch(IOException e){
          e.printStackTrace();
        }
      }
    });  
  }
  
  //寫INFO級別日誌
  void info(String msg) {
    bq.put(new LogMsg(LEVEL.INFO, msg));
  }
  //寫ERROR級別日誌
  void error(String msg) {
    bq.put(new LogMsg(LEVEL.ERROR, msg));
  }
}

//日誌級別
enum LEVEL {
  INFO, ERROR
}

class LogMsg {
  LEVEL level;
  String msg;
  //省略構造函數實現
  LogMsg(LEVEL lvl, String msg){}
  //省略toString()實現
  String toString(){}
}

總結

Java語言提供的線程池本身就是一種生產者-消費者模式的實現,但是線程池中的線程每次只能從任務隊列中消費一個任務來執行,對於大部分併發場景這種策略都沒有問題。但是有些場景還是需要自己來實現,例如需要批量執行以及分階段提交的場景。

生產者-消費者模式在分佈式計算中的應用也非常廣泛。在分佈式場景下,你可以藉助分佈式消息隊列(MQ)來實現生產者-消費者模式。MQ一般都會支持兩種消息模型,一種是點對點模型,一種是發佈訂閱模型。這兩種模型的區別在於,點對點模型裏一個消息只會被一個消費者消費,和Java的線程池非常類似(Java線程池的任務也只會被一個線程執行);而發佈訂閱模型裏一個消息會被多個消費者消費,本質上是一種消息的廣播,在多線程編程領域,你可以結合觀察者模式實現廣播功能。

Demo

1
public class ProducerAndConsumerClient {

    public static void main(String[] args) {
        final MessageQueue messageQueue = new MessageQueue();
        new ProducerThread(messageQueue, 1).start();
        new ProducerThread(messageQueue, 2).start();
        new ProducerThread(messageQueue, 3).start();
        new ConsumerThread(messageQueue, 1).start();
        new ConsumerThread(messageQueue, 2).start();
    }
}
2
public class MessageQueue {

    private final LinkedList<Message> queue;

    private final static int DEFAULT_MAX_LIMIT = 100;

    private final int limit;

    public MessageQueue() {
        this(DEFAULT_MAX_LIMIT);
    }

    public MessageQueue(final int limit) {
        this.limit = limit;
        this.queue = new LinkedList<>();
    }

    public void put(final Message message) throws InterruptedException {
        synchronized (queue) {
            while (queue.size() > limit) {
                queue.wait();
            }

            queue.addLast(message);
            queue.notifyAll();
        }
    }

    public Message take() throws InterruptedException {
        synchronized (queue) {
            while (queue.isEmpty()) {
                queue.wait();
            }

            Message message = queue.removeFirst();
            queue.notifyAll();
            return message;
        }
    }

    public int getMaxLimit() {
        return this.limit;
    }

    public int getMessageSize() {
        synchronized (queue) {
            return queue.size();
        }
    }
}
3
public class ProducerThread extends Thread {

    private final MessageQueue messageQueue;

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

    private final static AtomicInteger counter = new AtomicInteger(0);

    public ProducerThread(MessageQueue messageQueue, int seq) {
        super("PRODUCER-" + seq);
        this.messageQueue = messageQueue;
    }

    @Override
    public void run() {
        while (true) {
            try {
                Message message = new Message("Message-" + counter.getAndIncrement());
                messageQueue.put(message);
                System.out.println(Thread.currentThread().getName() + " put message " + message.getData());
                Thread.sleep(random.nextInt(1000));
            } catch (InterruptedException e) {
                break;
            }
        }
    }
}
4
public class ConsumerThread extends Thread {

    private final MessageQueue messageQueue;

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

    public ConsumerThread(MessageQueue messageQueue, int seq) {
        super("Consumer-" + seq);
        this.messageQueue = messageQueue;
    }

    @Override
    public void run() {
        while (true) {
            try {
                Message message = messageQueue.take();
                System.out.println(Thread.currentThread().getName() + " take a message " + message.getData());
                Thread.sleep(random.nextInt(1000));
            } catch (InterruptedException e) {
                break;
            }
        }
    }
}
5
public class Message {
    private String data;

    public Message(String data) {
        this.data = data;
    }

    public String getData() {
        return data;
    }
}
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章