JavaScript bất đồng bộ: vì sao forEach không chờ await?

6 giờ tới · 9 phút đọc
Vấn đề: log kết quả trước khi request xong
Bạn viết một vòng lặp tưởng chừng đơn giản:
Kết quả in ra terminal:
Xong rồi!
{ id: 1, name: 'Alice' }
{ id: 2, name: 'Bob' }
{ id: 3, name: 'Carol' }
'Xong rồi!' xuất hiện trước cả ba kết quả. Không phải bug ngẫu nhiên - đây là hành vi đúng theo spec của JavaScript.
forEach không chờ callback async - đây là thiết kế, không phải bug
Tại sao lại thế? forEach được thiết kế cho callback đồng bộ. Khi bạn truyền async function vào, nó vẫn gọi callback và nhận lại một Promise - nhưng không await Promise đó. Vòng lặp tiếp tục chạy ngay lập tức mà không chờ Promise resolve.
Nói cách khác: forEach không quan tâm callback trả về gì. Nó gọi xong là bỏ qua.
Tái hiện lỗi để hiểu rõ hơn
Muốn quan sát rõ hành vi này, bạn có thể tự mô phỏng fetchUser bằng một hàm trả về Promise sau một khoảng delay (đây là bài tập giả định để quan sát thứ tự):
Chạy đoạn trên trong Node.js hoặc browser console, bạn thấy ngay:
Bắt đầu forEach...
forEach kết thúc - nhưng data chưa về!
Nhận được: { id: 1, name: 'User 1' }
Nhận được: { id: 2, name: 'User 2' }
Nhận được: { id: 3, name: 'User 3' }
Chạy thử trong Node.js để thấy thứ tự log - kết quả không sai, chỉ khác kỳ vọng
Thứ tự hoàn tất của id 1, 2, 3 có thể thay đổi nếu bạn truyền delay khác nhau cho từng call. Thử fakeApi(1, 800), fakeApi(2, 200), fakeApi(3, 500) - bạn sẽ thấy id 2 về trước. Đây là bằng chứng rõ nhất: forEach không đảm bảo thứ tự hoàn tất, không chờ bất kỳ call nào.
Ba cách xử lý: so sánh for...of, Promise.all và forEach
Ba cách tiếp cận giải quyết bài toán "lấy dữ liệu cho danh sách ID" - mỗi cách có trade-off khác nhau.
Chọn đúng cách tùy theo yêu cầu tuần tự hay song song
Cách 1: for...of - tuần tự, chắc chắn
Kết quả đúng như mong đợi - id 1 xong mới sang id 2. Tổng thời gian = tổng delay của tất cả request. Phù hợp khi request sau phụ thuộc kết quả request trước (ví dụ: lấy user ID rồi mới lấy orders của user đó).
Cách 2: Promise.all - song song, nhanh hơn
Promise.all bắn tất cả request cùng lúc, chờ đến khi request cuối cùng hoàn tất mới trả về. Tổng thời gian = delay của request lâu nhất (không phải tổng). Phù hợp khi các request độc lập nhau.
Lưu ý quan trọng: nếu bất kỳ Promise nào reject, Promise.all sẽ reject ngay lập tức - các Promise còn lại vẫn chạy nhưng kết quả bị bỏ qua. Dùng Promise.allSettled nếu muốn lấy kết quả kể cả khi một số request thất bại.
Cách 3: forEach async - không kiểm soát được
Không có cơ chế nào để biết khi nào tất cả callback trong forEach đã hoàn tất. Không thể await forEach(...) vì forEach luôn trả về undefined.
Bảng so sánh điều kiện áp dụng
| Tình huống | Nên dùng | Lý do |
|---|---|---|
| Request sau phụ thuộc request trước | for...of |
Đảm bảo thứ tự, có thể dùng kết quả trước |
| Các request độc lập, cần nhanh | Promise.all |
Chạy song song, tổng thời gian = request chậm nhất |
| Muốn thu thập kết quả kể cả khi lỗi | Promise.allSettled |
Không throw khi một request fail |
| Chỉ cần side-effect, không cần kết quả | for...of hoặc Promise.all |
forEach async không đảm bảo gì |
| Giới hạn số request đồng thời (rate limit) | for...of theo batch |
Tránh spam server |
Chọn sai phương pháp có thể làm server quá tải hoặc kết quả sai thứ tự
Promise.allSettled là lựa chọn khi bạn gọi nhiều API và chấp nhận một số request thất bại - nó trả về mảng object với status: 'fulfilled' hoặc status: 'rejected' cho từng item:
Lưu ý: forEach ở dòng cuối trong ví dụ này hợp lệ vì callback không dùng await - chỉ đọc kết quả đã có sẵn, không có async nào cần chờ.
Bài tập thực hành: ba cách lấy dữ liệu cho danh sách ID
Dưới đây là bài tập đầy đủ để bạn tự chạy và quan sát. Copy vào Node.js (file .js) hoặc browser console.
Chạy thử cả ba hàm rồi so sánh thời gian thực tế
Điều cần quan sát khi chạy:
- Cách 1 (
for...of): tổng thời gian ≈ tổng delay của cả 5 request cộng lại. - Cách 2 (
Promise.all): tổng thời gian ≈ delay của request chậm nhất trong 5 request. - Cách 3 (
Promise.allSettled): tương tự cách 2 về thời gian, nhưng không throw khiid 99lỗi.
Nếu muốn hiểu thêm nền tảng async/await từ đầu, khóa Lập Trình JavaScript Cơ Bản trên F8 có phần bất đồng bộ khá chi tiết - miễn phí.
Lỗi thường gặp và cách tránh
Lỗi 1: dùng forEach cho async rồi await bên ngoài
await undefined = không chờ gì cả. Không báo lỗi, chỉ im lặng bỏ qua.
Lỗi 2: quên try/catch trong for...of
Bọc try/catch bên trong for nếu muốn tiếp tục khi một request thất bại:
Lỗi 3: Promise.all với danh sách lớn
Gọi 500 request cùng lúc bằng Promise.all có thể làm quá tải server hoặc bị rate limit. Giải pháp: chia batch.
Batch processing: cân bằng giữa tốc độ và tải server
Lỗi 4: nhầm map và forEach khi cần collect kết quả
forEach không trả về mảng kết quả. Nếu cần collect Promise, dùng .map() thay vì .forEach():