Đây là bài mở của một sê-ri mới về đồng thời và song song — và như mọi thứ trong loạt bài này, ta bắt đầu bằng một phép đo. Câu hỏi đầu tiên và nền tảng nhất: tạo ra một "đơn vị thực thi" mới tốn bao nhiêu? Bạn có hai lựa chọn — một luồng (thread) hay một tiến trình (process) — và trực giác về giá của chúng thường sai lệch cả hai chiều. Tôi đo cả hai trong container để lấy con số thật.
Luồng và tiến trình khác nhau ở đâu
Một luồng chạy bên trong một tiến trình và chia sẻ không gian địa chỉ với các luồng anh em: cùng một vùng nhớ, cùng biến toàn cục, cùng file mở. Tạo một luồng vì thế không cần dựng một không gian địa chỉ mới — nhưng vẫn phải cấp một stack riêng, ghi danh nó với nhân, và để bộ lập lịch xếp nó vào chạy. Một tiến trình tạo bằng fork thì có không gian địa chỉ riêng, một bản sao độc lập của tiến trình mẹ. Nghe có vẻ đắt khủng khiếp — phải chép cả bộ nhớ? Không: nhờ copy-on-write (COW), fork không chép nội dung bộ nhớ, nó chỉ chép bảng trang và đánh dấu các trang là "chép khi ghi". Chỉ khi một trong hai tiến trình ghi vào một trang thì trang đó mới thật sự được nhân đôi. Vậy giá thật của mỗi cách là bao nhiêu?
Đo: 47µs cho luồng, 82µs cho tiến trình
Tôi đo trong container gcc:13 (ARM AArch64), biên dịch -O2 -pthread, chạy 2000 lần mỗi phép, kiểm mã trả về, lấy cả giá trị nhỏ nhất (sàn, ít nhiễu) và trung vị (điển hình) vì nhiễu lịch của máy ảo khá lớn.
pthread create+join (luồng rỗng) : min ~28 µs | trung vị ~47 µs
fork + waitpid (tiến trình rỗng): min ~70 µs | trung vị ~82 µs
tỉ lệ (trung vị) fork / thread : ~1,8 lần
Luồng: tạo và join một luồng không làm gì cả mất khoảng 47 µs (trung vị). Tiến trình: fork một con rồi đợi nó thoát ngay mất khoảng 82 µs. Fork chỉ đắt hơn thread khoảng 1,8 lần — không hề là "gấp trăm lần" như đồn. Khoảng cách giữa min và trung vị (28 so 47, 70 so 82) chính là nhiễu lịch của máy ảo; tôi lấy min làm sàn cho "giá thuần" và trung vị cho "giá thực tế khi chạy chung với tải khác".
Một lần tôi đo hớ: cả hai niềm tin đều sai
Tôi vào đo với hai niềm tin ngược nhau mà cùng sai. Niềm tin thứ nhất, phổ biến ở người dùng ngôn ngữ có luồng nhẹ: "tạo luồng gần như miễn phí, cứ tạo thoải mái". Đo ra 47 µs mỗi luồng — không hề miễn phí. Đó là hàng chục nghìn nano giây, tức hàng chục nghìn phép cộng số nguyên bị bỏ lỡ cho mỗi luồng bạn tạo. Nếu bạn tạo một luồng cho mỗi request nhỏ và có hàng nghìn request mỗi giây, chi phí tạo luồng thuần đã ngốn một phần đáng kể CPU trước khi làm được việc gì.
Niềm tin thứ hai, ngược lại: "fork một tiến trình đắt kinh khủng, gấp trăm lần một thread, vì phải nhân đôi cả bộ nhớ". Đo ra fork chỉ 1,8 lần một thread. Lý do là copy-on-write: fork không nhân đôi bộ nhớ, chỉ nhân đôi bảng trang và đánh dấu COW — một thao tác tỉ lệ với kích thước bảng trang, không phải kích thước bộ nhớ. Với một tiến trình nhỏ, bảng trang nhỏ, nên fork rẻ hơn nhiều so với hình dung "chép hết".
Bài học đo lường mở màn sê-ri: trực giác về chi phí đồng thời hay lệch cả hai hướng — cái ta tưởng rẻ thì không, cái ta tưởng đắt thì không. Chỉ có đồng hồ nói thật. Và con số quan trọng nhất rút ra: cả luồng lẫn tiến trình đều tốn hàng chục micro giây để tạo — đủ đắt để việc "tạo một đơn vị thực thi cho mỗi việc nhỏ" trở thành lãng phí lớn.
Đo cái này cho đúng không dễ
Đo chi phí tạo một luồng khó hơn vẻ ngoài, và nó thừa hưởng đúng kỷ luật của đo lường vi mô đúng cách và đo tác động một cờ đúng cách. Phải làm nóng trước (lần tạo luồng đầu tiên đắt bất thường vì nhân phải cấp phát cấu trúc lần đầu). Phải lặp nhiều lần vì một phép đo đơn lẻ bị nhiễu lịch của máy ảo nuốt chửng — ở đây min và trung vị lệch nhau tới ~1,7 lần, cho thấy nhiễu lớn cỡ nào. Phải kiểm mã trả về của pthread_create và fork: nếu chúng thất bại (hết tài nguyên) mà bạn không kiểm, bạn đang đo một thao tác không xảy ra — đúng kiểu "công cụ nói dối" mà sê-ri luôn cảnh giác. Và phải đo create+join cùng nhau, vì tạo một luồng rồi không dọn nó là rò rỉ; con số có ý nghĩa là chi phí trọn vòng đời của một đơn vị thực thi dùng một lần. Bốn kỷ luật đó biến một con số nhiễu vô nghĩa thành ~47 µs đáng tin.
Vì sao điều này quan trọng khi lập trình
Hệ quả đầu tiên, và là lý do tồn tại của một mẫu thiết kế cả ngành dùng: dùng thread pool. Vì tạo một luồng tốn ~47 µs, tạo hàng nghìn luồng cho hàng nghìn việc nhỏ là ném đi ~47 µs × N chỉ cho khâu khởi tạo. Thread pool tạo một số luồng một lần rồi tái dùng chúng cho hết việc này tới việc khác — trả chi phí tạo đúng một lần thay vì mỗi việc. Mọi web server, mọi runtime song song đều làm vậy, và giờ bạn thấy con số đằng sau lý do.
Hệ quả thứ hai: fork không đáng sợ như tiếng của nó. Với các mô hình đa tiến trình (như một số web server, hay fork để chạy tiến trình con), giá fork nhờ COW là chấp nhận được — chỉ 1,8 lần một thread. Cái đắt của tiến trình không nằm ở tạo, mà ở giao tiếp (không chia sẻ bộ nhớ nên phải qua pipe/socket) và bộ nhớ thường trú — những thứ ta sẽ đo ở các phần sau.
Hệ quả thứ ba là tinh thần của cả sê-ri mới: đo chi phí đồng thời, đừng đoán. Con số mang theo: tạo+join một luồng rỗng mất ~47 µs (trung vị), fork+waitpid một tiến trình rỗng mất ~82 µs — fork chỉ ~1,8 lần thread nhờ copy-on-write (không chép cả bộ nhớ), và cả hai đều tốn hàng chục micro giây nên KHÔNG cái nào "miễn phí"; đó là lý do có thread pool. Trước khi tin một mô hình đồng thời rẻ hay đắt, hãy đo — vì trực giác ở đây đặc biệt hay sai.
Thử ba mươi giây
Viết một vòng tạo và join 100.000 luồng rỗng, bọc clock_gettime quanh nó, chia cho 100.000 — bạn sẽ thấy mỗi luồng tốn hàng chục micro giây, không phải "gần như 0". Rồi đổi thành fork+waitpid một tiến trình con _exit(0) và đo lại — nó sẽ đắt hơn, nhưng chỉ một vài lần, không phải hàng trăm lần. Ba mươi giây (well, vài giây chạy) đó điều chỉnh lại trực giác của bạn về giá của đồng thời: tạo đơn vị thực thi luôn có giá, và fork rẻ hơn bạn tưởng nhờ copy-on-write. Từ con số đó, mọi quyết định "bao nhiêu luồng, tái dùng hay tạo mới" mới có nền để đứng.