Promise: state, chaining, và chọn đúng combinator cho xử lý song song
Promise là một object đại diện cho kết quả tương lai của một thao tác bất đồng bộ. Nó có ba trạng thái — pending, fulfilled, rejected — và một khi rời pending thì cố định, không đổi nữa (settled). Trên nền đó là chaining (.then/.catch) để nối các bước, và một nhóm combinator (all, allSettled, race, any) để chạy nhiều việc song song. Chọn sai combinator hoặc bỏ sót xử lý lỗi là nguồn của unhandled rejection làm sập process và của những lỗi "một việc fail kéo sập cả lô".
Cơ chế hoạt động
Khi một Promise settle, các callback .then/.catch của nó không chạy ngay mà được đẩy vào microtask queue, chạy sau khi stack hiện tại rỗng và trước macrotask kế tiếp. Lỗi lan theo chuỗi: một rejection trượt qua mọi .then cho tới .catch gần nhất phía dưới.
fetchUser(id)
.then((user) => fetchOrders(user.id)) // trả Promise -> chuỗi đợi nó
.then((orders) => render(orders))
.catch((err) => log(err)) // bắt lỗi của bất kỳ bước nào ở trên
Trả về một Promise trong .then làm chuỗi đợi Promise đó resolve trước khi sang bước sau — đây là cách nối tuần tự các bước async mà không lồng callback. Quên return trong một .then là bug âm thầm: bước sau chạy trước khi việc async hoàn tất.
Vấn đề gặp trong production
Chọn combinator quyết định hành vi khi có lỗi. Bốn combinator khác nhau ở chỗ "khi nào kết thúc" và "lỗi xử lý ra sao":
await Promise.all(tasks) // fail-fast: một reject -> cả lô reject ngay
await Promise.allSettled(tasks) // chờ hết: trả [{status,value|reason}], không reject
await Promise.race(tasks) // settle theo cái xong trước (kể cả reject)
await Promise.any(tasks) // resolve theo cái fulfilled đầu tiên
Promise.all hợp khi mọi việc phải thành công thì kết quả mới có nghĩa (tải đủ dữ liệu để render một trang). Nhưng failure mode điển hình là dùng all cho các việc độc lập: gửi 100 email, một địa chỉ lỗi làm all reject và 99 email còn lại coi như thất bại dù phần lớn đã gửi xong. Ở đây allSettled mới đúng — nó chờ tất cả, trả về kết quả từng cái để tách thành công/thất bại:
const results = await Promise.allSettled(emails.map(send))
const failed = results.filter((r) => r.status === 'rejected')
// retry riêng phần failed, không vứt cả lô
race dùng cho timeout (đua việc thật với một Promise reject sau N ms). any dùng khi chỉ cần một nguồn thành công (đọc từ nhiều replica, lấy cái trả lời đầu tiên).
Failure mode: unhandled rejection. Một Promise reject mà không có .catch (hoặc không được await trong try/catch) tạo unhandled rejection. Trong Node hiện đại nó làm crash process theo mặc định:
async function notify(user) { await sendEmail(user) }
notify(user) // BUG: không await, không catch -> nếu reject thì unhandled
Gọi một hàm async mà không await cũng không .catch là "fire and forget" sai cách: nếu nó reject, không ai bắt. Phải hoặc await trong một try/catch, hoặc gắn .catch tường minh nếu cố ý không chờ.
Cách debug và monitor
Bắt unhandled rejection ở tầng process để không bao giờ bỏ sót: process.on('unhandledRejection', handler) trong Node — log đầy đủ rồi cho process thoát có kiểm soát thay vì chết câm. Khi một lô việc song song "thỉnh thoảng mất kết quả", kiểm tra ngay có đang dùng all cho việc độc lập không — đổi sang allSettled và đếm số rejected. Đo: all chạy song song nên tổng thời gian xấp xỉ việc chậm nhất, không phải tổng các việc; nếu thấy thời gian bằng tổng tuần tự thì đang vô tình await từng cái một thay vì gom vào all.
Tradeoff
Chạy song song bằng Promise.all nhanh — thời gian bằng việc chậm nhất thay vì tổng — nhưng khó kiểm soát khi có lỗi giữa chừng: các việc đã bắt đầu không tự rollback, và all reject ngay làm khó biết cái nào đã xong. allSettled an toàn hơn cho việc độc lập (không mất kết quả) nhưng buộc phải tự xử lý từng kết quả và không "fail fast". Quy tắc thực tế: mọi-hoặc-không (dữ liệu phụ thuộc nhau) → all; việc độc lập cần biết từng cái → allSettled; cần một cái nhanh nhất/đầu tiên → race/any. Và mọi thao tác song song có side-effect (ghi DB, gửi tiền) phải thiết kế idempotent để retry phần failed an toàn.
Câu hỏi phỏng vấn
Promise.allkhácallSettledthế nào, và khi nào dùng cái nào?
Promise.all fail-fast: nó reject ngay khi một promise trong lô reject, và chỉ resolve khi tất cả fulfilled — dùng khi mọi việc phải thành công thì kết quả mới có nghĩa, như tải đủ dữ liệu phụ thuộc nhau để render. allSettled luôn chờ tất cả settle và không bao giờ reject; nó trả về mảng {status, value|reason} cho từng promise — dùng cho các việc độc lập mà ta không muốn một cái lỗi kéo sập cả lô, như gửi hàng loạt email rồi retry riêng phần failed. Điểm cộng: nhắc Promise.all chạy song song nên thời gian bằng việc chậm nhất, và mọi việc song song có side-effect cần idempotent để retry an toàn; bỏ await/.catch gây unhandled rejection làm crash process.
Hands-on
Viết một tác vụ thật gửi hàng loạt thông báo (email hoặc webhook) cho một danh sách người dùng, đầu tiên dùng Promise.all và mô phỏng một vài địa chỉ lỗi để thấy cả lô bị coi là thất bại. Đổi sang Promise.allSettled, tách fulfilled/rejected, và retry riêng phần rejected với backoff. Thêm timeout cho từng việc bằng Promise.race, và đăng ký process.on('unhandledRejection') rồi cố tình tạo một promise reject không catch để xác nhận handler bắt được — đúng lớp bảo vệ cần có trong service production.
Top comments (0)