Java實現生產者-消費者模型

monkeysayhi發表於2017-11-20

考查Java的併發程式設計時,手寫“生產者-消費者模型”是一個經典問題。有如下幾個考點:

  • 對Java併發模型的理解
  • 對Java併發程式設計介面的熟練程度
  • bug free
  • coding style

JDK版本:oracle java 1.8.0_102

本文主要歸納了4種寫法,閱讀後,最好在白板上練習幾遍,檢查自己是否掌握。這4種寫法或者程式設計介面不同,或者併發粒度不同,但本質是相同的——都是在使用或實現BlockingQueue。

生產者-消費者模型

網上有很多生產者-消費者模型的定義和實現。本文研究最常用的有界生產者-消費者模型,簡單概括如下:

  • 生產者持續生產,直到緩衝區滿,阻塞;緩衝區不滿後,繼續生產
  • 消費者持續消費,直到緩衝區空,阻塞;緩衝區不空後,繼續消費
  • 生產者可以有多個,消費者也可以有多個

可通過如下條件驗證模型實現的正確性:

  • 同一產品的消費行為一定發生在生產行為之後
  • 任意時刻,緩衝區大小不小於0,不大於限制容量

該模型的應用和變種非常多,不贅述。

幾種寫法

準備

面試時可語言說明以下準備程式碼。關鍵部分需要實現,如AbstractConsumer。

下面會涉及多種生產者-消費者模型的實現,可以先抽象出關鍵的介面,並實現一些抽象類:

public interface Consumer {
  void consume() throws InterruptedException;
}
複製程式碼
public interface Producer {
  void produce() throws InterruptedException;
}
複製程式碼
abstract class AbstractConsumer implements Consumer, Runnable {
  @Override
  public void run() {
    while (true) {
      try {
        consume();
      } catch (InterruptedException e) {
        e.printStackTrace();
        break;
      }
    }
  }
}
複製程式碼
abstract class AbstractProducer implements Producer, Runnable {
  @Override
  public void run() {
    while (true) {
      try {
        produce();
      } catch (InterruptedException e) {
        e.printStackTrace();
        break;
      }
    }
  }
}
複製程式碼

不同的模型實現中,生產者、消費者的具體實現也不同,所以需要為模型定義抽象工廠方法:

public interface Model {
  Runnable newRunnableConsumer();

  Runnable newRunnableProducer();
}
複製程式碼

我們將Task作為生產和消費的單位:

public class Task {
  public int no;

  public Task(int no) {
    this.no = no;
  }
}
複製程式碼

如果需求還不明確(這符合大部分工程工作的實際情況),建議邊實現邊抽象,不要“面向未來程式設計”

實現一:BlockingQueue

BlockingQueue的寫法最簡單。核心思想是,把併發和容量控制封裝在緩衝區中。而BlockingQueue的性質天生滿足這個要求。

public class BlockingQueueModel implements Model {
  private final BlockingQueue<Task> queue;

  private final AtomicInteger increTaskNo = new AtomicInteger(0);

  public BlockingQueueModel(int cap) {
    // LinkedBlockingQueue 的佇列是 lazy-init 的,但 ArrayBlockingQueue 在建立時就已經 init
    this.queue = new LinkedBlockingQueue<>(cap);
  }

  @Override
  public Runnable newRunnableConsumer() {
    return new ConsumerImpl();
  }

  @Override
  public Runnable newRunnableProducer() {
    return new ProducerImpl();
  }

  private class ConsumerImpl extends AbstractConsumer implements Consumer, Runnable {
    @Override
    public void consume() throws InterruptedException {
      Task task = queue.take();
      // 固定時間範圍的消費,模擬相對穩定的伺服器處理過程
      Thread.sleep(500 + (long) (Math.random() * 500));
      System.out.println("consume: " + task.no);
    }
  }

  private class ProducerImpl extends AbstractProducer implements Producer, Runnable {
    @Override
    public void produce() throws InterruptedException {
      // 不定期生產,模擬隨機的使用者請求
      Thread.sleep((long) (Math.random() * 1000));
      Task task = new Task(increTaskNo.getAndIncrement());
      System.out.println("produce: " + task.no);
      queue.put(task);
    }
  }

  public static void main(String[] args) {
    Model model = new BlockingQueueModel(3);
    for (int i = 0; i < 2; i++) {
      new Thread(model.newRunnableConsumer()).start();
    }
    for (int i = 0; i < 5; i++) {
      new Thread(model.newRunnableProducer()).start();
    }
  }
}
複製程式碼

擷取前面的一部分輸出:

produce: 0
produce: 4
produce: 2
produce: 3
produce: 5
consume: 0
produce: 1
consume: 4
produce: 7
consume: 2
produce: 8
consume: 3
produce: 6
consume: 5
produce: 9
consume: 1
produce: 10
consume: 7
複製程式碼

由於操作“出隊/入隊+日誌輸出”不是原子的,所以上述日誌的絕對順序與實際的出隊/入隊順序有出入,但對於同一個任務號task.no,其consume日誌一定出現在其produce日誌之後,即:同一任務的消費行為一定發生在生產行為之後。緩衝區的容量留給讀者驗證。符合兩個驗證條件。

BlockingQueue寫法的核心只有兩行程式碼,併發和容量控制都封裝在了BlockingQueue中,正確性由BlockingQueue保證。面試中首選該寫法,自然美觀簡單。

勘誤:

在簡書回覆一個讀者的時候,順道發現了這個問題:生產日誌應放在入隊操作之前,否則同一個task的生產日誌可能出現在消費日誌之後。

// 舊的錯誤程式碼
queue.put(task);
System.out.println("produce: " + task.no);
複製程式碼
// 正確程式碼
System.out.println("produce: " + task.no);
queue.put(task);
複製程式碼

具體來說,生產日誌應放在入隊操作之前,消費日誌應放在出隊操作之後,以保障:

  • 消費執行緒中queue.take()返回之後,對應生產執行緒(生產該task的執行緒)中queue.put()及之前的行為,對於消費執行緒來說都是可見的。

想想為什麼呢?因為我們需要藉助“queue.put()與queue.take()的偏序關係”。其他實現方案分別藉助了條件佇列、鎖的偏序關係,不存在該問題。要解釋這個問題,需要讀者明白可見性和Happens-Before的概念,篇幅所限,暫時不多解釋。

PS:舊程式碼沒出現這個問題,是因為消費者列印消費日誌之前,sleep了500+ms,而恰巧競爭不激烈,這個時間一般足以讓“滯後”生產日誌列印完成(但不保證)。


順道說明一下,猴子現在主要在個人部落格、簡書、掘金和CSDN上發文章,搜尋“猴子007”或“程式猿說你好”都能找到。但個人精力有限,部分勘誤難免忘記同步到某些地方(甚至連新文章都不同步了T_T),只能保證個人部落格是最新的,還望理解。

寫文章不是為了出名,一方面希望整理自己的學習成果,一方面希望有更多人能幫助猴子糾正學習過程中的錯誤。如果能認識一些志同道合的朋友,一起提高就更好了。所以希望各位轉載的時候,一定帶著猴子個人部落格末尾的轉載宣告。需要聯絡猴子的話,簡書或郵件都可以。

文章水平不高,就不奢求有人能打賞鼓勵我這潑猴了T_T

實現二:wait && notify

如果不能將併發與容量控制都封裝在緩衝區中,就只能由消費者與生產者完成。最簡單的方案是使用樸素的wait && notify機制。

public class WaitNotifyModel implements Model {
  private final Object BUFFER_LOCK = new Object();
  private final Queue<Task> buffer = new LinkedList<>();
  private final int cap;

  private final AtomicInteger increTaskNo = new AtomicInteger(0);

  public WaitNotifyModel(int cap) {
    this.cap = cap;
  }

  @Override
  public Runnable newRunnableConsumer() {
    return new ConsumerImpl();
  }

  @Override
  public Runnable newRunnableProducer() {
    return new ProducerImpl();
  }

  private class ConsumerImpl extends AbstractConsumer implements Consumer, Runnable {
    @Override
    public void consume() throws InterruptedException {
      synchronized (BUFFER_LOCK) {
        while (buffer.size() == 0) {
          BUFFER_LOCK.wait();
        }
        Task task = buffer.poll();
        assert task != null;
        // 固定時間範圍的消費,模擬相對穩定的伺服器處理過程
        Thread.sleep(500 + (long) (Math.random() * 500));
        System.out.println("consume: " + task.no);
        BUFFER_LOCK.notifyAll();
      }
    }
  }

  private class ProducerImpl extends AbstractProducer implements Producer, Runnable {
    @Override
    public void produce() throws InterruptedException {
      // 不定期生產,模擬隨機的使用者請求
      Thread.sleep((long) (Math.random() * 1000));
      synchronized (BUFFER_LOCK) {
        while (buffer.size() == cap) {
          BUFFER_LOCK.wait();
        }
        Task task = new Task(increTaskNo.getAndIncrement());
        buffer.offer(task);
        System.out.println("produce: " + task.no);
        BUFFER_LOCK.notifyAll();
      }
    }
  }

  public static void main(String[] args) {
    Model model = new WaitNotifyModel(3);
    for (int i = 0; i < 2; i++) {
      new Thread(model.newRunnableConsumer()).start();
    }
    for (int i = 0; i < 5; i++) {
      new Thread(model.newRunnableProducer()).start();
    }
  }
}
複製程式碼

驗證方法同上。

樸素的wait && notify機制不那麼靈活,但足夠簡單。synchronized、wait、notifyAll的用法可參考【Java併發程式設計】之十:使用wait/notify/notifyAll實現執行緒間通訊的幾點重要說明著重理解喚醒與鎖競爭的區別

實現三:簡單的Lock && Condition

我們要保證理解wait && notify機制。實現時可以使用Object類提供的wait()方法與notifyAll()方法,但更推薦的方式是使用java.util.concurrent包提供的Lock && Condition

public class LockConditionModel1 implements Model {
  private final Lock BUFFER_LOCK = new ReentrantLock();
  private final Condition BUFFER_COND = BUFFER_LOCK.newCondition();
  private final Queue<Task> buffer = new LinkedList<>();
  private final int cap;

  private final AtomicInteger increTaskNo = new AtomicInteger(0);

  public LockConditionModel1(int cap) {
    this.cap = cap;
  }

  @Override
  public Runnable newRunnableConsumer() {
    return new ConsumerImpl();
  }

  @Override
  public Runnable newRunnableProducer() {
    return new ProducerImpl();
  }

  private class ConsumerImpl extends AbstractConsumer implements Consumer, Runnable {
    @Override
    public void consume() throws InterruptedException {
      BUFFER_LOCK.lockInterruptibly();
      try {
        while (buffer.size() == 0) {
          BUFFER_COND.await();
        }
        Task task = buffer.poll();
        assert task != null;
        // 固定時間範圍的消費,模擬相對穩定的伺服器處理過程
        Thread.sleep(500 + (long) (Math.random() * 500));
        System.out.println("consume: " + task.no);
        BUFFER_COND.signalAll();
      } finally {
        BUFFER_LOCK.unlock();
      }
    }
  }

  private class ProducerImpl extends AbstractProducer implements Producer, Runnable {
    @Override
    public void produce() throws InterruptedException {
      // 不定期生產,模擬隨機的使用者請求
      Thread.sleep((long) (Math.random() * 1000));
      BUFFER_LOCK.lockInterruptibly();
      try {
        while (buffer.size() == cap) {
          BUFFER_COND.await();
        }
        Task task = new Task(increTaskNo.getAndIncrement());
        buffer.offer(task);
        System.out.println("produce: " + task.no);
        BUFFER_COND.signalAll();
      } finally {
        BUFFER_LOCK.unlock();
      }
    }
  }

  public static void main(String[] args) {
    Model model = new LockConditionModel1(3);
    for (int i = 0; i < 2; i++) {
      new Thread(model.newRunnableConsumer()).start();
    }
    for (int i = 0; i < 5; i++) {
      new Thread(model.newRunnableProducer()).start();
    }
  }
}
複製程式碼

該寫法的思路與實現二的思路完全相同,僅僅將鎖與條件變數換成了Lock和Condition。

實現四:更高併發效能的Lock && Condition

現在,如果做一些實驗,你會發現,實現一的併發效能高於實現二、三。暫且不關心BlockingQueue的具體實現,來分析看如何優化實現三(與實現二的思路相同,效能相當)的效能。

分析實現三的瓶頸

最好的查證方法是記錄方法執行時間,這樣可以直接定位到真正的瓶頸。但此問題較簡單,我們直接用“瞪眼法”分析。

實現三的併發瓶頸很明顯,因為在鎖 BUFFER_LOCK 看來,任何消費者執行緒與生產者執行緒都是一樣的。換句話說,同一時刻,最多隻允許有一個執行緒(生產者或消費者,二選一)操作緩衝區 buffer。

而實際上,如果緩衝區是一個佇列的話,“生產者將產品入隊”與“消費者將產品出隊”兩個操作之間沒有同步關係,可以在隊首出隊的同時,在隊尾入隊。理想效能可提升至實現三的兩倍

去掉這個瓶頸

那麼思路就簡單了:需要兩個鎖 CONSUME_LOCKPRODUCE_LOCKCONSUME_LOCK控制消費者執行緒併發出隊,PRODUCE_LOCK控制生產者執行緒併發入隊;相應需要兩個條件變數NOT_EMPTYNOT_FULLNOT_EMPTY負責控制消費者執行緒的狀態(阻塞、執行),NOT_FULL負責控制生產者執行緒的狀態(阻塞、執行)。以此讓優化消費者與消費者(或生產者與生產者)之間是序列的;消費者與生產者之間是並行的。

public class LockConditionModel2 implements Model {
  private final Lock CONSUME_LOCK = new ReentrantLock();
  private final Condition NOT_EMPTY = CONSUME_LOCK.newCondition();
  private final Lock PRODUCE_LOCK = new ReentrantLock();
  private final Condition NOT_FULL = PRODUCE_LOCK.newCondition();

  private final Buffer<Task> buffer = new Buffer<>();
  private AtomicInteger bufLen = new AtomicInteger(0);

  private final int cap;

  private final AtomicInteger increTaskNo = new AtomicInteger(0);

  public LockConditionModel2(int cap) {
    this.cap = cap;
  }

  @Override
  public Runnable newRunnableConsumer() {
    return new ConsumerImpl();
  }

  @Override
  public Runnable newRunnableProducer() {
    return new ProducerImpl();
  }

  private class ConsumerImpl extends AbstractConsumer implements Consumer, Runnable {
    @Override
    public void consume() throws InterruptedException {
      int newBufSize = -1;

      CONSUME_LOCK.lockInterruptibly();
      try {
        while (bufLen.get() == 0) {
          System.out.println("buffer is empty...");
          NOT_EMPTY.await();
        }
        Task task = buffer.poll();
        newBufSize = bufLen.decrementAndGet();
        assert task != null;
        // 固定時間範圍的消費,模擬相對穩定的伺服器處理過程
        Thread.sleep(500 + (long) (Math.random() * 500));
        System.out.println("consume: " + task.no);
        if (newBufSize > 0) {
          NOT_EMPTY.signalAll();
        }
      } finally {
        CONSUME_LOCK.unlock();
      }

      if (newBufSize < cap) {
        PRODUCE_LOCK.lockInterruptibly();
        try {
          NOT_FULL.signalAll();
        } finally {
          PRODUCE_LOCK.unlock();
        }
      }
    }
  }

  private class ProducerImpl extends AbstractProducer implements Producer, Runnable {
    @Override
    public void produce() throws InterruptedException {
      // 不定期生產,模擬隨機的使用者請求
      Thread.sleep((long) (Math.random() * 1000));

      int newBufSize = -1;

      PRODUCE_LOCK.lockInterruptibly();
      try {
        while (bufLen.get() == cap) {
          System.out.println("buffer is full...");
          NOT_FULL.await();
        }
        Task task = new Task(increTaskNo.getAndIncrement());
        buffer.offer(task);
        newBufSize = bufLen.incrementAndGet();
        System.out.println("produce: " + task.no);
        if (newBufSize < cap) {
          NOT_FULL.signalAll();
        }
      } finally {
        PRODUCE_LOCK.unlock();
      }

      if (newBufSize > 0) {
        CONSUME_LOCK.lockInterruptibly();
        try {
          NOT_EMPTY.signalAll();
        } finally {
          CONSUME_LOCK.unlock();
        }
      }
    }
  }

  private static class Buffer<E> {
    private Node head;
    private Node tail;

    Buffer() {
      // dummy node
      head = tail = new Node(null);
    }

    public void offer(E e) {
      tail.next = new Node(e);
      tail = tail.next;
    }

    public E poll() {
      head = head.next;
      E e = head.item;
      head.item = null;
      return e;
    }

    private class Node {
      E item;
      Node next;

      Node(E item) {
        this.item = item;
      }
    }
  }

  public static void main(String[] args) {
    Model model = new LockConditionModel2(3);
    for (int i = 0; i < 2; i++) {
      new Thread(model.newRunnableConsumer()).start();
    }
    for (int i = 0; i < 5; i++) {
      new Thread(model.newRunnableProducer()).start();
    }
  }
複製程式碼

需要注意的是,由於需要同時在UnThreadSafe的緩衝區 buffer 上進行消費與生產,我們不能使用實現二、三中使用的佇列了,需要自己實現一個簡單的緩衝區 Buffer。Buffer要滿足以下條件:

  • 在頭部出隊,尾部入隊
  • 在poll()方法中只操作head
  • 在offer()方法中只操作tail

還能進一步優化嗎

我們已經優化掉了消費者與生產者之間的瓶頸,還能進一步優化嗎?

如果可以,必然是繼續優化消費者與消費者(或生產者與生產者)之間的併發效能。然而,消費者與消費者之間必須是序列的,因此,併發模型上已經沒有地方可以繼續優化了。

不過在具體的業務場景中,一般還能夠繼續優化。如:

  • 併發規模中等,可考慮使用CAS代替重入鎖
  • 模型上不能優化,但一個消費行為或許可以進一步拆解、優化,從而降低消費的延遲
  • 一個佇列的併發效能達到了極限,可採用“多個佇列”(如分散式訊息佇列等)

4種實現的本質

文章開頭說:這4種寫法的本質相同——都是在使用或實現BlockingQueue。實現一直接使用BlockingQueue,實現四實現了簡單的BlockingQueue,而實現二、三則實現了退化版的BlockingQueue(效能降低一半)。

實現一使用的BlockingQueue實現類是LinkedBlockingQueue,給出其原始碼閱讀對照,寫的不難:

public class LinkedBlockingQueue<E> extends AbstractQueue<E>
        implements BlockingQueue<E>, java.io.Serializable {
...
/** Lock held by take, poll, etc */
    private final ReentrantLock takeLock = new ReentrantLock();
    /** Wait queue for waiting takes */
    private final Condition notEmpty = takeLock.newCondition();
    /** Lock held by put, offer, etc */
    private final ReentrantLock putLock = new ReentrantLock();
    /** Wait queue for waiting puts */
    private final Condition notFull = putLock.newCondition();
...
    /**
     * Signals a waiting take. Called only from put/offer (which do not
     * otherwise ordinarily lock takeLock.)
     */
    private void signalNotEmpty() {
        final ReentrantLock takeLock = this.takeLock;
        takeLock.lock();
        try {
            notEmpty.signal();
        } finally {
            takeLock.unlock();
        }
    }

    /**
     * Signals a waiting put. Called only from take/poll.
     */
    private void signalNotFull() {
        final ReentrantLock putLock = this.putLock;
        putLock.lock();
        try {
            notFull.signal();
        } finally {
            putLock.unlock();
        }
    }

    /**
     * Links node at end of queue.
     *
     * @param node the node
     */
    private void enqueue(Node<E> node) {
        // assert putLock.isHeldByCurrentThread();
        // assert last.next == null;
        last = last.next = node;
    }

    /**
     * Removes a node from head of queue.
     *
     * @return the node
     */
    private E dequeue() {
        // assert takeLock.isHeldByCurrentThread();
        // assert head.item == null;
        Node<E> h = head;
        Node<E> first = h.next;
        h.next = h; // help GC
        head = first;
        E x = first.item;
        first.item = null;
        return x;
    }
...
    /**
     * Creates a {@code LinkedBlockingQueue} with the given (fixed) capacity.
     *
     * @param capacity the capacity of this queue
     * @throws IllegalArgumentException if {@code capacity} is not greater
     *         than zero
     */
    public LinkedBlockingQueue(int capacity) {
        if (capacity <= 0) throw new IllegalArgumentException();
        this.capacity = capacity;
        last = head = new Node<E>(null);
    }
...
    /**
     * Inserts the specified element at the tail of this queue, waiting if
     * necessary for space to become available.
     *
     * @throws InterruptedException {@inheritDoc}
     * @throws NullPointerException {@inheritDoc}
     */
    public void put(E e) throws InterruptedException {
        if (e == null) throw new NullPointerException();
        // Note: convention in all put/take/etc is to preset local var
        // holding count negative to indicate failure unless set.
        int c = -1;
        Node<E> node = new Node<E>(e);
        final ReentrantLock putLock = this.putLock;
        final AtomicInteger count = this.count;
        putLock.lockInterruptibly();
        try {
            /*
             * Note that count is used in wait guard even though it is
             * not protected by lock. This works because count can
             * only decrease at this point (all other puts are shut
             * out by lock), and we (or some other waiting put) are
             * signalled if it ever changes from capacity. Similarly
             * for all other uses of count in other wait guards.
             */
            while (count.get() == capacity) {
                notFull.await();
            }
            enqueue(node);
            c = count.getAndIncrement();
            if (c + 1 < capacity)
                notFull.signal();
        } finally {
            putLock.unlock();
        }
        if (c == 0)
            signalNotEmpty();
    }
...
    public E take() throws InterruptedException {
        E x;
        int c = -1;
        final AtomicInteger count = this.count;
        final ReentrantLock takeLock = this.takeLock;
        takeLock.lockInterruptibly();
        try {
            while (count.get() == 0) {
                notEmpty.await();
            }
            x = dequeue();
            c = count.getAndDecrement();
            if (c > 1)
                notEmpty.signal();
        } finally {
            takeLock.unlock();
        }
        if (c == capacity)
            signalNotFull();
        return x;
    }
...
}
複製程式碼

還存在非常多的寫法,如訊號量Semaphore,也很常見(本科作業系統教材中的生產者-消費者模型就是用訊號量實現的)。不過追究過多了就好像在糾結茴香豆的寫法一樣,本文不繼續探討。

總結

實現一必須掌握,實現四至少要能清楚表述;實現二、三掌握一個即可。


本文連結:Java實現生產者-消費者模型
作者:猴子007
出處:monkeysayhi.github.io
本文基於 知識共享署名-相同方式共享 4.0 國際許可協議釋出,歡迎轉載,演繹或用於商業目的,但是必須保留本文的署名及連結。

相關文章