Java Internals & Concurrency/Synchronizers: CountDownLatch, CyclicBarrier, Semaphore
26/75
Bài 26 / 75~13 phútConcurrency cơ bảnMiễn phí lượt xem

Synchronizers: CountDownLatch, CyclicBarrier, Semaphore

Ba synchronizer đếm để điều phối tiến độ giữa nhiều thread: CountDownLatch cho sự kiện một lần, CyclicBarrier cho nhóm hẹn lặp, Semaphore giới hạn truy cập đồng thời.

TL;DR: Synchronizer điều phối tiến độ giữa các thread — ai chờ ai, ai được đi — khác với lock/atomic vốn bảo vệ dữ liệu. Ba cái hay gặp nhất đều là máy đếm. CountDownLatch cho sự kiện một lần: đếm về 0 rồi mở vĩnh viễn, không reset — người chờ (await) tách khỏi người báo (countDown). CyclicBarrier cho một nhóm cố định hẹn nhau lặp lại tại các mốc, kèm barrier action giữa hai pha; một thành viên hỏng thì cả nhóm nhận BrokenBarrierException. Semaphore giới hạn số lượng truy cập đồng thời bằng permit không có chủ — acquire/release phải đi cặp qua try/finally kẻo permit rò rỉ. Chọn đúng cái theo hình dạng bài toán, đừng dựng lại bằng wait/notify.

1. Điều phối tiến độ khác bảo vệ dữ liệu thế nào?

Bài trước khép lại ở BlockingQueue, một component mà giá trị nằm ở chỗ nó block: put chờ khi hàng đầy, take chờ khi rỗng. Cái queue ấy làm hai việc cùng lúc — vừa chứa dữ liệu an toàn, vừa điều phối tiến độ, quyết định thread nào đi tiếp, thread nào dừng đợi. Hai việc đó là hai bài toán khác nhau; BlockingQueue chỉ tình cờ gộp cả hai.

Từ bài Thread safety tới giờ ta dồn sức cho loại thứ nhất: nhiều thread cùng chạm một mẩu dữ liệu mà không phá invariant. Loại thứ hai khác hẳn: cái cần sắp xếp là nhịp đi của các thread — một thread đợi ba thread khác khởi tạo xong, hay hệ thống chỉ cho mười request vào tài nguyên hiếm cùng lúc. Không có biến chung nào bị tranh chấp; cái được điều phối là tiến độ.

Đó là việc của synchronizer: đối tượng điều tiết luồng đi của các thread dựa trên trạng thái của chính nó. java.util.concurrent có năm cái, mỗi cái trả lời một câu hỏi điều phối. Bài này mổ ba máy đếm hay gặp nhất — CountDownLatch, CyclicBarrier, Semaphore; hai cái còn lại (Exchanger, Phaser) cho các hình dạng hiếm hơn, để dành bài kế. Cả năm đều là component đã kiểm chứng, không phải thứ ta nên tự dựng bằng wait/notify.

2. CountDownLatch chờ N sự kiện hoàn tất thế nào?

Hình dung một buổi đua: mọi vận động viên đã vào vị trí, nhưng cuộc đua chưa bắt đầu chừng nào trọng tài chưa kiểm xong từng làn — mỗi hạng mục xong, một ngọn đèn tắt. Đèn cuối tắt, cổng mở, tất cả cùng xuất phát. Cái bảng đèn đó là một latch: giữ cổng đóng tới khi một số điều kiện đều thỏa, rồi mở vĩnh viễn.

CountDownLatch khởi tạo với một số đếm. Thread gọi await() block tới khi số đếm về 0; mỗi sự kiện hoàn tất, ai đó gọi countDown() giảm số đếm đi một. Chạm 0, mọi thread đang chờ được thả cùng lúc, và mọi await() về sau trả về ngay. Chữ quan trọng nhất là "vĩnh viễn": latch dùng một lần, đã về 0 thì không reset — nó mô hình hóa một sự kiện chỉ xảy ra một lần trong đời ("khởi tạo đã xong"), không phải mốc lặp lại.

Cách dùng thứ nhất: một hay nhiều thread chờ đến khi N việc khởi tạo hoàn tất.

CountDownLatch ready = new CountDownLatch(3);   // 3 buoc warm-up
for (Runnable step : List.of(this::loadEvents, this::primeCache, this::verifyDependencies)) {
    Thread.startVirtualThread(() -> {
        try { step.run(); }
        finally { ready.countDown(); }           // luon dem, ke ca khi loi
    });
}
ready.await();                                   // block toi khi ca 3 dem xong

Đặt countDown() trong finally không phải tiểu tiết: nếu một bước warm-up ném exception mà bỏ qua nó, số đếm không bao giờ về 0 và thread await() treo vĩnh viễn. Latch không có khái niệm "thất bại"; nó chỉ đếm — ta phải bảo đảm nó luôn đếm đủ.

Cách thứ hai đảo vai: dùng latch như "súng lệnh" để N thread cùng bắt đầu một lúc — mẹo kinh điển viết test đo contention. Dựng hai latch: startGun (đếm từ 1) là cổng xuất phát, mọi worker await() trên nó; finished (đếm từ workerCount) là vạch đích, mỗi worker countDown() khi xong. Gọi startGun.countDown() là tất cả cùng lao ra, rồi finished.await() chờ về đích.

3. CyclicBarrier: N thread hẹn gặp nhau tại một điểm

Latch có một bất đối xứng tinh tế: thread gọi await() và thread gọi countDown() thường khác nhau — người chờ không phải người báo tin. CyclicBarrier xóa bỏ điều đó: mọi thread bình đẳng, cùng chạy đến một mốc, cùng gọi await(), cùng dừng đợi tới khi người cuối cùng tới, rồi rào mở và tất cả cùng đi tiếp. Hình dung một đoàn leo núi: mỗi trạm nghỉ cả đoàn phải tụ đủ mới đi tiếp; người tới sớm ngồi chờ, trạm kế lặp lại y hệt.

Chữ "cyclic" là khác biệt lớn nhất so với latch: sau khi tất cả vượt qua, barrier tự reset, sẵn sàng cho vòng tiếp. Đây là công cụ cho thuật toán lặp theo pha: chạy một pha, đồng bộ tại barrier để chắc mọi thread đã xong, rồi cùng bước vào pha sau.

// barrier action chay 1 lan moi khi ca nhom toi moc, TRUOC khi ai duoc di tiep
var barrier = new CyclicBarrier(workers, this::mergeAndPublishBoard);

Runnable worker = () -> {
    for (int step = 0; step < steps; step++) {
        computeRegion(myRegion);        // pha tinh toan doc lap tung vung
        try { barrier.await(); }        // cho moi vung tinh xong pha nay
        catch (InterruptedException | BrokenBarrierException e) { return; }
    }
};

CyclicBarrier cho ta thứ latch không có: barrier action — một Runnable truyền vào constructor, chạy đúng một lần mỗi khi rào mở, trên thread tới cuối cùng, trước khi bất kỳ thread nào được thả. Đó là khe an toàn để gộp kết quả pha vừa rồi: mọi thread đã xong việc, chưa ai sang pha mới, nên đọc gộp không bị chen ngang.

Cái tên BrokenBarrierException kể đúng bản chất: barrier là giao ước nhóm nên mong manh theo nhóm. Nếu một thread đang đợi bị interrupt, một await(timeout, unit) hết hạn, hoặc barrier action ném exception, barrier rơi vào trạng thái "broken" và mọi thread khác đang chờ lập tức bị đánh thức bằng BrokenBarrierException — vì nếu một thành viên không bao giờ tới mốc, cả nhóm không thể đợi mãi. Barrier vỡ phải reset() mới dùng lại được.

Đặt hai vòng đời cạnh nhau, khác biệt một-lần vs lặp-lại hiện rõ:

CountDownLatch đếm về 0 rồi mở vĩnh viễn, CyclicBarrier tự reset cho vòng sau

Ranh giới chọn: CountDownLatch khi sự kiện một lần và người chờ khác người báo; CyclicBarrier khi một nhóm cố định gặp nhau lặp lại và cần làm gì đó giữa các mốc.

4. Semaphore: giới hạn số thread truy cập đồng thời

Hai synchronizer vừa rồi nói về thời điểm: chờ tới khi, gặp nhau tại. Semaphore nói về số lượng: tại một thời điểm tối đa bao nhiêu thread được vào. Analogy chuẩn là bãi đỗ xe N chỗ có rào chắn: mỗi xe vào lấy một vé, hết chỗ thì rào đóng và xe sau xếp hàng, mỗi xe ra trả vé cho một xe chờ vào. Bãi chỉ giữ một bất biến: số xe bên trong không vượt N.

Semaphore quản lý một tập permit y như tập vé đó. acquire() lấy một permit, block nếu hết; release() trả permit về, đánh thức một thread đang chờ. Số permit ban đầu chính là số truy cập đồng thời tối đa.

public <T> T withConnection(Callable<T> action) throws Exception {
    permits.acquire();                  // cho toi khi co cho
    try {
        return action.call();
    } finally {
        permits.release();              // luon tra permit, ke ca khi action nem
    }
}

Cấu trúc acquire() ... try/finally ... release() gần như bắt buộc: nếu action ném mà ta quên release(), một permit biến mất vĩnh viễn. Lặp vài lần, semaphore cạn và mọi thread sau block mãi — resource leak âm thầm, khó truy hơn deadlock vì không có vòng chờ nào để soi.

Một đặc tính tinh tế: semaphore không gắn permit với thread. Khác intrinsic lock vốn nhớ thread đang giữ (và nhờ đó reentrant), permit không có chủ — thread A acquire() còn thread B release() vẫn hợp lệ, tổng permit chỉ là một con số đếm. Điều đó khiến nó linh hoạt hơn lock (mô hình "một bên cấp, bên khác thu") nhưng không bảo vệ ta khỏi lỗi đếm sai: release() nhiều hơn acquire() đẩy số permit vượt giá trị khởi tạo, phá vỡ chính cái giới hạn ta dựng nó để giữ.

Constructor còn tùy chọn fair: new Semaphore(permits, true) cấp permit cho thread chờ lâu nhất trước — chậm hơn non-fair chút nhưng tránh bỏ đói dưới tải dồn, thường đáng với resource pool phục vụ user thật. Khi không muốn block lúc hết chỗ, tryAcquire() cố lấy một permit rồi trả boolean tức thì — thà nói "hết suất, thử lại sau" còn hơn treo trình duyệt.

5. Chọn synchronizer cho TicketFlow v2

TicketFlow (dự án xuyên suốt module) sang v2 cần hai cơ chế điều phối, mỗi cái đúng một hình dạng vừa học. Trước khi đọc lời giải, tự chốt lựa chọn — đây đúng loại quyết định bạn phải ra khi thiết kế thật.

💡 Tự chọn trước khi đọc

Với mỗi yêu cầu, chọn một trong ba synchronizer vừa học và viết ra lý do trước khi đọc tiếp:

  1. SalesGate — các task warm-up (nạp sự kiện, làm nóng cache, kiểm dependency) phải xong hết thì luồng phục vụ mới bắt đầu nhận request; đã mở thì không đóng lại.
  2. HoldLimiter — mỗi sự kiện chỉ cho giữ chỗ đồng thời tối đa bằng sức chứa; hết chỗ thì từ chối ngay, không bắt client chờ.

SalesGate khớp CountDownLatch: sự kiện một-lần, mở rồi không quay lại — task warm-up xong gọi countDown(), luồng phục vụ chờ ở await(). HoldLimiter khớp Semaphore làm bounded resource pool per-event: permit bằng capacity mỗi sự kiện, dùng tryAcquire() để hết slot thì trả false ngay thay vì treo trình duyệt. Hai câu hỏi phân biệt: latch hỏi "đã đến lúc mở chưa?", semaphore hỏi "còn chỗ không?" — không cái nào bảo vệ dữ liệu, chúng điều phối tiến độ.

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

  • Thread API và vòng đời — mọi lời gọi block của synchronizer (await, acquire) đều phản hồi interrupt; bài đó dạy xử lý InterruptedException cho đúng.
  • ReentrantLock & Condition — phối hợp "chờ điều kiện rồi được đánh thức" mà ta từng dựng tay bằng lock + condition; synchronizer là tầng đóng gói bên trên.
  • ReadWriteLock, StampedLock & AQSCountDownLatchSemaphore đều là lớp vỏ mỏng quanh AbstractQueuedSynchronizer; đọc lại để thấy hàng đợi chờ chung bên dưới.
  • Blocking Queues & Producer–ConsumerBlockingQueue cũng là một synchronizer, nhưng kiêm thêm vai chứa dữ liệu; bài này tách riêng phần điều phối thuần túy.
📚 Deep Dive Oracle

7. Tóm tắt

Nửa series vừa qua ta xoay quanh giữ state đúng khi nhiều thread cùng chạm (lock, atomic, immutability, safe publication, concurrent collection, blocking queue). Synchronizer trả lời câu hỏi khác: không phải "bảo vệ dữ liệu nào", mà "sắp xếp nhịp đi của các thread ra sao". Ba cái trong bài đều là máy đếm:

SynchronizerCâu hỏi nó trả lờiTái sử dụngChờ bằng gì
CountDownLatch"N sự kiện xong hết chưa?"Không — một chiều, không resetawait() tới khi đếm về 0
CyclicBarrier"Cả nhóm tới mốc chưa?"Tự reset sau mỗi vòngawait() tới khi đủ N party
Semaphore"Còn slot trống không?"Permit quay vòng liên tụcacquire() tới khi có permit

Điểm chung: đều là component đã kiểm chứng trong java.util.concurrent, việc của ta là chọn đúng cái cho hình dạng bài toán, không dựng lại bằng wait/notify. Còn hai hình dạng điều phối nữa — đổi dữ liệu tay-đôi và barrier số-party-động — dành cho ExchangerPhaserbài kế.

8. Tự kiểm tra

Tự kiểm tra
0/5 câu đã trả lời
  1. Q1
    CountDownLatchCyclicBarrier khác nhau thế nào về khả năng tái sử dụng, và vì sao thiết kế lại khác nhau như vậy?
  2. Q2
    BrokenBarrierException được ném khi nào, và vì sao một thread hỏng lại khiến tất cả thread đang chờ cùng nhận exception?
  3. Q3
    Permit của Semaphore "không có chủ" nghĩa là gì? Đặc tính đó cho ta gì và bắt ta tự gánh gì?
  4. Q4
    Vì sao countDown() nên đặt trong khối finally, và lỗi tương ứng phía Semaphore là gì?
  5. Q5
    Một thuật toán chạy 10 pha; mỗi pha 4 thread tính độc lập rồi phải gộp kết quả trước khi sang pha sau. Chọn CountDownLatch hay CyclicBarrier? Vì sao?

Bài tiếp theo: Exchanger và Phaser — điều phối ngoài ba máy đếm

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

Exchanger và Phaser — điều phối tiến độ ngoài ba máy đếm