Ở bài luồng và tiến trình ta thoáng thấy mỗi luồng có một ngăn xếp (stack) riêng 8MB. Con số đó dễ gây hoang mang: nghìn luồng thì 8GB ngăn xếp sao? Và điều gì xảy ra khi một hàm đệ quy quá sâu, đè hết cái stack đó? Tôi đo trong container — cả kích thước thật của ngăn xếp lẫn cái gì bắt được lúc nó tràn — và vấp một con số bất khả do chính trình biên dịch dựng lên.
Ngăn xếp 8MB là ảo, không phải RAM
Mỗi luồng cần một ngăn xếp riêng để lưu biến cục bộ, tham số, và địa chỉ trả về của các lời gọi hàm lồng nhau. Trên Linux, glibc mặc định cấp cho mỗi ngăn xếp luồng 8MB. Nhưng — đây là điểm mấu chốt — 8MB đó là không gian địa chỉ ảo đặt trước, không phải RAM thật cấp ngay. Nhân chỉ biến một trang thành RAM thật khi luồng thật sự chạm tới nó, đúng cơ chế cấp lười của bộ nhớ ảo. Tôi đo bằng cách tạo 200 luồng và so VmSize (không gian ảo) với VmRSS (RAM thật) trong /proc/self/status:
200 luồng, mỗi luồng một ngăn xếp 8MB:
VmSize: tăng 1612 MB (~8,06 MB mỗi luồng)
VmRSS : tăng 1,6 MB (~8 KB mỗi luồng)
Con số này đúng là một bài kiểm tra thần kinh. VmSize nhảy vọt 1612 MB — mỗi luồng đặt trước trọn 8MB ảo. Nhưng VmRSS, tức RAM thật đang dùng, chỉ tăng 1,6 MB — khoảng 8 KB mỗi luồng, chỉ đúng vài trang mà mỗi luồng thật sự chạm để chạy hàm khởi động. Một luồng vừa tạo, chưa đệ quy sâu, chưa cấp mảng lớn trên stack, thì gần như không tốn RAM dù "sổ sách" ghi 8MB. Đây chính là bài học VSZ ≠ RSS từ bài bộ nhớ ảo, áp dụng vào ngăn xếp: nhìn thấy nghìn luồng với VSZ khổng lồ, đừng hoảng — hỏi RSS mới biết RAM thật.
Guard page: bắt tràn stack một cách chính xác
Điều gì xảy ra nếu một luồng dùng hết 8MB ngăn xếp — ví dụ một hàm đệ quy quá sâu? Nhìn vào /proc/self/maps, ngay dưới mỗi vùng stack có một dòng lạ:
ffff34d80000-ffff34d90000 ---p 00000000 00:00 0 <- guard page
Đó là một trang bảo vệ (guard page): một vùng bộ nhớ có quyền ---p — không đọc, không ghi, không chạy. Nó nằm ngay dưới đáy ngăn xếp như một cái lưới. Khi ngăn xếp lớn dần (đệ quy sâu) và tràn qua đáy, lời truy cập đầu tiên chạm vào guard page — vùng cấm — và CPU sinh SIGSEGV ngay tại điểm tràn. Nhờ vậy, tràn stack thành một cú crash chính xác và rõ ràng, thay vì âm thầm đè lên bộ nhớ của thứ khác (một lỗi hỏng ngầm cực khó tìm và là lỗ hổng bảo mật). Guard page biến một thảm họa im lặng thành một tín hiệu ồn ào, dễ chẩn đoán.
Một lần tôi đo hớ: một hàm đệ quy vô hạn mà không bao giờ tràn
Tôi muốn đo cụ thể: đệ quy được bao nhiêu tầng trước khi tràn stack 8MB? Tôi viết một hàm tự gọi lại chính nó mãi, mỗi tầng chiếm ~1KB trên stack, và chạy nó dưới -O2. Kết quả: chương trình chạy mãi không bao giờ crash — nó treo cho tới khi hết giờ.
Khoan đã. Một hàm đệ quy vô hạn mà không làm tràn một ngăn xếp hữu hạn? Điều đó bất khả về mặt vật lý — stack chỉ có 8MB, đệ quy vô hạn thì sớm muộn phải chạm guard. Con số bất khả (không bao giờ crash) tố cáo rằng trình biên dịch đã đổi bản chất cái tôi đang đo.
Lý do: lời gọi đệ quy của tôi nằm ở cuối hàm — một tail call. Trình biên dịch -O2 nhận ra điều này và tối ưu nó thành một vòng lặp: thay vì đẩy một khung mới lên stack cho mỗi tầng, nó tái dùng cùng một khung, nhảy về đầu hàm. Stack không lớn lên chút nào, nên không bao giờ chạm guard, nên đệ quy "vô hạn" của tôi chỉ là một vòng lặp vô hạn — nó đo được không gì cả.
Biên dịch lại ở -O0 (tắt tối ưu này), tôi có câu trả lời thật:
stack 8MB -> ~7939 khung ~1KB rồi SIGSEGV (chạm guard, đúng ~8MB)
stack 2MB -> ~1979 khung
stack 256KB -> ~241 khung
Đúng như mong đợi: độ sâu đệ quy tối đa tỉ lệ thẳng với kích thước ngăn xếp (mỗi tầng ~1KB, nên 8MB cho ~8000 tầng), và mọi trường hợp đều kết thúc bằng SIGSEGV khi chạm guard. Bài học đo lường kép: vòng đo có thể bị -O2 biến đổi tới mức bạn đo nhầm một thứ hoàn toàn khác — ở đây, đo một vòng lặp thay vì đo đệ quy; và một con số bất khả (treo vô hạn) chính là tín hiệu báo rằng công cụ đã thay đổi hiện tượng dưới tay bạn.
Vì sao điều này quan trọng khi lập trình
Hệ quả đầu tiên: đừng sợ số luồng vì VSZ, hãy nhìn RSS. Một tiến trình có nghìn luồng sẽ hiện VSZ hàng GB do các ngăn xếp 8MB cộng lại, nhưng RAM thật thường nhỏ hơn nhiều. Khi giám sát bộ nhớ, cột đáng lo là RSS (RAM thường trú), không phải VSZ. Ngược lại, nếu bạn thật sự cần cực nhiều luồng, chính không gian địa chỉ ảo (nhất là trên hệ 32-bit) mới là trần — khi đó giảm cỡ ngăn xếp mặc định bằng pthread_attr_setstacksize cho vừa việc luồng thật sự làm.
Hệ quả thứ hai: thu nhỏ ngăn xếp là dao hai lưỡi. Đặt stack 256KB thay vì 8MB tiết kiệm địa chỉ ảo và cho phép nhiều luồng hơn, nhưng như phép đo cho thấy, nó cắt độ sâu đệ quy cho phép từ ~7939 xuống ~241 tầng. Nếu code của bạn (hay một thư viện nó gọi) đệ quy sâu hay cấp mảng lớn trên stack, một ngăn xếp nhỏ sẽ tràn — may là guard page biến điều đó thành SIGSEGV rõ ràng chứ không phải hỏng ngầm, nhưng nó vẫn là một cú crash. Chọn cỡ stack theo độ sâu thật sự của code, đừng đoán.
Hệ quả thứ ba là bài học đo lường: luôn nghi ngờ khi con số nói điều bất khả, và cẩn thận với -O2 trong code đo. Con số mang theo: ngăn xếp luồng mặc định 8MB là không gian ảo cấp lười — 200 luồng tốn 1612 MB VSZ nhưng chỉ 1,6 MB RSS thật; guard page dưới mỗi stack biến tràn thành SIGSEGV chính xác; và độ sâu đệ quy tối đa tỉ lệ thẳng với cỡ stack (8MB ≈ 7939 tầng). Đo đúng cần cả -O0 cho vòng đệ quy lẫn con mắt phân biệt VSZ với RSS.
Thử ba mươi giây
Với một tiến trình đa luồng đang chạy, so hai con số: grep -E 'VmRSS|VmSize' /proc/<pid>/status. Nếu VmSize lớn hơn VmRSS rất nhiều, phần chênh phần lớn là các ngăn xếp luồng ảo (và các vùng mmap khác) chưa chạm tới — bình thường, không phải rò rỉ. Rồi xem cỡ ngăn xếp mặc định của shell bạn: ulimit -s (in ra KB, thường 8192 = 8MB). Thử hạ nó rồi chạy một chương trình đệ quy sâu: ulimit -s 256; ./chuong-trinh-de-quy — nó sẽ SIGSEGV (Segmentation fault) ở độ sâu nông hơn hẳn, đúng cái ranh giới guard page mà bài này đo, hiện ra ngay trước mắt bạn.