Khi cần làm nhiều việc cùng lúc, lập trình viên chọn giữa tiến trình (process, tạo bằng fork) và luồng (thread, tạo bằng pthread_create). Câu thần chú quen thuộc là "luồng nhẹ, tiến trình nặng" — nhưng nhẹ hơn bao nhiêu, và một luồng thật sự tốn bao nhiêu bộ nhớ? Bài này đo cả hai con số, và bắt đúng cái bẫy khiến người ta hoảng khi nhìn một chương trình nhiều luồng qua công cụ giám sát.

Chi phí tạo luồng

Luồng và tiến trình khác nhau ở đâu

Một tiến trình có không gian địa chỉ riêng: bộ nhớ của nó tách biệt với mọi tiến trình khác. Tạo một tiến trình mới bằng fork phải dựng lại bảng trang cho bản sao (đúng cái đã đo ở bài fork) — một chi phí tỉ lệ với bộ nhớ.

Một luồng thì ngược lại: các luồng trong cùng một tiến trình chia sẻ chung một không gian địa chỉ — cùng heap, cùng biến toàn cục, cùng file mở. Tạo một luồng không cần sao chép bảng trang; nhân chỉ cần cấp cho luồng mới một ngăn xếp (stack) riêng và một khối điều khiển nhỏ. Vì thế luồng được coi là "nhẹ". Câu hỏi là: nhẹ tới mức nào, và cái ngăn xếp riêng đó tốn bao nhiêu?

Đo: tạo nhanh hơn ~2 lần, ngăn xếp 8 MB "ảo"

Tôi viết một chương trình C đo hai thứ. Thứ nhất, chi phí tạo (trung vị 1000 lần):

pthread create + join : 42,6 µs
fork + exit + wait    : 95,8 µs

Tạo một luồng (rồi chờ nó xong) tốn ~43 µs; tạo một tiến trình con (rồi chờ) tốn ~96 µs. Luồng nhanh hơn, nhưng chỉ khoảng 2,2 lần — không phải "cả trăm lần" như câu thần chú gợi ý. Lý do: fork hiện đại cũng rẻ nhờ copy-on-write, nên khoảng cách hẹp lại nhiều so với thời xưa.

Thứ hai, bộ nhớ mỗi luồng. Tôi tạo N luồng cùng ngủ, rồi đọc /proc/self/status để lấy VmSize (bộ nhớ ảo) và VmRSS (bộ nhớ vật lý thật):

Số luồng VmSize (ảo) VmRSS (thật) Mỗi luồng
100 +806 MB +1 MB 8256 KB ảo / ~11 KB thật
500 +4031 MB +4 MB 8256 KB ảo / ~9 KB thật
1000 +8062 MB +8 MB 8256 KB ảo / ~8,5 KB thật

Hai cột kể hai câu chuyện trái ngược. VmSize tăng ~8 MB mỗi luồng — đúng bằng kích thước ngăn xếp mặc định (8 MB, do ulimit -s). Nhưng VmRSS — bộ nhớ vật lý thật — chỉ tăng ~8,5 KB mỗi luồng. Chênh nhau gần một nghìn lần.

Một lần tôi đo hớ: VmSize +8 GB không phải 8 GB RAM

Khi tạo 1000 luồng, VmSize của chương trình vọt lên +8 GB. Phản xạ đầu tiên của tôi là hoảng: "1000 luồng ăn 8 GB RAM — luồng đắt kinh khủng, ai bảo luồng nhẹ!". May là tôi liếc sang cột VmRSS cùng lúc: nó chỉ +8 MB. Hai con số lệch nhau tới mức không thể cùng đúng theo cách tôi đang hiểu.

Sự thật: 8 MB mỗi luồng là địa chỉ ảo đặt trước cho ngăn xếp, không phải RAM. Khi tạo một luồng, nhân dành riêng 8 MB không gian địa chỉ cho ngăn xếp của nó (để nếu luồng đệ quy sâu hay cấp biến cục bộ lớn thì có chỗ), nhưng đó chỉ là một lời hứa trên bản đồ địa chỉ — nhân không cấp trang vật lý nào cho tới khi luồng thật sự chạm tới ngăn xếp. Luồng đang ngủ chạm rất ít (vài trang đầu), nên RAM thật chỉ ~8,5 KB. VmSize đếm địa chỉ ảo đã dành; VmRSS đếm RAM thật đang dùng; với ngăn xếp luồng, hai thứ chênh nhau cả nghìn lần.

Bài học đo lường lặp lại từ bài copy-on-write: hai con số bộ nhớ mâu thuẫn nghĩa là bạn đang đọc nhầm cột, không phải phát hiện điều lạ. VmSize cao ngất không có nghĩa chương trình ngốn RAM; nó có nghĩa chương trình đặt chỗ nhiều địa chỉ ảo — điều gần như miễn phí. Và tôi còn đo hớ một chuyện thứ hai, nhẹ hơn: tôi tưởng luồng nhanh hơn tiến trình cả trăm lần, đo ra chỉ ~2 lần. Cả hai con số "đắt" mà folklore gán cho — 8 MB mỗi luồng, và tiến trình chậm gấp trăm lần — đều là huyền thoại; sự thật khiêm tốn hơn nhiều: ~9 KB RAM thật mỗi luồng, và tạo tiến trình chỉ chậm gấp đôi.

Vì sao điều này quan trọng khi lập trình

Hệ quả đầu tiên là đừng sợ số luồng qua con số VmSize, nhưng hãy để ý địa chỉ ảo trên hệ 32-bit và giới hạn khác. Trên hệ 64-bit, đặt trước 8 GB địa chỉ ảo cho 1000 luồng chẳng sao — không gian địa chỉ gần như vô tận và RAM thật mới là thứ đáng lo (~9 KB/luồng, rất rẻ). Nhưng cái ngăn xếp ảo 8 MB đó thể thành vấn đề theo cách khác: nếu bạn cần rất nhiều luồng (hàng chục nghìn), tổng địa chỉ ảo và số lượng ánh xạ có thể chạm trần hệ thống (vm.max_map_count, ulimit -v), và mỗi luồng dù chỉ dùng vài KB vẫn là một thực thể lịch biểu mà nhân phải quản. Muốn hàng vạn luồng, hãy giảm kích thước ngăn xếp (pthread_attr_setstacksize xuống 256 KB–1 MB) — cắt phần đặt-trước thừa.

Hệ quả thứ hai là chọn luồng hay tiến trình theo cô lập, không theo tốc độ tạo. Vì chênh lệch tạo chỉ ~2 lần, tốc độ hiếm khi là lý do quyết định — trừ khi bạn tạo/hủy hàng vạn lần mỗi giây (khi đó dùng bể luồng để không tạo lại). Lý do thật để chọn giữa chúng là chia sẻ và cô lập: luồng chia sẻ mọi thứ (nhanh để trao đổi dữ liệu, nhưng một luồng lỗi có thể kéo sập cả tiến trình, và phải khóa cẩn thận); tiến trình cô lập hoàn toàn (an toàn hơn, một tiến trình chết không ảnh hưởng cái khác, nhưng trao đổi dữ liệu tốn công hơn). Đây mới là trục quyết định, không phải 43 so với 96 µs.

Hệ quả thứ ba là hiểu đúng bộ nhớ của ứng dụng nhiều luồng khi giám sát. Một máy chủ 500 luồng nhìn qua top có thể hiện VIRT hàng GB — đừng báo động. Nhìn RES (RSS) để biết RAM thật, và nhớ rằng ngay cả RSS cũng đếm chung heap mà mọi luồng chia sẻ (khác với ngăn xếp riêng). Con số mang theo: tạo một luồng (~43 µs) nhanh hơn tạo một tiến trình (~96 µs) chỉ ~2 lần, không phải 100 lần; và mỗi luồng đặt trước 8 MB ĐỊA CHỈ ẢO cho ngăn xếp nhưng chỉ dùng ~9 KB RAM thật — VmSize không phải RAM. Luồng nhẹ thật, nhưng nhẹ ở RAM (~9 KB), không phải ở con số VmSize dọa người.

Thử ba mươi giây

Chạy một chương trình nhiều luồng bạn có sẵn (một máy chủ web, một trình duyệt), tìm PID của nó, rồi so hai cột: grep -E 'VmSize|VmRSS' /proc/<pid>/status. Bạn gần như chắc chắn sẽ thấy VmSize lớn hơn VmRSS rất nhiều — phần lớn khoảng cách đó là các ngăn xếp luồng được đặt trước mà chưa dùng tới. Rồi đếm số luồng bằng ls /proc/<pid>/task | wc -l và chia thử VmSize cho số đó: nếu ra khoảng 8 MB mỗi luồng, bạn vừa nhìn thấy đúng cái ngăn xếp ảo 8 MB mà bài này đo — một lời hứa địa chỉ, không phải RAM. RAM thật, như mọi khi, nhỏ hơn nhiều.