Async: Từ Lời Hứa Đến Thực Tại và Cái Giá Phải Trả Trong Lập Trình
Bài viết phân tích sự tiến hóa của lập trình bất đồng bộ qua các làn sóng: Callbacks, Promises và Async/Await. Mặc dù giải quyết được vấn đề hiệu năng như C10K, các mô hình này cũng mang lại những hệ quả phức tạp về kiến trúc, đặc biệt là vấn đề "function coloring" và sự phân mảnh hệ sinh thái.

Trong lịch sử phát triển phần mềm, mỗi làn sóng công nghệ mới thường giải quyết vấn đề lớn nhất của làn sóng trước đó, nhưng đồng thời cũng mang đến những thách thức mới. Lập trình bất đồng bộ (async) là một ví dụ điển hình cho quy luật này.
Vấn đề C10K và sự ra đời của Callbacks
Luồng của hệ điều hành (OS threads) rất đắt đỏ. Một luồng thường chiếm dụng khoảng 1MB bộ nhớ stack và mất khoảng 1 mili-giây để khởi tạo. Việc chuyển đổi ngữ cảnh (context switch) diễn ra trong không gian kernel và tiêu tốn chu kỳ CPU. Một máy chủ xử lý hàng nghìn kết nối đồng thời bằng cách cấp phát một luồng cho mỗi kết nối sẽ tạo ra gánh nặng khổng lồ về bộ nhớ và tranh giành tài nguyên lập lịch.
Đây là vấn đề C10K, được đặt tên bởi Dan Kegel vào năm 1999. Câu trả lời cho vấn đề này đến theo từng làn sóng.
Làn sóng đầu tiên là Callbacks. Thay vì chờ một thao tác I/O hoàn thành, chúng ta đăng ký một hàm để được gọi khi nó xong và chuyển sang làm việc khác. Node.js đã xây dựng cả một hệ sinh thái dựa trên mô hình này, xử lý hàng nghìn kết nối trên một luồng duy nhất.
Mô hình này giải quyết tốt vấn đề hiệu năng, nhưng đánh đổi bằng trải nghiệm lập trình. Callbacks đảo ngược luồng điều khiển, khiến mã nguồn trở nên lồng nhau phức tạp — hay còn gọi là "callback hell". Xử lý lỗi cũng bị phân mảnh và không thể lan truyền tự nhiên lên call stack.
Sự trỗi dậy của Promises và Futures
Làn sóng tiếp theo đưa ra một khái niệm: thay vì truyền một callback để gọi sau này, thao tác bất đồng bộ sẽ trả về ngay một đối tượng đại diện cho kết quả tương lai. Đây là Promise (trong JavaScript) hoặc Future (trong Java, Rust).
Promises mang lại tính tiện dụng (ergonomics) tốt hơn. Chúng có thể kết hợp chuỗi (composable) và xử lý lỗi tập trung hơn thông qua .catch(). Tuy nhiên, Promises cũng có những hạn chế riêng:
- Một lần sử dụng: Một Promise chỉ giải quyết (resolve) đúng một lần, khiến chúng không phù hợp để mô hình hóa các luồng dữ liệu liên tục.
- Lỗi im lặng: Nếu một Promise bị từ chối (reject) mà không có trình xử lý
.catch(), lỗi có thể bị nuốt chửng mà không có cảnh báo. - Phân chia kiểu dữ liệu: Mọi hàm giờ đây hoặc trả về giá trị, hoặc trả về Promise của giá trị, tạo ra sự phân chia trong thiết kế API.
Async/Await: Cú pháp đường phố nhưng chi phí ẩn
Để làm cho mã Promise trông giống mã tuần tự hơn, Async/Await ra đời, được tiên phong bởi C# và sau đó là JavaScript, Python, Rust. Nó cho phép viết mã bất đồng bộ trông như mã đồng bộ, sử dụng try/catch để bắt lỗi và các vòng lặp thông thường.
Ngành công nghiệp đã áp dụng nó rất nhanh. Tuy nhiên, cái giá phải trả là vấn đề "Function Coloring" (Màu sắc của hàm).
Bob Nystrom đã mô tả vấn đề này bằng một phép ẩn dụ: Trong một ngôn ngữ lập trình, mỗi hàm có thể là "đỏ" (async) hoặc "xanh" (sync). Hàm đỏ có thể gọi hàm xanh, nhưng hàm xanh không thể gọi hàm đỏ nếu không thực hiện các nghi thức đặc biệt. Nếu bạn gọi một hàm đỏ từ một hàm xanh, hàm xanh đó buộc phải trở thành đỏ, và sự thay đổi này lan truyền virus-like qua toàn bộ cơ sở mã.
- Ở cấp độ hàm: Thêm một cuộc gọi I/O vào một hàm đồng bộ buộc bạn phải thay đổi chữ ký của nó và cập nhật mọi nơi gọi nó.
- Ở cấp độ thư viện: Các tác giả phải chọn viết thư viện sync (loại trừ người dùng async) hoặc async (ép buộc người dùng sync thêm runtime dependency). Nhiều người chọn cách duy trì cả hai, làm tăng gấp đôi gánh nặng bảo trì.
- Ở cấp độ hệ sinh thái: Trong Rust, hệ sinh thái async bị phân mảnh giữa các runtime cạnh tranh như Tokio và async-std. Một thư viện viết cho Tokio khó có thể dùng được với async-std.
Cái bẫy của tính tuần tự
Một chi phí tinh tế hơn là Async/Await biến việc che giấu khả năng song song hóa trở thành một cái bẫy nhận thức.
Ví dụ, đoạn mã sau trông rất sạch sẽ và tuần tự:
const orders = await getOrders(user.id);
const recommendations = await getRecommendations(user.id);
Tuy nhiên, nó thực hiện tuần tự: getRecommendations không bắt đầu cho đến khi getOrders hoàn thành, mặc dù hai thao tác này độc lập nhau. Để chạy song song, lập trình viên phải phá vỡ cấu trúc tuần tự bằng các mẫu kết hợp (combinator patterns) như Promise.all, làm giảm đi sự tiện dụng mà cú pháp async/await mang lại.
Kết luận: Những bài học đã học
Mỗi giải pháp đã giải quyết một vấn đề nhưng lại đưa ra những chi phí cấu trúc mới. Callbacks giải quyết sự cạn kiệt tài nguyên nhưng tạo ra mã khó đọc. Promises cải thiện cú pháp nhưng giới hạn tính năng. Async/Await mang lại trải nghiệm tốt nhất cho các chuỗi thao tác tuyến tính nhưng lại gây ra "function coloring" và phân mảnh hệ sinh thái.
Một số ngôn ngữ đã rút ra bài học và chọn con đường khác. Go chấp nhận runtime nặng hơn để sử dụng Goroutines, tránh hoàn toàn việc phân màu hàm. Java 21 với Virtual Threads cũng đặt cược vào luồng nhẹ, trông và hoạt động giống luồng thường, giúp mã không cần thay đổi màu sắc.
Lịch sử của async là minh chứng rõ ràng cho việc các cách tiếp cận chỉ tập trung vào câu hỏi "làm thế nào để quản lý thực thi đồng thời?" thường xuyên tạo ra vấn đề mới ở mọi cấp độ trừu tượng. Lập trình viên hiện nay có trải nghiệm viết hàm async tốt hơn bao giờ hết, nhưng những người duy trì cơ sở mã lớn lại phải gánh chịu những gánh nặng cấu trúc mà các abstraction này mang lại.
Bài viết liên quan

Công nghệ
Framework trình làng Laptop 13 Pro mới với chip Intel Core Ultra Series 3 và dock gắn card đồ họa rời
22 tháng 4, 2026

Công nghệ
Sai lầm 8 triệu USD của Uber: Bài học đắt giá về thiết kế hệ thống và động lực sai lệch
22 tháng 4, 2026

Công nghệ
Khám phá bộ dữ liệu GM-SEUS v2: Phân tích 3,4 triệu tấm pin mặt trời tại Mỹ
22 tháng 4, 2026
