Async Waterfall: Chạy song song tác vụ độc lập thay vì tuần tự
Tối ưu hóa độ trễ bằng cách chạy đan xen các tác vụ bất đồng bộ độc lập mà không phá vỡ các chuỗi phụ thuộc dữ liệu thực tế.
Bản đồ học tập phát triển phần mềm bởi Tran Trong Thuc · Về dự án Atlas · Cập nhật lần cuối: 22 thg 9, 2026
Tóm tắt nhanh (TL;DR)
Hãy tưởng tượng một trang bảng điều khiển (dashboard) cần tải 4 khối dữ liệu độc lập: hồ sơ người dùng, cờ tính năng (feature flags), phân quyền và thông báo hệ thống. Mỗi API phản hồi trong 100ms. Nếu viết bằng một chuỗi await tuần tự nối đuôi nhau, 4 lời gọi 100ms sẽ biến thành một "thác nước" kéo dài 400ms—làm chậm ứng dụng gấp 4 lần và hủy hoại trải nghiệm người dùng cùng các chỉ số Core Web Vitals (LCP và TTFB). Bốn tác vụ này hoàn toàn không phụ thuộc dữ liệu của nhau, nhưng luồng điều khiển lại bắt chúng xếp hàng như những quân cờ domino. Khởi chạy song song bằng Promise.all() hoặc tách biệt thời điểm khởi tạo Promise khỏi thời điểm await sẽ co tổng thời gian chờ về đúng bằng request chậm nhất: ~100ms.
💡 Quy tắc bỏ túi: Tách biệt thời điểm khởi tạo Promise khỏi thời điểm await kết quả. Hãy kích hoạt mọi tác vụ bất đồng bộ độc lập càng sớm càng tốt, và chỉ
awaittại đúng vị trí mà công việc phía sau thực sự cần đến dữ liệu đầu ra của tác vụ đó.
awaitchỉ tạm dừng hàm hiện tại, không chặn Main Thread: Đặtawaittrước một Promise độc lập sẽ khiến hàm hiện tại đứng chờ vô ích, bỏ lỡ cơ hội kích hoạt các tác vụ khác vốn có thể chạy ngầm đồng thời.- Tách biệt thời điểm gọi hàm và thời điểm await: Gọi
const p = fetch(...)là lệnh mạng đã lập tức được bắn đi;await psau đó chỉ để thu nhận kết quả. Hãy kích hoạt sớm toàn bộ tác vụ độc lập rồi mới thu hoạch kết quả. - Chạy gối đầu co độ trễ về đường găng (Critical Path): Các tác vụ độc lập chạy đồng thời sẽ rút ngắn thời gian xử lý từ tổng số
sum(thời_lượng)xuống xấp xỉ bằngmax(thời_lượng), xóa bỏ thời gian chờ nhân tạo. Promise.allkhông tự động hủy tác vụ nền: Khi một Promise con thất bại,Promise.alllập tức reject (fail-fast), nhưng các tác vụ anh em đã khởi chạy vẫn âm thầm tiếp tục trừ khi được hủy chủ động bằngAbortSignal.- Cạm bẫy chết người (Cơn bão Fan-out không giới hạn): Vội vã thay thế vòng lặp bằng
await Promise.all(items.map(...))khi xử lý hàng nghìn phần tử sẽ tạo ra cơn bão kết nối đồng thời, làm cạn kiệt connection pool của cơ sở dữ liệu, tràn socket mạng và đánh sập hạ tầng nội bộ. Luôn phải giới hạn mức độ đồng thời (bounded concurrency) khi độ bung tỏa lớn.
Mô hình tư duy (Mental Model)
Hãy bắt đầu với một kịch bản thời gian biểu cụ thể. Giả sử hệ thống cần thực hiện ba tác vụ độc lập lần lượt mất 800ms, 400ms, và 300ms.
Nếu mỗi tác vụ chỉ khởi động sau khi tác vụ trước kết thúc hoàn toàn:
Tác vụ A: 0 ───────────── 800
Tác vụ B: 800 ───── 1200
Tác vụ C: 1200 ─── 1500Tổng thời gian thực thi là 800 + 400 + 300 = 1500ms.
Nếu cả ba tác vụ có thể khởi động ngay lập tức tại thời điểm ban đầu:
Tác vụ A: 0 ───────────── 800
Tác vụ B: 0 ───── 400
Tác vụ C: 0 ─── 300Tổng thời gian thực thi chỉ bằng tác vụ lâu nhất: max(800, 400, 300) = 800ms. Khoảng thời gian chờ đợi đã gối đầu lên nhau, tiết kiệm được tới 700ms.
Tuần tự: ~1500 ms
Chồng lấp: ~800 ms
Câu hỏi cốt lõi khi thiết kế:
Cần thông tin gì tồn tại trước khi thao tác này có thể bắt đầu?
Nếu câu trả lời là "không cần dữ liệu nào từ thao tác trước", thao tác đó hoàn toàn có thể được kích hoạt sớm hơn.
Thử nghiệm thực tế: Async Waterfall Lab
Tự điều chỉnh thời lượng của 3 tác vụ để quan sát trực quan sự chênh lệch thời gian giữa hai mô hình:
Phòng thực hành Thác nước bất đồng bộ
Xử lý tuần tự (Sequential)
| Tác vụ | Bắt đầu | Thời lượng | Kết thúc |
|---|---|---|---|
| A | 0ms | 800ms | 800ms |
| B | 800ms | 400ms | 1200ms |
| C | 1200ms | 300ms | 1500ms |
Tổng thời gian tuần tự: 1500ms
Xử lý đồng thời (Concurrent)
| Tác vụ | Bắt đầu | Thời lượng | Kết thúc |
|---|---|---|---|
| A | 0ms | 800ms | 800ms |
| B | 0ms | 400ms | 400ms |
| C | 0ms | 300ms | 300ms |
Tổng thời gian đồng thời: 800ms
Nhận diện nguyên nhân gây ra Thác nước
Mã nguồn tuần tự rất dễ đọc và trực quan, do đó thác nước vô tình xuất hiện trông rất bình thường:
const user = await getUser(id);
const organization = await getOrganization(user.organizationId);
const flags = await getFeatureFlags(id);
const recommendations = await getRecommendations(id);Trong ví dụ trên:
getOrganizationbắt buộc phải chờgetUservì nó cầnuser.organizationId(phụ thuộc thực tế).- Nhưng
getFeatureFlags(id)vàgetRecommendations(id)chỉ cầnidban đầu; chúng hoàn toàn không cầnuserhayorganization! Việc bắt chúng đứng chờ là sự lãng phí thời gian không đáng có.
Giải pháp tối ưu là chạy gối đầu các tác vụ độc lập:
const flagsPromise = getFeatureFlags(id);
const recommendationsPromise = getRecommendations(id);
const user = await getUser(id);
const organization = await getOrganization(user.organizationId);
const [flags, recommendations] = await Promise.all([
flagsPromise,
recommendationsPromise,
]);Ở đây, chuỗi user → organization vẫn diễn ra tuần tự đúng nghiệp vụ, trong khi hai tác vụ độc lập kia đã hoàn thành xong từ trước.
Đường găng: 900 ms (Tuần tự bắt buộc)
Các nhánh độc lập (Chạy song song từ t=0)
Promise.all() là công cụ, không phải quy tắc vạn năng
Promise.all(iterable) trả về một Promise hoàn thành khi tất cả các Promise con đều hoàn thành. Kết quả trả về giữ nguyên thứ tự truyền vào ban đầu.
Tuy nhiên, cần đặc biệt lưu ý: khi một Promise con bị reject, Promise.all() lập tức bị reject mà không hề tự động hủy bỏ (cancel) các tác vụ con khác đã được khởi chạy! Các thao tác đó vẫn tiếp tục chạy ngầm trong nền trừ khi API bên dưới hỗ trợ cơ chế hủy (như AbortSignal) và bạn chủ động kích hoạt nó.
Cân nhắc trong môi trường Production
Tình huống thực tế: Cơn bão Fan-out đánh sập dịch vụ nội bộ
Một dịch vụ báo cáo phân tích cần truy xuất lịch sử giao dịch cho 2,000 thành viên tích cực. Một lập trình viên nhận thấy vòng lặp for...await chạy quá chậm nên đã thay thế bằng:
await Promise.all(members.map(fetchHistory));Ở môi trường kiểm thử với 10 người dùng, đoạn code chạy cực nhanh trong 45ms. Tuy nhiên khi triển khai lên production với 2,000 thành viên, nó lập tức kích hoạt 2,000 kết nối TCP đồng thời, làm tràn bộ đệm kết nối và kéo sập dịch vụ xác thực nội bộ với lỗi ECONNRESET, gây tê liệt hàng loạt dịch vụ phụ thuộc trong toàn công ty.
- Hậu quả: Hệ thống sập cục bộ trên diện rộng, tỷ lệ lỗi 5xx tăng đột biến trên production.
- Nguyên nhân cốt lõi: Lập trình viên nhầm lẫn giữa việc tối ưu hóa độ trễ của một request cá nhân với việc bảo toàn sức chịu tải đồng thời (throughput) của toàn hệ thống.
- Cách khắc phục chuẩn: Áp dụng cơ chế giới hạn mức độ đồng thời (Bounded Concurrency) thông qua Semaphore hoặc Worker Pool với
limit = 10-20:
import pLimit from 'p-limit';
const limit = pLimit(15);
const results = await Promise.all(
members.map(member => limit(() => fetchHistory(member)))
);- 1.000 job trong queueCông việc độc lập
- Semaphore: 5 slotKiểm soát đầu vào
- 5 worker đang chạyFan-out có giới hạn
- Database / APITải downstream ổn định
Khi nào thực thi tuần tự là chính xác?
Thực thi tuần tự không phải là lỗi thiết kế. Hãy giữ nguyên tính tuần tự khi:
- Thao tác sau cần dữ liệu được sinh ra từ thao tác trước.
- Các thao tác ghi dữ liệu bắt buộc phải theo thứ tự thời gian định sẵn.
- Giao thức xử lý giao dịch (Transaction Protocol) yêu cầu các bước tuần tự.
- Hệ thống chủ động áp dụng cơ chế điều áp ngược (Backpressure).
- Thao tác phía sau chỉ nên chạy sau khi bước xác thực hoặc kiểm tra quyền phía trước thành công để tránh lãng phí chi phí tính toán.
Bài tập củng cố tư duy
Một request cần thực thi 4 thao tác sau:
| Thao tác | Thời lượng | Phụ thuộc |
|---|---|---|
getUser(id) | 500ms | Không |
getFeatureFlags(id) | 300ms | Không |
getRecommendations(id) | 700ms | Không |
getOrganization(user.organizationId) | 400ms | getUser |
Hãy xác định:
- Những thao tác nào có thể chạy ngay?
- Thao tác nào bắt buộc phải chờ?
- Thời gian hoàn thành tối ưu nhất trong trường hợp lý tưởng là bao nhiêu?
Xem giải thích chi tiết
-
getUser,getFeatureFlags, vàgetRecommendationscó thể bắt đầu ngay lập tức vì chúng chỉ cầnidban đầu. -
getOrganizationbắt buộc phải chờgetUservì nó cần giá trịuser.organizationId. -
Đường găng (Critical path) của bài toán:
- Nhánh
user → organization:500 + 400 = 900ms. - Nhánh
featureFlags:300ms. - Nhánh
recommendations:700ms.
Đường găng dài nhất là 900ms. Đây chính là thời gian hoàn tất tối ưu. Nếu thực thi tuần tự hoàn toàn, hệ thống sẽ tốn tới
500 + 300 + 700 + 400 = 1900ms. - Nhánh
Nguyên tắc cốt lõi dành cho Kỹ sư
Khi các thao tác bất đồng bộ độc lập với nhau, hãy khởi chạy chúng trước khi chờ đợi kết quả của thao tác khác. Giữ nguyên
awaittuần tự khi có phụ thuộc dữ liệu thực tế hoặc khi cần điều áp hệ thống. Không bao giờ đưa vào cơ chế đồng thời không giới hạn chỉ để giảm chút độ trễ, và không mặc định rằngPromise.all()bị lỗi sẽ tự động hủy các thao tác đang chạy ngầm.
Checklist kiểm tra khi tối ưu luồng bất đồng bộ:
- Phụ thuộc thật vs Phụ thuộc giả: Thao tác B có thực sự cần giá trị trả về của thao tác A không, hay chỉ ngẫu nhiên được viết nối tiếp nhau?
- Tác dụng phụ & Tính toàn vẹn: Có thao tác ghi dữ liệu hoặc sửa đổi trạng thái nào trở nên mất an toàn nếu chạy đồng thời không?
- Giới hạn Fan-out: Số lượng thao tác đồng thời có nguy cơ làm cạn kiệt socket mạng, pool kết nối cơ sở dữ liệu hay vượt ngưỡng rate limit không?
- Tận dụng thời gian gối đầu: Có công việc độc lập nào có thể kích hoạt sớm trong khi đường găng đang chạy không?
- Cơ chế hủy bỏ (Cancellation): Nếu một thao tác thất bại, các request còn lại có cần được hủy bỏ chủ động qua
AbortSignalkhông?
Nguồn tham khảo chuẩn mực
Vòng lặp kiểm chứng & guardrail kiểm tra được bằng máyNew
Biến ý định kỹ thuật thành bằng chứng mới bằng tiêu chí chấp nhận rõ ràng, phản hồi nhanh, các cổng kiểm tra của kho mã và guardrail mà tác nhân không thể vượt qua chỉ bằng lời khẳng định.
Cơ chế hoạt động của Event Loop trong Trình duyệt
Hiểu sâu sắc về task queue, microtask, checkpoint, rendering pipeline, đói tài nguyên và sự khác biệt căn bản so với Node.js.