Phần trước mổ một Future. Bài này ghép năm cái, và đo xem cách ghép đổi được bao nhiêu — cùng một cái bẫy làm hai đoạn mã trông giống hệt nhau chạy khác nhau hoàn toàn.

Năm lời gọi, mỗi cái 100 ms

// tuần tự — mỗi bước tạo Future SAU khi bước trước xong
goi("a").compose(v -> goi("b")).compose(v -> goi("c"))
        .compose(v -> goi("d")).compose(v -> goi("e"));

// song song
Future.all(List.of(goi("a"), goi("b"), goi("c"), goi("d"), goi("e")));
tuần tự (compose nối tiếp): 507 ms
song song (Future.all)    : 101 ms
song song (Future.join)   : 101 ms

Năm lần. Không có gì bất ngờ ở đây, nhưng nó đặt nền cho phần sau: cách bạn ghép quyết định thời gian, không phải số lời gọi.

all so với join khi có lỗi

Hai hàm này cùng chờ nhiều Future và cùng thất bại khi có cái hỏng. Khác biệt chỉ lộ ra ở thời điểm.

Cho một lời gọi hỏng ở 20 ms, hai lời gọi còn lại xong ở 300 ms:

Trả về sau Kết quả
Future.all 21 ms thất bại
Future.join 301 ms thất bại
all hỏng nhanh: cái đầu tiên thất bại là nó trả về ngay, không đợi ai. join đợi hết: mọi Future phải kết thúc — dù thành công hay thất bại — rồi mới báo. Cả hai đều báo thất bại; chúng chỉ khác nhau ở chỗ bạn biết lúc nào.

Chọn cái nào theo câu hỏi: những lời gọi kia có tác dụng phụ không?

Nếu năm lời gọi chỉ để đọc dữ liệu, all đúng hơn — biết sớm 280 ms là 280 ms trả lời sớm hơn cho người dùng. Nếu chúng đang ghi dữ liệu, join đúng hơn: bạn cần biết cái nào đã kịp ghi để dọn dẹp, và all trả về khi hai cái kia vẫn đang chạy dở.

Lưu ý quan trọng: all trả về sớm không hề huỷ những Future còn lại. Chúng vẫn chạy tới cùng; bạn chỉ ngừng nghe. Vert.x không có cơ chế huỷ, nên đừng nhầm "trả về sớm" với "dừng lại".

any: xong khi có một cái thành công

any trả về sau 31 ms (thành công)

Ba lời gọi: một cái 300 ms, một cái 30 ms, một cái hỏng ở 10 ms. any bỏ qua cái hỏng và trả về ngay khi cái 30 ms xong. Dùng cho mẫu "hỏi ba bản sao, lấy cái trả lời trước".

Bẫy: Future chạy ngay khi tạo, không phải khi ghép

Đây là chỗ tôi nghĩ nhiều người viết sai mà không biết:

var f1 = goi("x", 100);       // ĐÃ bắt đầu chạy ngay dòng này
var f2 = goi("y", 100);       // ĐÃ bắt đầu chạy ngay dòng này
f1.compose(v -> f2);          // trông như nối tiếp...
vừa tạo 2 Future, chưa ghép -> số lệnh ĐÃ bắt đầu: 2
compose hai Future ĐÃ tạo: 98 ms  (không phải 200 ms)

Chín mươi tám mili giây, không phải hai trăm. compose không tuần tự hoá gì cả — nó chỉ nối kết quả. Hai lời gọi đã chạy song song từ trước, và compose chỉ ngồi đợi cái thứ hai.

Điều này vô hại khi bạn muốn song song, và tai hại khi bạn cần thứ tự — ví dụ phải tạo bản ghi cha rồi mới tạo bản ghi con. Khác biệt nằm ở chỗ Future được tạo ở đâu:

// SAI — cả hai chạy song song
var f2 = taoCon(id);
taoCha().compose(v -> f2);

// ĐÚNG — con chỉ được tạo sau khi cha xong
taoCha().compose(cha -> taoCon(cha.id()));

Luật: trong compose, hãy gọi hàm sinh ra Future, đừng tham chiếu tới một Future đã có sẵn.

Bảng chọn

Cần gì Dùng
Chạy tuần tự, bước sau cần kết quả bước trước compose, tạo Future bên trong lambda
Chạy song song, cần tất cả thành công, muốn biết sớm Future.all
Chạy song song, có tác dụng phụ, cần biết cái nào xong Future.join
Lấy cái trả lời đầu tiên Future.any
Dọn dẹp dù thành công hay thất bại eventually

Bài sau: Context — nơi dữ liệu của request sống trong thế giới bất đồng bộ, và vì sao ThreadLocal không chỉ mất dữ liệu mà còn trả về dữ liệu của người khác.

Thử ba mươi giây

# tim nhung cho tao Future ra bien roi moi compose
grep -rn -B2 "\.compose(" src/ | grep -E "var f[0-9]* = |Future<.*> f" | head

Mỗi dòng tìm được là một chỗ đáng xem lại: nếu Future đã nằm trong biến trước khi compose chạm tới, nó đã khởi động rồi.