Hình dung Promise và Future như hai đầu của một phiếu sửa đồ: khi bạn mang máy tới tiệm, tiệm giữ tờ phiếu công việc — họ là người sẽ đóng dấu "xong" hoặc "hỏng" (Promise); còn bạn cầm tờ phiếu hẹn, chỉ có thể chờ và xem kết quả (Future). Đưa nhầm tờ phiếu công việc cho khách là mời họ tự đóng dấu "xong" cho đơn của mình. Vert.x không có async/await, không có CompletableFuture ở tầng API. Nó có hai kiểu đơn giản tới mức dễ dùng sai này. Bài này đo bốn hành vi mà bạn sẽ gặp trong tuần đầu tiên.
Promise là đầu ghi, Future là đầu đọc
Promise<String> p = Promise.promise(); // bạn hoàn tất nó
vertx.setTimer(20, id -> p.complete("xong")); // hoặc p.fail(...)
return p.future(); // người gọi chỉ đọc
Đây là cách bọc mọi API kiểu callback thành thứ ghép nối được. Quy tắc: hàm của bạn trả Future, và chỉ nơi tạo ra công việc mới giữ Promise. Đưa Promise cho người gọi là mời họ hoàn tất nhầm nó.
compose: hỏng một bước là dừng cả chuỗi
buoc1()
.compose(v -> buoc2()) // bước này hỏng
.compose(v -> buoc3())
.compose(v -> buoc4())
.onFailure(t -> ...);
chuỗi thất bại: buoc-2 hong
các bước ĐÃ chạy: [buoc-1, buoc-2]
Bước 3 và 4 không bao giờ chạy. Đây là hành vi bạn muốn — nhưng nhớ rằng nó cũng có nghĩa là mọi việc dọn dẹp đặt ở cuối chuỗi cũng bị bỏ qua. Cần chắc chắn chạy thì dùng eventually, đừng nối vào compose.
recover khác otherwise ở chỗ nào
Hai cái tên gần giống nhau, và khác biệt là kiểu trả về:
.recover(t -> Future.succeededFuture("dự phòng")) // trả về FUTURE
.otherwise(t -> "dự phòng") // trả về GIÁ TRỊ
Cả hai đều biến thất bại thành thành công, nhưng recover cho bạn chạy tiếp một việc bất đồng bộ — gọi sang dịch vụ dự phòng, đọc cache — còn otherwise chỉ thay bằng một giá trị có sẵn ngay.
Quy tắc chọn: cần I/O để cứu thì recover, có sẵn giá trị thì otherwise.
Ngoại lệ đi về đâu
Đây là phần đáng nhớ nhất của bài, vì hai trường hợp trông giống nhau lại xử sự ngược nhau:
ném trong map() -> map bắt lại và biến thành thất bại: IllegalStateException
ném trong onSuccess() -> onFailure có chạy không? KHÔNG — ngoại lệ bị nuốt
map, compose, flatMap là các toán tử biến đổi: ngoại lệ ném ra trong đó được bắt lại và chuyển thành thất bại của Future, nên onFailure phía sau bắt được. onSuccess và onFailure là các toán tử tiêu thụ — chúng đứng ở cuối chuỗi, và ngoại lệ ném ra ở đó không đi đâu cả.
Hệ quả thực tế: nếu logic nghiệp vụ của bạn nằm trong onSuccess và nó ném, bạn mất luôn lỗi đó — không log, không 500, request treo cho tới khi hết giờ. Hãy đặt việc thật trong map/compose, và để onSuccess chỉ làm đúng một việc là trả lời. Tìm những chỗ đặt sai cũng nhanh:
// tim moi cho ban dat logic nghiep vu vao onSuccess
grep -rn "onSuccess(.*->.*{" src/ | head -20
Mỗi khối onSuccess nhiều hơn một dòng là một chỗ ngoại lệ có thể biến mất.
Chi phí thật của một chuỗi Future
Câu hỏi hay gặp: bọc mọi thứ vào Future có tốn không? Đo 5 triệu lần, kết quả hai vế được đối chiếu để chắc chắn JIT không xoá mất vòng lặp:
| Mỗi thao tác | Số thao tác mỗi giây | |
|---|---|---|
| Gọi hàm trực tiếp (3 bước) | 0,29 ns | 3 399 385 663 |
Chuỗi 3 Future |
25,03 ns | 39 952 383 |
Đắt hơn 85 lần — và con số đó hoàn toàn không quan trọng. Chênh lệch tuyệt đối là 25 nano giây, trong khi một lời gọi mạng nội bộ tốn khoảng 200 000 nano giây. Chuỗi Future đắt gấp 85 lần một phép nhân, và rẻ hơn tám nghìn lần so với việc mà nó đang điều phối.
Bốn mươi triệu chuỗi mỗi giây cũng cao hơn nhiều lần thông lượng tối đa mà phần 1 đo được cho chính máy chủ HTTP (86 804 req/s).
Nhưng đừng bọc vòng lặp tính toán vào Future. Nếu bạn đang map từng phần tử của một mảng một triệu phần tử, 25 ns nhân lên thành 25 mili giây — và phần 3 đã đo 50 ms chặn tốn bao nhiêu.
Mẫu số chung
Lỗi chỉ chạy dọc theo chuỗi tới mắt xích cuối cùng: một ngoại lệ ném trong toán tử biến đổi (map/compose/flatMap) được bắt lại và biến thành thất bại của cả chuỗi, nhưng ném trong toán tử tiêu thụ ở cuối (onSuccess) thì không còn đường ray nào để đi — nó biến mất, để lại một request treo không một dòng log. Mọi thế giới promise/stream đều có cái mối nối này: một callback .then của JS ném ra thì reject promise kế tiếp, nhưng ném trong .then cuối không có .catch là một rejection bị nuốt; toán tử của RxJava/Reactor so với tác dụng phụ trong một subscriber. Quy tắc rút ra: đặt việc thật vào các toán tử biến đổi (nơi lỗi được chở đi), và để mắt cuối chỉ làm đúng một việc — vì qua khỏi mắt cuối thì không còn gì đỡ lỗi cho bạn nữa.
Điều thứ hai: việc dọn dẹp phải móc vào một cấu trúc chạy bất kể kết quả, đừng nối vào nhánh thành công. compose đoản mạch ngay ở bước hỏng đầu tiên, nên bất cứ thứ gì bạn nối phía sau sẽ bị bỏ qua đúng vào lúc cần nhất — trên đường lỗi. Vì thế hệ sinh thái nào cũng cho bạn một cái móc "chạy dù sao đi nữa": try/finally, defer của Go, RAII, Promise.finally, và ở đây là eventually. Việc-phải-chắc- chắn-xong thuộc về đó; còn chuỗi thành công thì không có gì bảo đảm nó chạy tới cuối. (Và mặt còn lại từ phép đo chi phí: một khoản phụ trội 25 ns là sai số làm tròn cho một lời gọi, nhưng nhân lên một triệu vòng lặp thì thành 25 mili giây thật — cùng một con số, tầm thường hay chí mạng là tuỳ hệ số nhân.)
Bài sau: ghép nhiều Future — all, any, join — và đo khác biệt giữa chạy song song với chạy tuần tự.