Java Internals & Concurrency/Executor Framework: Thread pool, lập lịch & shutdown
18/47
Bài 18 / 47~20 phútConcurrency cơ bảnMiễn phí lượt xem

Executor Framework: Thread pool, lập lịch & shutdown

Tách mô tả task khỏi cơ chế chạy task: ExecutorService, ThreadPoolExecutor tuning, scheduled execution, RejectedExecutionHandler, cancellation và shutdown.

TL;DR: Ngừng new Thread() — đóng gói công việc thành task (Runnable/Callable) rồi giao cho ExecutorService lo thread pool, lập lịch và vòng đời. Bên dưới gần như mọi pool là ThreadPoolExecutor với thuật toán nhận task phản trực giác: tạo core thread → xếp queue → tạo thêm thread → từ chối. Hệ quả: queue unbounded làm maximumPoolSize vô nghĩa. Production cần giới hạn hữu hạn ở mọi chiều — bounded queue cộng RejectedExecutionHandler, trong đó CallerRunsPolicy là backpressure tự nhiên. Task định kỳ dùng ScheduledExecutorService, nhớ bắt mọi exception trong thân task kẻo lịch chết lặng lẽ. Và shutdown theo pattern graceful-trước-forceful-sau, không bao giờ nuốt InterruptedException.

1. Vì sao nên ngừng new Thread()?

Suốt phần vừa rồi của series, mọi câu hỏi đều xoay quanh state: nhiều thread cùng chạm một dữ liệu mà không phá vỡ invariant. Bài Synchronizers khép lại bốn chiến lược ấy, nhưng chưa nói ai chạy công việc, trên thread nào, và bao nhiêu thread — trục mới của series: task execution.

Luận điểm trung tâm: ngừng tự tạo thread. Bài Process và Thread đã cho thấy platform thread không miễn phí — tốn stack, tốn context switching, áp lực lên scheduler. Một server đặt vé cứ mỗi request lại new Thread(task).start() sẽ sụp dưới tải thật: mười nghìn request thành mười nghìn thread tranh nhau vài chục core, CPU trôi vào chuyển ngữ cảnh, JVM ném OutOfMemoryError vì không cấp nổi stack. Nó còn trộn hai thứ vốn nên tách rời — mô tả công việcchính sách chạy công việc — quấn vào nhau thì đổi từ "mỗi task một thread" sang "pool cố định" phải sửa khắp nơi.

Executor Framework (Java 5, gói java.util.concurrent) chính là cú tách đôi đó: một bên là task — Runnable hoặc Callable; bên kia là chính sách thực thi đóng gói trong một Executor — interface nhỏ đến bất ngờ:

public interface Executor {
    void execute(Runnable command);
}

Chỉ một method, không nói gì về thread, pool hay hàng đợi. Sự nghèo nàn ấy cố ý: Executor tách việc gửi task khỏi việc chạy task — người gửi không cần biết task chạy trên thread mới, thread của pool, hay ngay thread gọi.

Executor đủ cho "bắn rồi quên", nhưng đời thực còn cần biết task xong chưa, lấy kết quả, hủy nó, tắt hệ thống cho gọn. ExecutorService thêm submit (nhận cả Callable lẫn Runnable, trả về một Future — tay cầm tới kết quả tương lai), invokeAll/invokeAny cho cả mẻ task, và bộ vòng đời shutdown/shutdownNow/awaitTermination.

ExecutorService pool = Executors.newFixedThreadPool(4);
pool.execute(() -> log.info("fire-and-forget"));          // Runnable, khong ket qua
Future<Booking> handle = pool.submit(() ->                // Callable, co ket qua
        bookingService.book("concert-01", "user-42"));

executesubmit khác nhau không chỉ ở giá trị trả về mà ở một bẫy exception. execute để exception của task nổi lên UncaughtExceptionHandler — bạn thấy stack trace trong log. submit nuốt exception vào Future, chỉ phát lại dưới dạng ExecutionException khi gọi future.get(). submit rồi không bao giờ get thì một lỗi nghiêm trọng biến mất không dấu vết — cách phổ biến khiến lỗi concurrency ẩn mình, và bài Future & CompletableFuture sẽ mổ tiếp.

Từ Java 19, ExecutorServiceAutoCloseable nên dùng được trong try-with-resources: close tự gọi shutdown rồi chờ task chạy nốt. Pool sống suốt vòng đời ứng dụng vẫn cần shutdown thủ công (mục 6).

2. ThreadPoolExecutor: cỗ máy bên dưới

Hầu hết ExecutorService ta dùng hằng ngày, dù tạo qua factory nào, đều là một ThreadPoolExecutor. Hiểu nó là hiểu gần như mọi thread pool trong JVM; cách tốt nhất là nhìn constructor đầy đủ — mỗi tham số là một nút vặn của chính sách thực thi:

new ThreadPoolExecutor(
        int corePoolSize,                  // so thread giu thuong truc
        int maximumPoolSize,               // tran so thread
        long keepAliveTime, TimeUnit unit, // thoi gian song cua thread vuot core khi ranh
        BlockingQueue<Runnable> workQueue, // noi task cho khi khong co thread ranh
        ThreadFactory threadFactory,       // cach tao thread
        RejectedExecutionHandler handler); // lam gi khi khong nhan noi task

Bảy tham số ấy quy về một thuật toán quyết định duy nhất, chạy mỗi lần có task mới. Pool không hỏi "có thread nào rảnh không": nó tạo core thread trước — ngay cả khi đang có thread rảnh, miễn số thread còn dưới corePoolSize; đủ core rồi thì xếp task vào workQueue; queue đầy mới tạo thêm thread tới maximumPoolSize; cả hai kịch trần thì task bị từ chối, RejectedExecutionHandler vào cuộc.

flowchart TD
    A["submit(task)"] --> B{"so thread dang co < corePoolSize?"}
    B -- "chua du core" --> C["tao CORE thread moi chay task<br/>(ke ca khi co thread ranh)"]
    B -- "du core roi" --> D{"workQueue con cho?"}
    D -- "con cho" --> E["xep task vao queue, cho thread ranh"]
    D -- "queue day" --> F{"so thread < maximumPoolSize?"}
    F -- "chua cham tran" --> G["tao them thread (non-core) chay task"]
    F -- "da kich tran" --> H["TU CHOI task<br/>RejectedExecutionHandler vao cuoc"]
    style C fill:#6EE7B7
    style G fill:#6EE7B7
    style E fill:#FCD34D
    style H fill:#FCA5A5

Thứ tự "tạo core thread → xếp hàng → tạo thêm thread → từ chối" phản trực giác đến mức là nguồn của vô số hiểu nhầm: nhiều người tưởng pool bung tới maximumPoolSize rồi mới xếp hàng, thực tế queue được ưu tiên. Hệ quả trực tiếp: queue unbounded làm maximumPoolSize vô nghĩa — queue không bao giờ đầy nên pool không bao giờ tạo quá corePoolSize thread.

keepAliveTime lo phần co lại: thread vượt corePoolSize mà ngồi không quá keepAliveTime sẽ tự kết thúc, đưa pool về kích thước core khi tải hạ. Hình dung quán cà phê: corePoolSize là nhân viên ca cố định, workQueue là hàng khách chờ, maximumPoolSize là trần khi gọi thêm người thời vụ giờ cao điểm, keepAliveTime là khoảng vắng đủ lâu để họ về.

Booking pool của TicketFlow v3 dựng thẳng từ constructor để chọn từng nút vặn có chủ đích:

new ThreadPoolExecutor(
        workerCount,                          // core = max: pool kich thuoc co dinh
        workerCount,
        0L, TimeUnit.MILLISECONDS,
        new ArrayBlockingQueue<>(queueCapacity),       // queue BOUNDED — chu y
        new NamedThreadFactory("booking-worker"),      // ten thread cho thread dump
        new ThreadPoolExecutor.CallerRunsPolicy());    // backpressure khi qua tai

3. Khi nào KHÔNG nên dùng Executors factory?

Executors cung cấp factory tạo sẵn các cấu hình ThreadPoolExecutor thông dụng — gọn, và chính sự tiện lợi đó khiến chúng bị lạm dụng.

3.1 Ba factory thông dụng

newFixedThreadPool(n) tạo pool đúng n thread thường trực, queue unbounded: task vượt n thread đang bận xếp hàng vô tận. newSingleThreadExecutor là trường hợp n = 1 — một thread xử lý mọi task theo thứ tự gửi vào, cho serial execution mà vẫn hưởng task/Future (một dạng confinement). newCachedThreadPool ngược cực: corePoolSize 0, maximumPoolSize Integer.MAX_VALUE, SynchronousQueue không chứa nổi phần tử nào — nên task không gặp thread rảnh làm pool tạo thread mới ngay, thread rảnh quá 60 giây thì bị thu hồi.

3.2 Vì sao tránh newCachedThreadPool và unbounded queue trong production

Hai factory này hỏng theo hai cách đối xứng, cùng một gốc — không có trần. newCachedThreadPool không giới hạn số thread: khi request đến nhanh hơn tốc độ xử lý (kịch bản tải đỉnh), pool cứ tạo thêm thread, mỗi thread một stack, tới khi JVM kiệt bộ nhớ. Một cơn tăng tải thành OutOfMemoryError.

newFixedThreadPool hỏng âm thầm hơn nên nguy hiểm hơn: thread chặn cứng nhưng queue unbounded. Quá tải thì task không bị từ chối mà chất đống vô hạn — bộ nhớ phình theo từng task chờ, latency task cuối hàng dài vô nghĩa, hệ thống không báo gì cho tới khi đổ.

Bài học: production cần giới hạn hữu hạn ở mọi chiều — chặn số thread, chặn độ dài queue, rồi quyết định có ý thức điều gì xảy ra khi chạm trần. TicketFlow vì thế dùng ArrayBlockingQueue bounded: cả thread lẫn queue đầy thì pool từ chối task thay vì âm thầm chất đống.

3.3 Sizing pool: CPU-bound khác I/O-bound

n nên là bao nhiêu? Không có con số thần kỳ — nguyên lý phụ thuộc bản chất task.

Task CPU-bound — mã hóa, nén, tính toán thuần — chiếm trọn một core trong lúc chạy; thêm thread vượt số core chỉ tăng chi phí context switching. Điểm xuất phát: số thread xấp xỉ số core, thường là Runtime.getRuntime().availableProcessors() cộng một.

Task I/O-bound — gọi database, remote API, đọc file — phần lớn thời gian chỉ ngồi chờ, không dùng CPU; chạy số thread bằng số core thì core rảnh rỗi trong lúc mọi thread treo trên lời gọi mạng. Cần nhiều thread hơn số core, tỉ lệ thuận với phần thời gian chờ. Công thức của Brian Goetz (Java Concurrency in Practice §8.2): N = Ncpu × Ucpu × (1 + W/C) — tỉ số chờ trên tính (W/C) càng lớn cần càng nhiều thread. Task chờ I/O 90% thời gian có thể cần gấp mười lần số core mới bão hòa CPU.

Căng thẳng "I/O-bound cần rất nhiều thread mà platform thread thì đắt" là động lực dẫn tới virtual thread — Executors.newVirtualThreadPerTaskExecutor() cho một thread mỗi task không trả giá đắt (bài Virtual Threads). Hệ quả thực dụng: đừng nhét task blocking I/O và task CPU-bound vào cùng một pool — một mẻ mạng chậm sẽ chiếm hết thread, bỏ đói tính toán ngắn; tách pool theo workload (TicketFlow tách booking pool khỏi notify pool).

4. Lập lịch với ScheduledExecutorService

Tới giờ mọi task đều chạy ngay khi có thể. Nhiều hệ thống cần chạy sau một khoảng trễ hoặc lặp đều: dọn cache hết hạn, gửi heartbeat, hết hạn chỗ giữ vé. Phản xạ cũ Timer/TimerTask có hai khuyết tật chí mạng: chỉ một thread nên một task chạy lâu làm trễ mọi task sau, và một exception không bắt trong TimerTask giết luôn thread Timer, khiến mọi lịch còn lại im lặng ngừng chạy. ScheduledExecutorService thay thế nó hoàn toàn.

ScheduledExecutorService scheduler = Executors.newScheduledThreadPool(2);
scheduler.schedule(() -> sendReminder(), 10, TimeUnit.MINUTES);  // chay MOT lan sau 10 phut
scheduler.scheduleAtFixedRate(() -> emitHeartbeat(),
        0, 30, TimeUnit.SECONDS);                    // moi 30s tinh tu luc BAT DAU lan truoc
scheduler.scheduleWithFixedDelay(() -> sweepExpired(),
        1, 1, TimeUnit.MINUTES);                     // 1 phut SAU KHI lan truoc KET THUC

Hai biến thể lặp dễ chọn nhầm. scheduleAtFixedRate đo nhịp từ thời điểm bắt đầu mỗi lần chạy: lần thứ k hẹn vào mốc initialDelay + k*period, bất kể lần trước chạy bao lâu. scheduleWithFixedDelay đo từ thời điểm kết thúc: chờ đúng delay sau khi lần trước xong mới khởi động lần kế.

Khác biệt ấy vô hại khi task nhanh hơn period, thành tai họa khi task chậm hơn. Với fixedRate, sweep mất 90 giây mà period 30 giây thì các lần chạy dồn cục: scheduler cố giữ nhịp nên xếp các lần trễ nối đuôi không nghỉ, đè lên pool. fixedDelay luôn có đúng delay giây nghỉ giữa hai lần nên không bao giờ dồn. Quy tắc: cần tần suất đều (lấy mẫu metric) dùng fixedRate; cần khoảng nghỉ đều tránh chồng lấn (dọn dẹp định kỳ) dùng fixedDelay.

TicketFlow v3 chọn fixedDelay cho sweeper hết hạn chỗ giữ, đúng vì lý do tránh dồn:

handle = scheduler.scheduleWithFixedDelay(this::sweepOnce, ms, ms, TimeUnit.MILLISECONDS);

private void sweepOnce() {
    try {
        int expired = limiter.expireOldHolds();
        // ...
    } catch (Exception e) {            // BAT MOI exception ngay trong task body
        sweepErrors.incrementAndGet();
    }
}

Cái try/catch ấy không thừa. ScheduledExecutorService thừa hưởng hành vi khắc nghiệt: task định kỳ ném exception mà không bị bắt thì không chỉ lần đó thất bại — toàn bộ lịch của task ấy bị hủy vĩnh viễn, lặng lẽ, không log. Một NullPointerException từ expireOldHolds đủ khiến sweeper ngừng mãi mãi, phát hiện nhiều ngày sau khi vé hết hạn chất đống. Luật cứng với mọi periodic task: bắt mọi exception ngay trong thân task.

Lưu ý cài đặt: ScheduledThreadPoolExecutor luôn dùng hàng đợi unbounded theo thứ tự thời gian và bỏ qua maximumPoolSize — chỉ co giãn theo corePoolSize. Hợp lý cho task định kỳ thưa, nhưng đừng trông cậy nó tự bung thread khi task dồn; giữ task lập lịch ngắn gọn.

5. ThreadFactoryRejectedExecutionHandler

Hai tham số cuối của constructor thường bị bỏ mặc mặc định, nhưng trong production chúng phân định một pool dễ vận hành với một pool mù mịt khi sự cố.

ThreadFactory quyết định cách pool tạo thread. Mặc định thread mang tên kiểu pool-3-thread-1 — vô nghĩa khi bạn nhìn thread dump lúc 3 giờ sáng, cố hiểu thread nào đang treo. ThreadFactory tự viết cho phép đặt tên có ý nghĩa, daemon flag, gắn UncaughtExceptionHandler, hay set priority. Lợi ích rẻ nhất là cái tên:

public class NamedThreadFactory implements ThreadFactory {
    private final AtomicInteger counter = new AtomicInteger(0);   // newThread co the bi goi dong thoi
    @Override public Thread newThread(Runnable r) {
        Thread t = new Thread(r, namePrefix + "-" + counter.incrementAndGet());
        t.setDaemon(false);
        return t;
    }
}

Với prefix "booking-worker", thread dump hiện ra booking-worker-1, booking-worker-2, và đọc một sự cố trở nên khả thi: đống thread đang chờ database là của booking pool, không phải report pool. Bộ đếm phải là AtomicInteger — chính class quản lý thread cũng phải thread-safe.

RejectedExecutionHandler lo điều xảy ra khi pool không nhận nổi task nữa — khi cả maxPool lẫn bounded queue đều đầy, hoặc pool đã shutdown. Đã chọn bounded queue thì phải quyết định từ chối nghĩa là gì. JDK cung cấp bốn chính sách: AbortPolicy (mặc định) ném RejectedExecutionException để phía gọi tự xử; DiscardPolicy lặng lẽ vứt task mới, nguy hiểm vì mất việc không dấu vết; DiscardOldestPolicy vứt task chờ lâu nhất để nhường chỗ; và CallerRunsPolicy — đáng chú ý nhất — bắt chính thread gọi submit chạy luôn task đó.

CallerRunsPolicy là một cơ chế backpressure thanh lịch. Thread gọi (thường là thread nhận request) bị giữ lại tự chạy task, nên lúc đó không thể nhận thêm request mới. Áp lực truyền ngược lên acceptor rồi tới client, làm tốc độ đổ task vào đúng bằng tốc độ pool tiêu thụ, thay vì tích nợ hay ném lỗi tới tấp. TicketFlow chọn nó cho booking pool chính vì lẽ đó: dưới tải đỉnh, hệ thống chậm lại có kiểm soát thay vì sụp hay đánh rơi vé. Cần từ chối tường minh hơn thì viết handler riêng ném exception miền như RejectedException.

6. Cancellation và shutdown

Tạo pool thì dễ; dừng nó cho sạch mới phân biệt code demo với code production. Một pool tắt cẩu thả đánh rơi task đang dở, treo JVM vì thread không chịu chết, hoặc hỏng dữ liệu vì cắt ngang thao tác giữa chừng.

6.1 Hủy một task: interruption và Future.cancel

Java không có cách an toàn nào ép một thread dừng ngay — Thread.stop bị khai tử vì có thể bỏ object ở trạng thái hỏng. Cơ chế hủy của Java là hợp tác: ta yêu cầu thread dừng, thread phải chủ động kiểm tra rồi thu xếp. Phương tiện là interruption.

future.cancel(true) khiến executor interrupt() thread đang chạy task. Interrupt không tự dừng task; nó bật cờ trên thread và làm các method blocking như sleep, wait, take ném InterruptedException. Task tôn trọng hay không tùy cách viết: task tính toán dài cần định kỳ hỏi Thread.currentThread().isInterrupted() rồi thoát khi cờ bật; task chặn I/O bắt InterruptedException rồi dừng gọn.

Một luật mà vi phạm sẽ phá hỏng khả năng hủy của cả hệ thống: đừng bao giờ nuốt InterruptedException. Bắt mà không truyền tiếp được thì phải khôi phục cờ bằng Thread.currentThread().interrupt() để tầng trên biết đã có yêu cầu hủy. Nuốt im lặng là xóa sổ một yêu cầu dừng, pool có thể không bao giờ tắt.

future.cancel(false) nhẹ hơn: ngăn task chưa chạy khởi động, nhưng không interrupt task đang chạy. Dùng khi muốn task đang dở chạy nốt — sweeper của TicketFlow gọi handle.cancel(false) khi đóng để lần sweep đang chạy hoàn tất thay vì bị cắt ngang.

6.2 shutdownshutdownNow

shutdown() là cuộc chia tay lịch sự: pool ngừng nhận task mới (mọi submit sau đó bị từ chối) nhưng mọi task đã nhận, kể cả trong queue, vẫn chạy cho xong.

shutdownNow() là cuộc chia tay gấp gáp: interrupt() mọi thread đang chạy, bỏ qua task còn chờ trong queue, và trả về danh sách task chưa kịp khởi động để bạn tự định đoạt. Vì dựa trên interruption, nó chỉ dừng được task biết tôn trọng interrupt. Cả hai trả về ngay; muốn biết pool dừng hẳn chưa phải hỏi bằng awaitTermination.

6.3 Pattern shutdown chuẩn: graceful trước, forceful sau

Cách tắt đúng là phối hợp cả hai: xin lịch sự trước, hết kiên nhẫn thì ép — LifecycleManager của TicketFlow đóng gói pattern đó:

static void shutdownAndAwait(String name, ExecutorService executor,
                             Duration grace, Duration force) {
    executor.shutdown();                                   // 1. ngung nhan, de task chay not
    try {
        if (!executor.awaitTermination(grace.toMillis(), TimeUnit.MILLISECONDS)) {
            executor.shutdownNow();                        // 2. qua han an hue → ep dung
            if (!executor.awaitTermination(force.toMillis(), TimeUnit.MILLISECONDS)) {
                // log: pool khong phan hoi interrupt
            }
        }
    } catch (InterruptedException e) {
        executor.shutdownNow();
        Thread.currentThread().interrupt();                // 3. khoi phuc co interrupt
    }
}

awaitTermination thứ hai cho task ngoan cố cơ hội cuối phản ứng sau shutdownNow(). Thread chạy shutdown mà bị interrupt giữa chừng thì ép dừng rồi khôi phục cờ — đúng luật mục 6.1.

Còn một chi tiết dễ xem nhẹ: thứ tự đóng nhiều pool. LifecycleManager đóng theo đúng thứ tự danh sách, có chủ đích: đóng nguồn sinh task trước, nơi tiêu thụ sau. Với TicketFlow là dừng sweeper trước rồi mới đóng booking pool; đảo ngược sẽ có cảnh một pool đã đóng còn bị pool khác cố submit việc vào, sinh RejectedExecutionException giữa lúc đang tắt. Đăng ký cả chuỗi vào một JVM shutdown hook để hệ thống tự dọn khi nhận tín hiệu dừng.

7. 📚 Deep Dive Oracle

📚 Deep Dive Oracle

Spec / reference chính thức:

  • ThreadPoolExecutor javadoc (Java 21) — phần đầu javadoc mô tả chính xác thuật toán core/queue/max và từng nút vặn; đây là nguồn gốc của sơ đồ mục 2.
  • ScheduledThreadPoolExecutor javadoc — ghi rõ hành vi "task ném exception thì các lần chạy sau bị suppress" của lịch định kỳ.
  • Java Concurrency in Practice (Goetz et al.), chương 6 (Task Execution) và §8.2 (Sizing thread pools) — nguồn của công thức N = Ncpu × Ucpu × (1 + W/C).

Ghi chú: javadoc của ThreadPoolExecutor đáng đọc trọn — gần như mọi câu hỏi tuning đều có câu trả lời trực tiếp ở đó.

8. Tóm tắt

  • Executor Framework tách chính sách thực thi khỏi nghiệp vụ: gói việc thành task (Runnable/Callable), giao ExecutorService, thôi tự new Thread(). execute để exception nổi lên UncaughtExceptionHandler; submit nuốt vào Future, chỉ phát lại khi get — submit-rồi-quên là nguồn lỗi ẩn.
  • Dưới mọi factory là ThreadPoolExecutor, thuật toán "core → queue → thêm thread → từ chối"; queue unbounded làm maximumPoolSize vô nghĩa. newCachedThreadPool không chặn số thread, newFixedThreadPool không chặn queue — production cần giới hạn hữu hạn ở mọi chiều cộng RejectedExecutionHandler, và CallerRunsPolicy biến giới hạn thành backpressure trung thực.
  • Sizing tách CPU-bound (xấp xỉ số core) với I/O-bound (N = Ncpu × Ucpu × (1 + W/C)); đừng trộn hai workload vào một pool.
  • ScheduledExecutorService thay Timer; chọn đúng fixedRate hay fixedDelay, bắt mọi exception trong thân task kẻo cả lịch chết lặng lẽ.
  • ThreadFactory đặt tên thread để vận hành được; shutdown graceful-trước-forceful-sau, dựa trên hủy hợp tác qua interruption mà ta không được nuốt.

Còn một câu hỏi bài này liên tục né: submit một Callable trả về một Future — rồi sao nữa? Làm sao lấy kết quả mà không chặn cứng một thread ngồi chờ, và ghép những bước phụ thuộc nhau thành chuỗi mà không lồng get vào get? Future.get là blocking và không compose được — giới hạn mà bài kế tiếp gỡ bằng CompletableFuture.

9. Tự kiểm tra

Tự kiểm tra
Q1
Vì sao khi workQueue là unbounded thì maximumPoolSize trở nên vô nghĩa?

Thuật toán nhận task của ThreadPoolExecutor chỉ tạo thread vượt quá corePoolSize khi queue đã đầy — thứ tự là: tạo core thread, rồi xếp queue, rồi mới tạo thêm thread. Một queue unbounded (như LinkedBlockingQueue mặc định của newFixedThreadPool) không bao giờ đầy, nên nhánh "tạo thêm thread" không bao giờ được chạm tới.

Hệ quả là pool mãi mãi chỉ có corePoolSize thread, còn task thừa chất đống vô hạn trong queue — bộ nhớ phình, latency của task cuối hàng kéo dài, và hệ thống không phát tín hiệu quá tải nào cho tới khi đổ.

Q2
scheduleAtFixedRate và scheduleWithFixedDelay khác nhau thế nào, và điều gì xảy ra khi task chạy lâu hơn period?

scheduleAtFixedRate đo nhịp từ thời điểm bắt đầu mỗi lần chạy: lần thứ k được hẹn vào mốc initialDelay + k*period, bất kể lần trước chạy bao lâu. scheduleWithFixedDelay đo từ thời điểm kết thúc: chờ đúng delay sau khi lần trước xong mới khởi động lần kế.

Khi task chậm hơn period, fixedRate dồn cục: scheduler cố giữ nhịp nên các lần trễ chạy nối đuôi không nghỉ, đè lên pool. fixedDelay luôn giữ khoảng nghỉ cố định nên không bao giờ dồn — nhịp chỉ giãn ra. Cần tần suất đều (lấy mẫu metric) thì dùng fixedRate; cần khoảng nghỉ để tránh chồng lấn (dọn dẹp định kỳ) thì dùng fixedDelay.

Q3
Vì sao CallerRunsPolicy được coi là cơ chế backpressure tự nhiên?

Khi pool kịch trần, CallerRunsPolicy bắt chính thread gọi submit — thường là thread nhận request — phải tự chạy task bị từ chối. Trong lúc bận chạy task đó, thread này không thể nhận thêm request mới.

Áp lực vì thế tự truyền ngược lên tầng trên: acceptor của server chậm lại, rồi tới client. Tốc độ đổ task vào tự co về đúng bằng tốc độ pool tiêu thụ — hệ thống chậm lại có kiểm soát thay vì tích nợ trong queue (unbounded) hay ném lỗi hàng loạt (AbortPolicy).

Q4
Một task định kỳ trên ScheduledExecutorService ném exception không bị bắt — chuyện gì xảy ra với các lần chạy sau, và phòng thế nào?

Toàn bộ lịch của task ấy bị hủy vĩnh viễn, lặng lẽ — không log, không lỗi nổi lên. Đây là contract của scheduleAtFixedRate/scheduleWithFixedDelay: một lần chạy ném exception là chuỗi lặp chấm dứt. Bạn thường chỉ phát hiện nhiều ngày sau, khi hậu quả nghiệp vụ (vé hết hạn không được dọn) chất đống.

Luật cứng: bọc try/catch ôm trọn thân task, bắt mọi exception ngay bên trong và ghi nhận (log, counter) thay vì để nó thoát ra ngoài — như sweeper của TicketFlow đếm sweepErrors rồi cho lịch chạy tiếp.

Q5
execute và submit xử lý exception của task khác nhau ra sao, và vì sao submit-rồi-quên là nguồn lỗi ẩn?

execute để exception nổi lên UncaughtExceptionHandler của thread — bạn thấy stack trace trong log ngay. submit thì bắt exception, gói vào Future, và chỉ phát lại dưới dạng ExecutionException khi có ai gọi future.get().

Nếu bạn submit rồi không bao giờ get, exception nằm im trong Future không ai đọc — task hỏng mà không một dấu vết nào. Quy tắc: hoặc tiêu thụ Future tử tế, hoặc dùng execute cho task fire-and-forget để lỗi còn nổi lên.

Q6
Vì sao pattern shutdown chuẩn phải là shutdown() trước rồi mới shutdownNow(), và vì sao phải khôi phục cờ interrupt trong nhánh catch?

shutdown() cho task đang dở và task trong queue một cửa sổ ân huệ hoàn tất tử tế — không mất việc, không cắt ngang thao tác giữa chừng. Chỉ khi awaitTermination hết hạn mà pool chưa dừng, shutdownNow() mới interrupt các thread ngoan cố. Đảo thứ tự nghĩa là vứt task trong queue và cắt ngang task đang chạy ngay cả khi chúng chỉ cần thêm vài giây.

Trong nhánh catch (InterruptedException), gọi Thread.currentThread().interrupt() để khôi phục cờ vì cơ chế hủy của Java là hợp tác: nuốt im lặng exception này nghĩa là xóa sổ một yêu cầu dừng, khiến tầng code phía trên không bao giờ biết có ai đó đang muốn tắt hệ thống.

Q7
Vì sao pool cho task CPU-bound nên xấp xỉ số core, còn pool cho task I/O-bound cần nhiều thread hơn hẳn?

Task CPU-bound chiếm trọn một core suốt lúc chạy — thêm thread vượt số core không tăng việc làm được, chỉ tăng chi phí context switching. Task I/O-bound thì phần lớn thời gian ngồi chờ (DB, mạng) không dùng CPU; nếu chỉ có số thread bằng số core, các core rảnh rỗi trong lúc mọi thread treo trên lời gọi mạng.

Công thức Goetz (Java Concurrency in Practice §8.2): N = Ncpu × Ucpu × (1 + W/C) — tỉ số chờ/tính càng lớn, cần càng nhiều thread để bão hòa CPU. Và đừng trộn hai loại task vào một pool: một mẻ I/O chậm sẽ chiếm hết thread, bỏ đói các tính toán ngắn.

Bài tiếp theo: Future & CompletableFuture: Từ kết quả blocking đến pipeline async

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