Hình dung tạo một Future như bấm nút Start trên máy giặt: máy chạy ngay khoảnh khắc bạn bấm, không phải lúc bạn quay lại lấy đồ. Nạp đồ và bấm start hai máy (tạo hai Future) là cả hai đang quay song song; câu compose sau đó chỉ là "đợi máy một xong rồi đợi máy hai", nó không khiến chúng chạy nối tiếp. 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. Soi những chỗ dễ sai cũng nhanh:
# 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.
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 |
Mẫu số chung
Một Future của Vert.x là háo hức (eager): công việc bắt đầu ngay khoảnh khắc bạn tạo nó, không phải khi bạn ghép — nên việc nối chuỗi sắp xếp điều gì xảy ra với kết quả, chứ không hề quyết định khi nào công việc bắt đầu. Đó là lý do compose hai Future đã tạo lại chạy song song, và lý do muốn tuần tự thì phải tạo việc sau bên trong phần tiếp nối của việc trước. Biết cái nguyên thuỷ bất đồng bộ của mình là háo hức hay lười (hot hay cold) là điều sống còn: Promise của JS háo hức đúng như thế này, còn observable của RxJava/Reactor và Flow của Kotlin thì lười — không gì chạy cho tới khi có người subscribe. Cùng một cú pháp nối chuỗi, thời điểm chạy ngược nhau, tuỳ cái bạn đang cầm.
Điều thứ hai: mỗi toán tử ghép gói sẵn một chính sách về lỗi-một-phần và thời điểm — all hỏng-nhanh (cái đầu tiên hỏng là đoản mạch), join đợi-tất-cả (nên biết chính xác ai đã xong), any lấy-cái-thành- công-đầu-tiên. Chọn theo ngữ nghĩa cần, đừng theo thói quen: lời gọi thuần đọc muốn câu trả lời sớm nhất (all), lời gọi có ghi cần biết ai đã kịp commit (join). Và một cái bẫy sống lâu hơn cả Vert.x: trả về sớm không phải là huỷ — các việc còn lại vẫn chạy tới cùng, bạn chỉ ngừng nghe (Vert.x không có huỷ). Cùng bộ thực đơn ấy, cùng cái bẫy ấy, lặp lại ở Promise.all/allSettled/race/any của JS và errgroup của Go — chọn một toán tử là chọn cách lỗi và thời gian được xử lý.
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.