Java Internals & Concurrency/Blocking queue & producer–consumer: tách nhịp sản xuất–tiêu thụ
25/75
Bài 25 / 75~15 phútConcurrency cơ bảnMiễn phí lượt xem

Blocking queue & producer–consumer: tách nhịp sản xuất–tiêu thụ

BlockingQueue và mẫu producer–consumer: put/take, bounded vs unbounded, backpressure, SynchronousQueue và các biến thể, shutdown sạch bằng poison pill.

TL;DR: BlockingQueue là hàng đợi thread-safe biết chờ: take() chặn khi rỗng, put() chặn khi đầy — hai năng lực làm nên xương sống của mẫu producer–consumer. Hàng đợi bounded biến put/offer thành van backpressure: producer nhanh hơn consumer kéo dài sẽ bị ghì lại, thay vì để hàng đợi phình tới OutOfMemoryError. API có bốn kiểu hành xử cho mỗi thao tác — ném exception, trả tín hiệu, chặn vô hạn, chặn có timeout — chọn theo điều bạn muốn khi không làm được ngay. Object đi qua hàng đợi còn được safe publication miễn phí, và shutdown sạch bằng poison pill (N consumer cần N viên) hoặc interrupt. Pitfall lớn nhất: hàng đợi unbounded không xóa giới hạn hệ thống, nó chỉ giấu giới hạn đó đi rồi đổi vài request bị từ chối lấy một cú OOM lúc tải đỉnh.

1. Vì sao producer và consumer cần một hàng đợi biết chờ?

Các concurrent collection ở bài trước — ConcurrentHashMap, ConcurrentLinkedQueue — thả vào là dùng, tự lo phần đồng bộ khó. Nhưng chúng có điểm chung: lấy mà không có gì thì trả về ngay (poll() trên hàng đợi rỗng cho null lập tức). Trong rất nhiều bài toán, chính cái chờ đó mới là thứ ta cần.

Mẫu producer–consumer tách một hoạt động thành hai vai — bên tạo việc và bên xử lý việc — nối nhau bằng một hàng đợi ở giữa. Hai bên không gọi trực tiếp nhau, không cần biết nhau, không cần cùng số lượng. Hình ảnh gần nhất là quầy pha chế quán cà phê đông khách: thu ngân kẹp phiếu order vào kẹp, barista lấy phiếu trên cùng rồi pha.

Producer
Producer
Queue
BlockingQueue
Consumer
Consumer
Quán cà phêProducer–Consumer
Thu ngân nhận orderProducer tạo task
Kẹp order ở giữaBlockingQueue
Barista lấy phiếu rồi phaConsumer take() rồi xử lý
Kẹp đầy → ngừng nhận kháchQueue đầy → put() chặn (backpressure)
Kẹp rỗng → barista đứng chờQueue rỗng → take() chặn, không đốt CPU

Tách rời như vậy không chỉ cho gọn: hai bên được suy luận, kiểm thử và điều chỉnh độc lập, và hàng đợi ở giữa trở thành nơi duy nhất để áp đặt giới hạn tải. Nhưng hai bên gần như không bao giờ cùng tốc độ — hai tình huống đối xứng nảy sinh: chờ khi đầy, chờ khi rỗng. Đó chính là hai năng lực BlockingQueue đóng gói sẵn; tự viết bằng List cộng wait/notify là cái bẫy kinh điển đã mổ ở bài 09.

2. BlockingQueue API: bốn kiểu hành xử

BlockingQueue<E> nằm trong java.util.concurrent, có từ Java 5, mở rộng Queue với các thao tác biết chờ. Điều đáng nhớ: mỗi thao tác cơ bản — thêm vào, lấy ra — đều có nhiều biến thể tùy theo bạn muốn nó hành xử thế nào khi không làm được ngay.

Khi không làm được ngayThêm vào (queue đầy)Lấy ra (queue rỗng)
Ném exceptionadd(e)remove()
Trả tín hiệuoffer(e) trả falsepoll() trả null
Chặn vô hạnput(e)take()
Chặn có timeoutoffer(e, timeout, unit)poll(timeout, unit)
BlockingQueue<Task> queue = new ArrayBlockingQueue<>(1000);

// Producer: chan neu queue day → backpressure tu nhien
queue.put(task);

// Consumer: chan neu queue rong → khong busy-wait, ngu toi khi co viec
Task task = queue.take();

puttake là cặp mạnh nhất: toàn bộ phần phối hợp "đầy thì chặn, rỗng thì chặn, có biến động thì đánh thức đúng bên" nằm gọn trong hai lời gọi, thread nằm chờ không tốn CPU. Cả hai ném InterruptedException — điểm ta quay lại ở phần shutdown.

Cơ chế bên dưới không huyền bí: source ArrayBlockingQueue chính là pattern một ReentrantLock + hai Condition (notEmpty/notFull) mà bài ReentrantLock & Condition đã dựng tay. BlockingQueue không thay thế kiến thức lock/condition, nó là kiến thức đó đóng gói sẵn. Còn khi chặn vô hạn quá rủi ro, biến thể timeout ở hàng cuối bảng là van xả — capstone ở mục 5 dùng đúng nó.

Một chi tiết hay bị bỏ sót: đặt object vào BlockingQueue còn lo luôn safe publication — bỏ vào và lấy ra nối nhau bằng happens-before (Immutability), nên object dựng đúng cách hiển thị lành lặn với consumer mà không cần đồng bộ thêm: hàng đợi vừa truyền dữ liệu, vừa truyền memory visibility.

3. Bounded, unbounded và các biến thể

3.1 ArrayBlockingQueueLinkedBlockingQueue

ArrayBlockingQueue được nâng đỡ bởi một mảng kích thước cố định chốt lúc khởi tạo — nó luôn bounded. LinkedBlockingQueue được nâng đỡ bởi node liên kết: bounded nếu truyền capacity, unbounded nếu không — khi đó trần mặc định là Integer.MAX_VALUE, lớn tới mức thực tế coi như vô hạn.

BlockingQueue<Task> array     = new ArrayBlockingQueue<>(1000);    // tran cung 1000
BlockingQueue<Task> bounded   = new LinkedBlockingQueue<>(1000);   // co tran
BlockingQueue<Task> unbounded = new LinkedBlockingQueue<>();       // "vo han" — coi chung

ArrayBlockingQueue dùng một khóa cho cả hai đầu; LinkedBlockingQueue tách khóa hai đầu nên throughput dưới tải cao thường nhỉnh hơn — nhưng đó là tối ưu hiệu năng, quyết định quan trọng hơn nằm ở chữ "bounded".

3.2 Bounded nghĩa là có backpressure

Hàng đợi bounded biến put thành một van an toàn. Khi đầy, producer gọi put bị chặn lại — nó không thể chạy nhanh hơn consumer kéo dài, vì đến một điểm chính hàng đợi sẽ ghì nó lại. Đây là backpressure ở dạng tinh khiết nhất: không phải viết một dòng logic kiểm soát tải nào, chỉ cần chọn capacity hợp lý.

Hàng đợi bounded và backpressureProducer bỏ task vào một hàng đợi sức chứa ba. Khi đầy, lời gọi put bị chặn và producer ngủ; khi consumer lấy ra một task, có chỗ trống và producer được đánh thức để tiếp tục.Producerput(task)BlockingQueue · 0/3Consumertake()
bước 0/6
Hàng đợi bounded, sức chứa 3. Bấm “Chạy” (hoặc “Bước”) để xem cơ chế backpressure.

Producer không cần dòng code nào hỏi "consumer theo kịp chưa": chính lời gọi put bị chặn là câu trả lời.

3.3 Cái bẫy unbounded: backpressure biến mất, thay bằng OOM

Một LinkedBlockingQueue không capacity nghe có vẻ tiện — không bao giờ lo put bị chặn. Chính sự tiện đó là cái bẫy: backpressure biến mất, producer nhanh hơn consumer kéo dài thì hàng đợi phình mãi — mỗi phần tử chưa xử lý là một object còn reachable, heap căng dần tới OutOfMemoryError. Lỗi này im lặng lúc tải thấp (hàng đợi luôn ngắn, mọi test pass) rồi lộ đúng lúc tải đỉnh. Unbounded không loại bỏ giới hạn hệ thống; nó giấu đi rồi đổi một sự cố nhẹ (vài request bị từ chối) lấy một sự cố nặng (cả tiến trình chết). Hãy hỏi "khi đầy thì sao?" lúc thiết kế.

3.4 Các biến thể cho ràng buộc đặc thù

java.util.concurrent còn vài cài đặt giải đúng những bài toán riêng — biết chúng tồn tại để khỏi tự dựng lại:

Biến thểCơ chế phân biệtKhi nào dùng
PriorityBlockingQueueLấy ra theo Comparator/Comparable thay vì FIFO; unbounded về logic nên vẫn canh OOMViệc không bình đẳng: request VIP đến sau vẫn xử lý trước
SynchronousQueueKhông chứa gì, kể cả một chỗ: mỗi put phải chờ một take — điểm hẹn trao tay trực tiếpHandoff; chính là work queue bên trong Executors.newCachedThreadPool()
DelayQueuePhần tử cài Delayed; take chỉ trả khi delay đã hết, chưa "chín" thì vẫn chờCache có TTL, retry có backoff, task hẹn giờ
LinkedTransferQueue (Java 7)Thêm transfer(e): producer chờ tới khi consumer thực sự nhận; lock-free ở fast pathKhi cần chắc chắn phần tử đã có người nhận
BlockingDeque (Java 6)Chặn hai đầu (LinkedBlockingDeque): bỏ vào / lấy ra ở cả đầu lẫn cuốiNền cho work stealing: worker rảnh "trộm" việc từ đầu kia deque của worker khác

Phần lớn thời gian, một queue bounded thường là đủ; bảng trên để khi bài toán có ràng buộc đặc thù, ta nhận ra ngay đã có công cụ sẵn.

4. Shutdown sạch: poison pill và interrupt

Consumer điển hình là vòng lặp while quanh take, mà take chặn vô hạn khi hàng đợi rỗng. Vậy làm sao bảo một consumer đang nằm chờ ở take rằng "hết việc rồi, về đi"?

4.1 Poison pill

Cách kinh điển nhất là poison pill: một phần tử đặc biệt, không phải việc thật, mà là tín hiệu "dừng lại". Producer bỏ nó vào hàng đợi như phần tử cuối cùng; consumer take ra mà thấy nó thì thoát vòng lặp.

final Task POISON = new Task();          // sentinel, khong mang du lieu that

// Consumer
void consume() throws InterruptedException {
    while (true) {
        Task t = queue.take();          // chan toi khi co phan tu
        if (t == POISON) break;         // gap pill → dung sach
        process(t);
    }
}
queue.put(POISON);                      // producer: rai pill khi het viec that

Poison pill tôn trọng thứ tự hàng đợi: consumer xử lý hết việc thật nằm trước pill rồi mới dừng. N consumer cần N viên (mỗi viên dừng đúng một consumer), và producer phải ngừng bỏ việc thật trước khi rải pill — pill phải là phần tử cuối cùng. Nó chỉ gọn khi số bên biết trước; số lượng động thì interrupt tự nhiên hơn.

4.2 Interrupt khi take đang chặn

takeput ném InterruptedException khi bị interrupt lúc đang chặn, ta dừng consumer bằng cách interrupt thread của nó: lời gọi take đang chờ bật dậy bằng exception đó, consumer dùng làm tín hiệu thoát. Điều quan trọng là xử lý đúng theo cooperative cancellation ở bài Thread API và vòng đời: hoặc ném tiếp lên trên, hoặc khôi phục cờ interrupt bằng Thread.currentThread().interrupt() trước khi thoát — nuốt im lặng làm mất tín hiệu hủy.

Capstone TicketFlow dùng đúng pattern này. Hàng đợi ở đây mang FutureTask — "gói việc" vừa chạy được vừa giữ kết quả trả về; định nghĩa đầy đủ ở mục 5.

// BookingWorker — consumer side, capstone v2
@Override
public void run() {
    while (running) {
        FutureTask<BookingResult> task;
        try {
            task = queue.take();                       // chan toi khi co task
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();        // khoi phuc co roi thoat sach
            return;
        }
        task.run();
    }
}

Worker chạy tới khi cờ running (một volatile boolean) bị tắt, hoặc tới khi thread bị interrupt giữa lúc đang take — cả hai con đường đều dẫn ra khỏi vòng lặp có trật tự. Cờ runningvolatile để thay đổi của thread gọi stop() hiển thị kịp với worker — đúng cái visibility mà bài volatile & synchronized đã nhấn mạnh.

5. Capstone: pipeline đặt vé qua BlockingQueue

Ở các version trước, servlet thread gọi thẳng BookingService.book — trộn việc nhận request với việc xử lý request, không có chỗ áp giới hạn tải. Version v2 tách hai vai bằng một BlockingQueue bounded: BookingGateway (producer) gói business logic vào FutureTask, đẩy vào hàng đợi, trả Future cho caller chờ qua future.get(timeout).

💡 Future và FutureTask — định nghĩa nhanh

Future là "phiếu hẹn kết quả": caller gọi future.get(timeout) để chờ. FutureTaskRunnable bọc quanh Callable — worker gọi task.run() chạy logic, kết quả (hoặc exception) giữ bên trong cho ai cầm Future. Chi tiết ở bài Future & CompletableFuture; ở đây chỉ cần hiểu nó tách "gửi việc" khỏi "nhận kết quả".

// BookingGateway — producer side, capstone v2
public Future<BookingResult> submit(BookingRequest req) {
    FutureTask<BookingResult> task = new FutureTask<>(() -> {
        try { return BookingResult.ok(service.book(req.eventId(), req.userId())); }
        catch (SoldOutException e) { return BookingResult.fail("Sold out: " + req.eventId()); }
    });
    boolean accepted;
    try {
        accepted = queue.offer(task, offerTimeoutMillis, TimeUnit.MILLISECONDS);
    } catch (InterruptedException e) {
        Thread.currentThread().interrupt();
        throw new RejectedException("Interrupted while submitting");
    }
    if (!accepted) throw new RejectedException("Queue full, try again");   // backpressure thanh tin hieu
    return task;
}

Điểm thiết kế đáng chú ý nhất: gateway dùng offer có timeout chứ không phải put, vì servlet thread không được treo vô hạn khi còn nợ client một response. Hết offerTimeoutMillis mà hàng đợi vẫn đầy, offer trả false, gateway biến trạng thái "đầy" thành RejectedException rõ ràng — "thử lại sau" gửi ngược về client thay vì để task chất đống về phía OOM.

Phía consumer là BookingWorker (mục 4.2): nhiều worker cùng take rồi chạy FutureTask — tăng worker là nâng throughput mà không động tới gateway. FutureTask hấp thụ exception rồi lan qua future.get() nên một task hỏng không giết worker; hàng đợi chỉ định hình lại nhịp gọi logic, không đổi tính đúng đắn, mà ta không viết một dòng wait/notify nào.

6. Liên hệ các bài khác

  • Thread API và vòng đời — interrupt là cơ chế shutdown thứ hai của pipeline; bài đó giải thích vì sao phải khôi phục cờ interrupt.
  • Immutability — safe publication mà hàng đợi cho "miễn phí" được dựng nền ở đó; phần tử immutable đi qua queue là tổ hợp an toàn nhất.
  • ReentrantLock & ConditionArrayBlockingQueue chính là pattern một lock + hai condition (notFull/notEmpty) của bài đó, đóng gói sẵn.
  • Synchronizers — họ công cụ điều phối tiến độ không kèm dữ liệu; BlockingQueue cũng là một synchronizer kiêm vai chứa dữ liệu.
  • Executor & thread pool — thread pool là producer–consumer hoàn chỉnh: submit (producer), worker thread (consumer), work queue ở giữa — đúng kiến trúc capstone vừa dựng tay.

7. 📚 Deep Dive Oracle

📚 Deep Dive Oracle

Spec / reference chính thức:

Ghi chú: đọc phần "Usage example" trong javadoc BlockingQueue để thấy pattern producer–consumer chuẩn của Oracle.

8. Tóm tắt

  • BlockingQueue tách nhịp sản xuất khỏi nhịp tiêu thụ: lấy khi rỗng thì chặn, bỏ vào khi đầy thì chặn. API có bốn kiểu hành xử mỗi thao tác — ném exception (add/remove), trả tín hiệu (offer/poll), chặn vô hạn (put/take), chặn có timeout.
  • Bounded là nơi backpressure sống. Unbounded chỉ giấu giới hạn hệ thống đi rồi đổi một sự cố nhẹ lấy một cú OOM lúc tải đỉnh — mặc định nên bounded. Biến thể chuyên biệt (Priority/Synchronous/Delay/Transfer/Deque) dùng khi có ràng buộc riêng.
  • Shutdown sạch dùng poison pill (gọn khi số bên cố định) hoặc interrupt (tự nhiên khi số bên động) — đừng nuốt im lặng InterruptedException.
  • Bỏ object vào hàng đợi cũng lo luôn safe publication: object hiển thị lành lặn với consumer, không cần đồng bộ thêm.

Bài kế tiếp chuyển sang họ công cụ chỉ điều phối tiến độ (không kèm dữ liệu) — cho thread chờ nhau, giới hạn số thread vào một vùng, hẹn gặp tại một điểm chung: các synchronizer.

9. Tự kiểm tra

Tự kiểm tra
0/5 câu đã trả lời
  1. Q1
    offer(e, timeout, unit) khác put(e) thế nào, và vì sao gateway phục vụ servlet thread nên chọn offer có timeout?
  2. Q2
    Vì sao gần như mọi hàng đợi đứng trên đường đi của tải sản xuất nên là bounded? Chuyện gì xảy ra với hàng đợi unbounded khi producer nhanh hơn consumer kéo dài?
  3. Q3
    Pipeline có 4 consumer cùng rút từ một queue. Vì sao muốn dừng sạch bằng poison pill phải bỏ vào đúng 4 viên, và vì sao producer phải ngừng bỏ việc thật trước khi rải pill?
  4. Q4
    Producer dựng một object rồi bỏ vào BlockingQueue, consumer lấy ra dùng. Vì sao consumer thấy object lành lặn đầy đủ mà không cần synchronized hay volatile nào thêm?
  5. Q5
    Consumer đang nằm chặn ở take(). Làm sao dừng nó không cần poison pill, và vì sao trong catch phải gọi Thread.currentThread().interrupt() trước khi thoát?

Bài tiếp theo: Synchronizers — điều phối tiến độ giữa nhiều thread

Bài này đáng gửi cho bạn học cùng?

Copy link đã gắn nguồn — dán group, chat, hoặc LinkedIn.

Bài này có giúp bạn hiểu bản chất không?

Hỏi đáp về bài này

Chưa có câu hỏi

Đặt câu hỏi

Có gì chưa rõ trong bài? Đặt câu hỏi đầu tiên — câu trả lời từ cộng đồng giúp bạn (và người sau).

Đặt câu hỏi đầu tiên

Bài tiếp theo

Synchronizers: CountDownLatch, CyclicBarrier, Semaphore