Vào một quán ăn đông khách kiểu tự phục vụ, bạn gọi món và được đưa một cái máy rung — cái đĩa nhựa nhấp nháy. Bạn cầm nó đi tìm bàn, làm việc riêng, và khi món xong nó rung lên báo bạn ra lấy. Cái máy rung là một future: một lời hứa về một món ăn sẽ có; bạn không phải đứng lì ở quầy nhìn đầu bếp nấu. Nhưng để ý ba điều. Món ăn vẫn cần thời gian nấu — cái máy rung không làm nó nhanh hơn. Nếu bạn cứ đứng ngay quầy chờ, bạn vẫn chờ đúng bằng ấy, cái máy rung thành vô dụng. Và nếu quán ngớ ngẩn thuê một đầu bếp mới cho mỗi món thay vì có sẵn một bếp, thì gọi mười món nhỏ còn lâu hơn tự nấu ở nhà. Future và promise là đúng cái máy rung đó — tiện, nhưng không phải phép màu.
Đến giờ mọi công cụ đồng bộ ta đo đều làm việc với trạng thái chia sẻ: khóa bảo vệ dữ liệu, biến điều kiện báo hiệu, semaphore đếm giấy phép. Future và promise là một mô hình khác, cao cấp hơn và rất được ưa chuộng: thay vì "khóa rồi đọc biến", bạn hỏi một giá trị sẽ có trong tương lai. Producer hứa (promise) sẽ giao một giá trị; consumer cầm một future và gọi get() để nhận nó — chờ nếu chưa có. Nghe rất sạch và hiện đại, đến mức dễ tin nó "tự động nhanh và rẻ". Tôi đo bằng C++ (std::promise/std::future/std::async — C không có future chuẩn), và con số cho thấy future là một tiện ích tốt cho đúng một việc, nhưng không phải phép màu.
Một giá trị, một lần, có đồng bộ
Một cặp promise–future là một đường ống một chiều, một lần, cho một giá trị. promise là đầu ghi: producer gọi set_value(x) đúng một lần. future là đầu đọc: consumer gọi get() để lấy x. Điều then chốt: get() chặn cho tới khi set_value được gọi — đứng chờ tới khi máy rung reo. Nếu consumer hỏi trước khi giá trị sẵn sàng, nó đợi; nếu hỏi sau, nó lấy ngay. Đây là một điểm đồng bộ, y như biến điều kiện — chỉ được gói lại thành một API sạch quanh "một giá trị".
std::async(f) là tầng tiện lợi phía trên: nó chạy hàm f "bất đồng bộ" và trả về một future để bạn lấy kết quả sau. Trông như "gửi việc đi làm nền, lấy kết quả khi cần". Câu hỏi tôi mang đi đo: get() chặn tốn bao nhiêu, và std::async "chạy nền" thực sự làm gì bên dưới? Tôi đo trong container gcc:13 (g++ 13, 10 lõi).
Đo (a): bàn giao một giá trị tốn một luồng
Đo độ trễ bàn giao: một luồng producer set_value, luồng consumer get(). Mỗi vòng tôi tạo một luồng producer mới để giao giá trị (đúng như std::async làm), kiểm giá trị nhận đúng:
promise.set_value -> future.get (tạo luồng SX mỗi lần) : 40,3 µs / bàn giao
so: busy-poll atomic flag (không tạo luồng) : 65 ns / bàn giao
Bàn giao một giá trị qua future khi phải tạo một luồng để sản xuất nó tốn 40,3 µs — và con số này gần đúng bằng chi phí tạo một luồng (~47 µs) ta đã đo ở bài đầu sê-ri. Nghĩa là: cái đắt không phải bản thân future, mà là luồng bạn spin lên để sản xuất giá trị (thuê một đầu bếp mới). Bản thân việc get() đồng bộ (đánh thức khi giá trị sẵn sàng) là một cond wakeup ~8,5 µs; phần còn lại là vòng đời luồng. So với busy-poll một cờ atomic (dùng luồng có sẵn, không tạo mới): 65 ns — nhanh hơn sáu trăm lần về độ trễ, nhưng đốt CPU quay tại chỗ. Future không phải là công cụ độ-trễ-thấp; nó là công cụ tiện để lấy một kết quả bất đồng bộ.
Đo (b): async cho nhiều task nhỏ là thảm họa
Đây là đo hớ lớn nhất. std::async trông như "cách hiện đại để chạy song song": viết async(f) cho mỗi việc, thu kết quả qua future. Nhưng mặc định (launch::async), mỗi lần gọi tạo một luồng mới. Tôi chạy 20.000 task nhỏ (mỗi task ~vài chục nano giây tính toán) bằng std::async và so với chạy tuần tự:
std::async 20.000 task nhỏ : 422 ms (21 µs/task) [thread-per-task]
tuần tự (không luồng) : 0,2 ms
async / tuần tự : 1724× CHẬM HƠN
std::async chậm hơn chạy tuần tự 1724 lần — không phải nhanh hơn, mà chậm hơn ba bậc độ lớn. Vì mỗi trong 20.000 task tạo và dẹp một luồng riêng (~21 µs mỗi cái), trong khi việc thật chỉ vài ns. Đây chính xác là cạm bẫy thread-per-task ở bài thread pool: một chi phí per-item "nhỏ" (tạo luồng) nhân với số item lớn thành thảm họa. std::async với launch::async là thread-per-task đội lốt một API đẹp. Với hàng vạn task nhỏ, nó là lựa chọn tệ nhất có thể — thua cả việc không song song hóa gì cả.
(Chú ý: std::async cũng có launch::deferred — chạy lười, ngay trên luồng gọi khi get(); và một số cài đặt có thể dùng pool. Nhưng mặc định launch::async | launch::deferred để trình biên dịch chọn, còn launch::async tường minh thì luôn tạo luồng — và đó là điều nhiều người dùng mà không biết cái giá.)
Đo (c): get() chặn, ngủ chờ chứ không quay
Để xác nhận get() là điểm đồng bộ thật (chặn tới khi sẵn sàng), tôi cho producer set_value sau 50 ms, và đo consumer get() chờ bao lâu:
promise set sau 50 ms -> future.get() chờ 57,0 ms rồi trả về 123
get() chờ đúng ~50 ms (cộng chút phí lập lịch) rồi trả về giá trị đúng — nó ngủ chờ, không quay bận đốt CPU. Đây là hành vi đúng và mong muốn: consumer nhường CPU trong khi chờ kết quả. Nhưng nó khẳng định điểm cốt lõi: get() là một chỗ chặn, không phải một phép đọc tức thì. Nếu bạn gọi get() khi kết quả chưa sẵn, luồng của bạn dừng ở đó — đứng lì ở quầy chờ máy rung.
(Môi trường đo: container gcc:13 (g++ 13) trên host ARM AArch64, 10 lõi. Con số tuyệt đối đổi theo máy và cài đặt thư viện chuẩn; điều bền vững là hình dạng: get() là điểm chặn ngủ-chờ, và std::async mặc-định-tạo-luồng biến hàng vạn task nhỏ thành thread-per-task.)
Hai niềm tin đối nghịch, cùng sai
Tôi vào đo với hai nửa của một niềm tin phổ biến: "future và async là cách hiện đại, tự động nhanh và rẻ để làm việc bất đồng bộ". Đo lật cả hai. Nửa "nhanh": std::async cho nhiều task nhỏ chậm hơn tuần tự 1724 lần vì nó là thread-per-task. Nửa "rẻ/tức thì": future.get() là một điểm đồng bộ chặn — nó ngủ chờ tới khi promise set, không phải một phép đọc miễn phí.
Sự thật, và là bài học đo lường: future/promise là một tiện ích gói gọn mẫu "chờ một kết quả bất đồng bộ" thành một API sạch — nhưng nó không xóa bỏ chi phí bên dưới (một luồng để sản xuất, một lần đồng bộ để nhận), chỉ giấu chúng đi. Và cái giấu đi đó nguy hiểm: async(f) trông rẻ đến mức người ta rắc nó lên hàng vạn task nhỏ, không nhận ra mỗi cái là một luồng. Đúng tinh thần cả sê-ri: một API đẹp không đổi được vật lý — tạo luồng vẫn ~vài chục µs, đồng bộ vẫn tốn một lần đánh thức. Future là đúng công cụ cho một kết quả bất đồng bộ (một truy vấn mạng, một tính toán nền dài); nó là sai công cụ cho hàng vạn task nhỏ (dùng thread pool) hay cho một luồng giá trị liên tục (dùng hàng đợi/channel).
Về đến code hằng ngày
Điều đầu tiên: dùng future/promise cho một kết quả bất đồng bộ, không phải cho hàng loạt task nhỏ. Khi bạn cần khởi động một việc dài (gọi API, đọc file lớn, một tính toán nền) và lấy kết quả sau, future là công cụ sạch và đúng. Nhưng nếu bạn có nhiều việc, đừng gọi std::async trong vòng lặp — mỗi cái tạo một luồng.
Điều thứ hai: nhớ get() là điểm chặn — đừng gọi nó quá sớm. Nếu bạn get() ngay sau khi tạo future, bạn chặn cho tới khi kết quả có, mất hết tính bất đồng bộ. Hãy làm việc khác trong lúc chờ, chỉ get() khi thực sự cần giá trị; hoặc dùng wait_for để kiểm không chặn.
Chỗ này cái khác biệt giữa các ngôn ngữ đáng nói, vì nó quyết định "một future cho mỗi task" là ngu ngốc hay hoàn toàn bình thường. Trong Java, CompletableFuture.supplyAsync(fn) mặc định chạy trên ForkJoinPool.commonPool() — không tạo luồng mới mỗi lần, và bạn còn chỉ định được pool riêng bằng supplyAsync(fn, myExecutor); đó chính là liều thuốc cho cái bẫy thread-per-task ở trên: gắn future vào một thread pool thay vì một luồng dùng-một-lần. Ngược lại, cú async(f).get() ngay lập tức trong Java (Future.get() từ ExecutorService.submit) cũng chặn y hệt C++. Go thì né bài toán theo gốc rễ: nó không có future chuẩn, mà dùng goroutine + channel — ch := make(chan int); go func(){ ch <- f() }(); v := <-ch — và vì goroutine rẻ (~vài KB, do runtime lập lịch chứ không phải luồng OS), "một goroutine cho mỗi task" thật sự ổn tới hàng trăm nghìn, khác hẳn "một luồng OS cho mỗi task". JavaScript thì Promise là một-giá-trị-một-lần y như đây, nhưng chạy trên vòng lặp sự kiện đơn luồng nên không có chi phí luồng — đổi lại không có song song thật. Mẫu số chung: future/promise là khái niệm chung "chờ một kết quả", nhưng cái giá của nó nằm ở cơ chế chạy bên dưới — luồng OS đắt (C++ launch::async), pool chia sẻ (Java), hay coroutine rẻ (Go) — và bạn phải biết mình đang trả giá nào.
Muốn tự soi thì nhìn một chỗ trong code bạn (hoặc dự định) dùng future/async và hỏi: tôi đang khởi động bao nhiêu việc? Nếu là một (một truy vấn, một tính toán nền), future là đúng — cứ dùng. Nếu bạn thấy async(...) trong một vòng lặp chạy nhiều lần, dừng lại: mỗi lần gọi có thể là một luồng mới, và hàng nghìn lần là hàng nghìn luồng — hãy đổi sang một thread pool (hoặc, trong Go, cứ goroutine vì nó rẻ). Rồi hỏi tiếp: tôi gọi get() ngay hay để dành? Nếu gọi ngay sau khi tạo, bạn không hề bất đồng bộ — bạn chỉ chặn với thêm một bước. Vài phút phân loại "một việc hay nhiều việc" và "get() sớm hay muộn" cho bạn biết future có phải công cụ đúng không — và nhắc rằng một API sạch như async(f).get() vẫn phải trả cái giá vật lý của một luồng và một lần đồng bộ; nó chỉ giấu chúng đi, không làm chúng biến mất.