Thread, Runnable, Callable và Future

6 câu hỏi~25 phútTừ khóa: start vs run, Runnable, Callable, Future, FutureTask, effectively final, CompletableFuture

Callable này sẽ chạy trên main thread?

public class Main {
    public static void main(String[] args) throws Exception {
        Callable<Integer> task = () -> {
            return 10 + 20;
        };
        Integer result = task.call();
        System.out.println(result);
    }
}

Đúng, đoạn này chạy trên main thread. call() chỉ là một method bình thường: thread nào gọi call() thì thread đó chạy code bên trong.

main thread
 |-- tạo Callable task      (chưa chạy gì cả)
 |-- task.call()
 |     └── return 10 + 20
 |-- result = 30
 └-- println(30)

Bản thân Callable (và Runnable) không tự tạo thread, nó chỉ mô tả công việc. Để thấy vì sao cần đưa nó sang thread khác, mình quay lại Order Service.

1. Bài toán

Order Service có API GET /checkout/{userId} cần 3 thông tin, mỗi cái gọi một service khác:

Ba việc này độc lập nhau. Tomcat thread nhận request rồi gọi lần lượt.

2. Vấn đề

Tomcat thread exec-1:
getUser ████ 1s ████
                     checkStock ████ 1s ████
                                            shippingFee ████ 1s ████
→ tổng 3s, trong khi 3 việc hoàn toàn chạy cùng lúc được

Muốn chạy song song thì phải có thêm thread. Và việc chạy ở thread khác thường cần trả kết quả về cho thread request.

3. Giải pháp: tách "việc cần làm" khỏi "thread chạy nó"

4. Code từng bước

Bước 1: hai cách tạo thread.

// Cách 1: kế thừa Thread (ít dùng)
class MyThread extends Thread {
    @Override public void run() { System.out.println("chạy ở " + getName()); }
}
new MyThread().start();

// Cách 2: truyền Runnable (hay dùng)
Runnable task = () -> System.out.println("chạy ở " + Thread.currentThread().getName());
new Thread(task).start();

Nên dùng cách 2: tách việc khỏi thread. Java không đa kế thừa, nên kế thừa Thread làm class của bạn mất cơ hội kế thừa class khác.

Bước 2: start() khác run().

Thread t = new Thread(() -> System.out.println(Thread.currentThread().getName()));
t.run();    // in "main": gọi method thường, KHÔNG có thread mới
t.start();  // in "Thread-0": tạo thread mới, thread mới gọi run()
t.start();  // IllegalThreadStateException: một thread chỉ start được một lần

Bước 3: cần kết quả → Callable + ExecutorService + Future.

@Service
public class CheckoutService {
    private static final ExecutorService POOL = Executors.newFixedThreadPool(10);

    public CheckoutDto checkout(long userId) throws Exception {
        Future<User>    userF  = POOL.submit(() -> userClient.get(userId));
        Future<Stock>   stockF = POOL.submit(() -> inventoryClient.check(userId));
        Future<Integer> feeF   = POOL.submit(() -> shippingClient.fee(userId));

        // submit HẾT rồi mới get, luôn có timeout
        User u    = userF.get(2, TimeUnit.SECONDS);
        Stock s   = stockF.get(2, TimeUnit.SECONDS);
        int fee   = feeF.get(2, TimeUnit.SECONDS);
        return new CheckoutDto(u, s, fee);
    }
}

Bước 4: test thread nào chạy.

Callable<String> who = () -> Thread.currentThread().getName();
System.out.println(who.call());               // "main": gọi trực tiếp như method thường
System.out.println(POOL.submit(who).get());   // "pool-1-thread-1": executor chạy giùm

5. Cách hoạt động

Tomcat exec-1 (request /checkout)
 │ submit(getUser)    ──► pool-1-thread-1: getUser   ████ 1s ████
 │ submit(checkStock) ──► pool-1-thread-2: checkStock████ 1s ████
 │ submit(fee)        ──► pool-1-thread-3: fee       ████ 1s ████
 │ userF.get()  ... WAITING (block) ...
 │ stockF.get() → có ngay
 │ feeF.get()   → có ngay
 ▼ trả response  → tổng ≈ 1s thay vì 3s

Khi submit, call() chạy trên thread của pool. Thread request đi tiếp, cho tới khi gọi future.get() thì bị block chờ kết quả. Nhiều bạn nhầm chỗ này: thấy main "đứng chờ" nên tưởng Callable chạy trên main. Còn nếu submit xong gọi get() ngay thì gần như chẳng song song gì.

6. So sánh

RunnableCallable
Methodvoid run()V call() throws Exception
Trả kết quảKhôngCó
Checked exceptionKhông ném đượcNém được
Chạy bằngnew Thread(r).start() hoặc executorExecutor (submit, invokeAll)
Gọi trực tiếp run()/call()Chạy trên thread hiện tại, không có thread mới

Nhân tiện: execute(Runnable) không trả gì; submit() nhận cả Runnable lẫn Callable và trả Future. Lỗi trong task của submit() bị giữ trong Future, không gọi get() thì không ai thấy.

7. Khi nào nên dùng / không nên dùng

Quay lại câu hỏi ban đầu, mình tóm lại thế này có đúng không:

  • Callable và Runnable đều là functional interface; method của Callable trả về được kết quả, với nhiều kiểu dữ liệu khác nhau.
  • Thread có constructor nhận Runnable nhưng không có cho Callable.
  • Runnable chạy trên thread mới bằng new Thread(r).start(), còn Callable muốn chạy trên thread khác thì phải qua ExecutorService / thread pool?

Đúng gần hết. Chỉ chỉnh một chút ở ý "trả về nhiều kiểu":

  1. Cả hai là functional interface: mỗi cái chỉ có 1 abstract method nên viết được bằng lambda.
  2. Callable trả về nhiều kiểu nhờ Generic, nhưng mỗi Callable<V> có một kiểu cố định. Đã chọn V = Integer thì call() thành Integer call(); trả "Hello" là lỗi compile.
  3. Thread chỉ nhận Runnable: new Thread(callable) không compile.
  4. Callable chạy thread khác thường qua ExecutorService: đúng, đây là cách chuẩn.
Callable<Integer> c1 = () -> 100;
Callable<String>  c2 = () -> "Hello";
Callable<Integer> bad = () -> "Hello";   // ❌ compile error

Callable<V> ──submit──> ExecutorService ──trả về──> Future<V> ──get()──> V

Thực ra vẫn có cách không cần pool: bọc vào FutureTask (vì nó là Runnable) rồi đưa cho new Thread(...). Nhưng trong code thật thì hầu như luôn dùng executor.

Mình hiểu Future là "phiếu nhận kết quả". Đi sâu hơn thì bên trong nó là gì?

Đúng, "phiếu nhận kết quả" là chuẩn. Đi sâu thêm một tầng: Future chỉ là interface (get, isDone, cancel, isCancelled). Object thật đứng sau thường là FutureTask, vừa là Runnable vừa là Future:

// submit() bên trong gần giống:
FutureTask<Integer> ft = new FutureTask<>(callable);
execute(ft);          // FutureTask là Runnable → worker chạy được
return ft;            // FutureTask cũng là Future → trả về cho bạn

// FutureTask<V> implements RunnableFuture<V>
// RunnableFuture<V> extends Runnable, Future<V>
Callable ─bọc─► FutureTask ─(là Runnable)─► Worker thread chạy ft.run()
                    │
                    └─(là Future)─► trả cho bạn: get() / isDone() / cancel()

Khi worker chạy ft.run(), bên trong (đơn giản hóa) là:

try {
    result = callable.call();   // lưu kết quả
    state  = NORMAL;            // đánh dấu xong, đánh thức thread đang get()
} catch (Throwable e) {
    exception = e;              // lưu lỗi, get() sẽ ném ExecutionException
}

Nếu thread request gọi get() trước khi worker xong, nó bị park (ngủ chờ) trong FutureTask, worker xong thì đánh thức nó.

Cho mình một bài toán và code mẫu: cần làm 10 việc, thread pool có 5 thread (khai báo static dùng chung cho app), mỗi việc là một Callable trả về kết quả. Function phải đợi đủ 10 kết quả rồi mới tính output cuối.

Được. Bài toán: Order Service có báo cáo tổng doanh thu 10 cửa hàng, mỗi cửa hàng query mất ~1 giây, app có pool chung 5 thread.

public class RevenueService {
    // pool dùng chung cho cả app, không tạo mới mỗi lần gọi
    private static final ExecutorService EXECUTOR = Executors.newFixedThreadPool(5);

    public static double calculateTotalRevenue() throws Exception {
        List<Future<Double>> futures = new ArrayList<>();
        // 1. Tạo và submit 10 task
        for (int storeId = 1; storeId <= 10; storeId++) {
            final int id = storeId;
            futures.add(EXECUTOR.submit(() -> getRevenue(id)));
        }
        // 2. Chờ và lấy kết quả của cả 10 task
        double total = 0;
        for (Future<Double> f : futures) {
            total += f.get();
        }
        // 3. Có đủ 10 kết quả rồi mới return
        return total;
    }

    private static double getRevenue(int storeId) throws InterruptedException {
        System.out.println("Store " + storeId + " on " + Thread.currentThread().getName());
        Thread.sleep(1000);   // giả lập query DB
        return storeId * 1000.0;
    }
}
Task 1..5  → worker 1..5 chạy ngay
Task 6..10 → nằm trong queue, chờ worker rảnh
Tổng thời gian ≈ 2 giây (2 đợt), tuần tự sẽ là 10 giây

Hai câu hỏi về ví dụ này:

  1. future.get() chỉ trả về kết quả, mình không biết future đó là của việc thứ mấy. Vậy phải sửa code lúc tạo Callable?
  2. Bỏ final ở int id = storeId; thì có được không?

Đúng, bạn đang nhìn ra hạn chế của ví dụ trước. Cách nên dùng: cho Callable trả về object chứa cả ID và kết quả, đừng dựa vào index của List:

record StoreRevenue(int storeId, double revenue) {}

List<Future<StoreRevenue>> futures = new ArrayList<>();
for (int storeId = 1; storeId <= 10; storeId++) {
    int id = storeId;   // không cần final, miễn không gán lại
    futures.add(EXECUTOR.submit(() -> new StoreRevenue(id, getRevenue(id))));
}
for (Future<StoreRevenue> f : futures) {
    StoreRevenue r = f.get();
    System.out.println("Store " + r.storeId() + " = " + r.revenue());
}

Về final: bỏ được hoàn toàn. Lambda chỉ cần biến effectively final, tức là sau khi gán thì không đổi nữa.

int id = storeId;
Callable<Double> ok = () -> getRevenue(id);       // OK
id++;                                             // từ đây id không còn effectively final
Callable<Double> bad = () -> getRevenue(storeId); // ❌ storeId bị ++ trong vòng for

Phỏng vấn hỏi "Runnable khác Callable thế nào, Callable có chạy trên main thread không?" thì trả lời sao?

Câu 1: Runnable khác Callable thế nào?

🇻🇳 Tiếng Việt:

Runnable có run() trả void, không ném checked exception, đưa được vào new Thread(). Callable<V> có call() trả kết quả kiểu V và ném được exception, thường submit vào ExecutorService để nhận Future<V>. Executor bọc Callable thành FutureTask, vừa là Runnable để worker chạy, vừa là Future để mình lấy kết quả.

🇬🇧 English:

Runnable has void run(), cannot throw checked exceptions and can be passed to new Thread(). Callable<V> has call() that returns a V and may throw; we usually submit it to an ExecutorService and get a Future<V>. Internally the executor wraps it in a FutureTask, which is both a Runnable for the worker and a Future for the caller.

Câu 2: Callable có chạy trên main thread không?

🇻🇳 Tiếng Việt:

Tùy cách gọi. Callable chỉ mô tả công việc, không tự tạo thread: gọi trực tiếp call() thì chạy trên thread hiện tại, submit vào executor thì chạy trên worker của pool. Main chỉ bị block khi gọi future.get(), nên em submit hết task trước rồi mới get, có timeout. Tương tự, gọi thread.run() không tạo thread mới, phải gọi start().

🇬🇧 English:

It depends on how it is invoked. A Callable only describes work and never creates a thread: calling call() directly runs it on the current thread, submitting it runs it on a pool worker. The caller only blocks on future.get(), so I submit all tasks first and then get with a timeout. Likewise thread.run() does not start a new thread; start() does.

Câu 3: Future có nhược điểm gì?

🇻🇳 Tiếng Việt:

Future chỉ có get() (block), isDone(), cancel(); không gắn callback, không ghép nhiều kết quả. Cần nối tiếp hay ghép thì em dùng CompletableFuture với executor riêng, vì không truyền executor thì nó dùng common ForkJoinPool rất ít thread.

🇬🇧 English:

Future only offers blocking get(), isDone() and cancel(); no callbacks and no way to combine results. For chaining or combining I use CompletableFuture with a dedicated executor, because without one it falls back to the small common ForkJoinPool.

Cách nhớ ngắn gọn


Tự kiểm tra

Gọi start() hai lần trên cùng một Thread thì sao?

Lần hai ném IllegalThreadStateException. Thread đã start không thể start lại.

who.call() gọi trực tiếp trong main in ra gì?

"main". Gọi trực tiếp là method call thường, không có thread mới.

Task trong Callable ném exception thì main thấy ở đâu?

Khi gọi future.get(), exception được bọc trong ExecutionException; lấy lỗi gốc bằng getCause().

Vì sao executor chạy được Callable dù Thread chỉ nhận Runnable?

Nó bọc Callable vào FutureTask, class implement RunnableFuture = Runnable + Future.

Vì sao không nên gọi API chậm bằng supplyAsync mà không truyền executor?

Nó dùng common ForkJoinPool rất ít thread và dùng chung toàn JVM; task I/O block sẽ làm nghẽn mọi thứ khác dùng pool đó.