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

Future và promise: bàn giao một giá trị

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. 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ị. 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 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 ở đó.

Một lần tôi đo hớ: future tiện, nhưng không phải phép màu

Tôi vào đo với 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 phá tan hai nửa của niềm tin đó. 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ì sao điều này quan trọng khi lập trình

Hệ quả đầ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. Dùng một thread pool và nộp task vào đó (nhiều thư viện future hiện đại cho phép gắn future với một pool).

Hệ quả 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. Future chỉ hữu ích khi bạn hoãn việc get() đủ lâu để việc nền chạy song song với việc chính.

Hệ quả thứ ba là tinh thần đo lường: API đẹp giấu chi phí, không xóa nó — đo cái thật bên dưới. Con số mang theo: future.get() là ĐIỂM ĐỒNG BỘ (chặn, ngủ chờ tới khi promise set — value tới sau 50ms thì get chờ 57ms); std::async mặc định TẠO MỘT LUỒNG MỚI mỗi task, nên 20.000 task nhỏ bằng async mất 422ms (21µs/task) — CHẬM hơn tuần tự 1724 lần, đúng thread-per-task. Future tiện cho MỘT kết quả bất đồng bộ (bàn giao 1 giá trị ~cỡ tạo luồng nếu tự spawn), nhưng KHÔNG thay thread pool cho nhiều task nhỏ, không thay hàng đợi cho nhiều giá trị — API đẹp không đổi được vật lý.

Thử ba mươi giây

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. 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. Ba mươi giây 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.