Hình dung công ty cần thêm người làm. Có hai cách. Thêm một nhân viên mới vào văn phòng đang có (một luồng): họ dùng chung tòa nhà, tủ hồ sơ, máy pha cà phê với mọi người (chung không gian địa chỉ) — bạn chỉ cần kê cho họ một cái bàn (stack) và đăng ký với phòng nhân sự (nhân). Hoặc mở một chi nhánh mới (một tiến trình, bằng fork): nghe như phải photo toàn bộ tủ hồ sơ sang chi nhánh — tốn kinh khủng? Không. Bạn chỉ photo cái mục lục (bảng trang) và ghi chú "ngăn nào có người viết vào thì mới thật sự nhân đôi ngăn đó" (copy-on-write); một ngăn hồ sơ chỉ bị chép thật khi ai đó sửa nó. Nên mở chi nhánh không phải gấp trăm lần tuyển một nhân viên — chỉ khoảng gấp đôi. Tôi đo, và cả hai trực giác quen thuộc đều lệch.
Đâ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ở (cùng tòa nhà, cùng tủ hồ sơ). 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 (cái bàn), 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" (photo mục lục, ghi chú chép-khi-viết). 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ôi trường đo: container gcc:13 trên host ARM AArch64, -O2 -pthread, 2000 lần mỗi phép, có warmup và kiểm mã trả về. Con số tuyệt đối đổi theo máy; điều bền vững là hình dạng: cả hai tốn hàng chục µs, và fork chỉ nhỉnh hơn thread một chút nhờ COW — không gấp trăm lần.)
Hai niềm tin đối nghịch, cùng 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ớ (photo mục lục, không photo hồ sơ). 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".
Hai niềm tin gặp nhau ở một bài học 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ề đến code hằng ngày
Điều đầ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.
Điều 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, 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ú. Chính COW-fork còn là một kỹ thuật production tinh: nginx và PostgreSQL fork các tiến trình con để phục vụ; Redis BGSAVE fork một tiến trình con rồi để COW cho nó một ảnh chụp bộ nhớ nhất quán mà không khóa tiến trình chính — chỉ những trang bị ghi trong lúc lưu mới bị nhân đôi.
Chỗ này hướng đi của các ngôn ngữ hiện đại chính là "làm cho đơn vị thực thi thật sự rẻ" để thoát cái giá 47 µs. Java new Thread() ánh xạ một luồng OS (đúng cái giá này), nên chuẩn mực là ExecutorService/thread pool; nhưng Java 21 virtual thread (Loom) nhẹ cỡ ~1 KB và tạo trong vài trăm nano giây vì nó không tạo luồng OS — như một nhân viên dùng chung cái bàn có sẵn theo ca, thay vì kê bàn mới. Go đi hướng đó từ đầu: goroutine ~2-8 KB, do runtime tạo chứ không phải nhân, nên spawn hàng triệu con vẫn ổn — khác hẳn "một luồng OS cho mỗi việc". Mẫu số chung: luồng/tiến trình OS tốn hàng chục µs để tạo, nên hoặc tái dùng (thread pool, fork sẵn worker) hoặc dùng đơn vị nhẹ hơn OS (goroutine, virtual thread) — cả hai đều là cách né đúng con số tôi vừa đo.
Muốn tự thấy thì 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. 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.