Mới54 bài học mới được bổ sung từ 10/09!
Xem nhật ký cập nhật →
Software Development Atlas
Lập trìnhLập trình bất đồng bộ

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ế.

Phát triểnĐã xác minh: 9 thg 9, 2026Đánh giá lại: 180 ngày

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ỉ await tạ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ụ đó.

  • await chỉ tạm dừng hàm hiện tại, không chặn Main Thread: Đặt await trướ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 p sau đó 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ằng max(thời_lượng), xóa bỏ thời gian chờ nhân tạo.
  • Promise.all không tự động hủy tác vụ nền: Khi một Promise con thất bại, Promise.all lậ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ằng AbortSignal.
  • 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 ─── 1500

Tổ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 ─── 300

Tổ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.

Waterfall tuần tự so với thời gian chờ chồng lấp

Tuần tự: ~1500 ms

A · 800 ms
B · 400 ms
C · 300 ms

Chồng lấp: ~800 ms

A bắt đầu t=0 · 800 ms
B bắt đầu t=0 · 400 ms
C bắt đầu t=0 · 300 ms
Các khoảng chờ độc lập có thể bắt đầu cùng lúc để tổng thời gian gần với nhánh bắt buộc chậm nhất thay vì tổng tất cả.

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ộ

Thay đổi thời lượng để đối chiếu thời gian chờ tuần tự so với công việc bất đồng bộ độc lập chạy đồng thời. Cả hai đồ thị đều dùng cùng một thang thời gian.
Thời lượng tác vụ

Xử lý tuần tự (Sequential)

Tác vụBắt đầuThời lượngKết thúc
A0ms800ms800ms
B800ms400ms1200ms
C1200ms300ms1500ms

Tổng thời gian tuần tự: 1500ms

Xử lý đồng thời (Concurrent)

Tác vụBắt đầuThời lượngKết thúc
A0ms800ms800ms
B0ms400ms400ms
C0ms300ms300ms

Tổng thời gian đồng thời: 800ms

Với thời lượng này, việc chạy đồng thời các tác vụ độc lập tiết kiệm được 700ms thời gian chờ tổng thể.

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:

  • getOrganization bắt buộc phải chờ getUser vì nó cần user.organizationId (phụ thuộc thực tế).
  • Nhưng getFeatureFlags(id) và getRecommendations(id) chỉ cần id ban đầu; chúng hoàn toàn không cần user hay organization! 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.

Đồ thị phụ thuộc và Critical Path

Đường găng: 900 ms (Tuần tự bắt buộc)

getUser · 500 ms
Bước 1: Bắt đầu tại t=0
getOrganization · 400 ms
Bước 2: Chờ user.orgId

Các nhánh độc lập (Chạy song song từ t=0)

Feature flags · 300 ms
Hoàn thành tại t=300 ms
Recommendations · 700 ms
Hoàn thành tại t=700 ms
Khởi động sớm các nhánh độc lập tại t=0 nhưng vẫn giữ đúng chuỗi phụ thuộc tuần tự quyết định thời điểm hoàn tất.

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)))
);
Concurrency có giới hạn bảo vệ downstream
  1. 1.000 job trong queue
    Công việc độc lập
  2. Semaphore: 5 slot
    Kiểm soát đầu vào
  3. 5 worker đang chạy
    Fan-out có giới hạn
  4. Database / API
    Tải downstream ổn định
Worker pool cho phép công việc độc lập chồng lấp mà không mở số kết nối hoặc request không giới hạn.

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ácThời lượngPhụ thuộc
getUser(id)500msKhông
getFeatureFlags(id)300msKhông
getRecommendations(id)700msKhông
getOrganization(user.organizationId)400msgetUser

Hãy xác định:

  1. Những thao tác nào có thể chạy ngay?
  2. Thao tác nào bắt buộc phải chờ?
  3. 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
  1. getUser, getFeatureFlags, và getRecommendations có thể bắt đầu ngay lập tức vì chúng chỉ cần id ban đầu.

  2. getOrganization bắt buộc phải chờ getUser vì nó cần giá trị user.organizationId.

  3. Đườ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.

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 await tuầ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ằng Promise.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 AbortSignal không?

Nguồn tham khảo chuẩn mực

Mục lục bài học