Ở bài về ngăn xếp và heap ta thấy malloc tốn công hơn cấp phát trên stack vì phải làm sổ sách. Nhưng còn một chuyện phức tạp hơn: khi nhiều luồng cùng malloc, chúng phải tranh nhau cấu trúc dữ liệu chung của bộ cấp phát — và tranh nhau khóa là kẻ thù của khả năng mở rộng. glibc giải quyết bằng một kiến trúc hai lớp mà ít người đo tận mắt. Bài này đo nó — và phát hiện một trong hai lớp che mất hiệu ứng của lớp kia, khiến tôi suýt kết luận sai về cả hai.
Arena và tcache: hai lớp chống tranh khóa
Bộ cấp phát của glibc dùng các arena — những vùng heap riêng biệt, mỗi cái có khóa riêng. Luồng chính dùng arena chính; khi nhiều luồng cùng cấp phát và tranh khóa, glibc tạo thêm arena (tối đa 8 lần số nhân) để các luồng phân tán ra, cấp phát ở những arena khác nhau thì không giẫm chân nhau. Đây là giải pháp cổ điển cho vấn đề tranh khóa trong cấp phát đa luồng.
Từ glibc 2.26 có thêm một lớp thứ hai: tcache (thread cache) — một bộ đệm cục bộ từng luồng chứa các chunk vừa được free. Khi luồng malloc một kích cỡ nhỏ vừa free gần đó, nó lấy thẳng từ tcache của mình không cần khóa nào cả. Đây là con đường nhanh nhất, và nó xử lý phần lớn các cấp phát nhỏ lặp đi lặp lại.
Còn một chi tiết quan trọng về free: nó không trả bộ nhớ về hệ điều hành ngay, mà giữ chunk trong bins/tcache để tái dùng. Nghĩa là bộ nhớ bạn "giải phóng" vẫn nằm trong tiến trình — đây là gốc của chuyện phân mảnh và của việc RSS không giảm sau khi free. Tôi muốn đo hai thứ: các arena giúp đa luồng scale bao nhiêu, và free có thật sự trả bộ nhớ không.
Đo: contention chỉ bùng nổ khi thiếu cả hai lớp
Tôi cho 8 luồng cùng chạy malloc(64) + free một trăm triệu lần (có làm việc thật để chống trình biên dịch xóa vòng lặp — cái bẫy đã gặp ở bài trước), rồi đo thông lượng trong bốn cấu hình: có/tắt tcache, nhân với nhiều/một arena:
| 8 luồng, malloc+free 64B | CÓ tcache | TẮT tcache |
|---|---|---|
| nhiều arena (mặc định) | 556 | 380 |
| một arena (MALLOC_ARENA_MAX=1) | 434 | 23 |
(triệu thao tác mỗi giây)
Bảng này là toàn bộ câu chuyện. Ở góc thiếu cả hai lớp — một arena và không tcache — thông lượng sập xuống 23 triệu op/s: cả 8 luồng nối đuôi nhau qua đúng một khóa, tuần tự hóa hoàn toàn. Nhưng chỉ cần một trong hai lớp là cứu được: nhiều arena (không tcache) đạt 380, tcache (một arena) đạt 434 — cả hai đều nhanh hơn góc thảm họa khoảng 16 lần. Và có cả hai (mặc định) thì 556.
Đo thêm khả năng mở rộng theo số luồng ở cấu hình mặc định: 1 luồng 167, 2 luồng 333 (gấp đôi đẹp), 8 luồng 621 triệu op/s — malloc đa luồng scale tốt, đúng như thiết kế. Và về phân mảnh: tôi cấp một triệu khối 128 byte (128 MB) rồi free hết, đo lại RSS — nó vẫn 146 MB, không giảm. Bộ nhớ đã free nằm trong bins của bộ cấp phát chờ tái dùng, không được trả về hệ điều hành.
Một lần tôi đo hớ: tcache che mất hiệu ứng của arena
Sai lầm của tôi nằm ở chỗ tôi đo một núm mà quên núm kia. Tôi vào bài định chứng minh một điều sách vẫn dạy: "arena giúp malloc đa luồng scale bằng cách tránh tranh khóa". Nên tôi đo cấu hình mặc định (556) so với ép một arena (434), và... chỉ tụt nhẹ. Nếu dừng ở đó, tôi đã kết luận "arena chẳng quan trọng mấy, ép về một cái cũng gần như không sao".
Kết luận đó sai, và cái sai là do một biến ẩn: tcache. Với một arena nhưng vẫn còn tcache, mỗi luồng phục vụ phần lớn cấp phát từ bộ đệm cục bộ của nó không đụng tới khóa arena nào, nên khóa chung hiếm khi bị tranh — tcache đã âm thầm làm đúng cái việc mà arena sinh ra để làm. Hiệu ứng của arena bị tcache che khuất hoàn toàn.
Lợi ích thật của arena chỉ lộ ra khi tôi tắt luôn tcache: lúc đó một arena tụt xuống 23 triệu op/s (tranh khóa bùng nổ), còn nhiều arena vẫn giữ 380 — gấp 16 lần. Chỉ khi kiểm cả hai núm cùng lúc, bức tranh thật mới hiện ra: glibc chặn tranh khóa bằng hai lớp độc lập, mỗi lớp đủ sức tự cứu, và thảm họa chỉ đến khi thiếu cả hai. Bài học đo lường: khi một hệ thống có nhiều cơ chế cùng giải một vấn đề, đo tác dụng của một cơ chế trong khi cơ chế kia vẫn bật thì sẽ thấy nó "vô dụng" — vì cái kia đang gánh. Phải tắt hết các lớp khác để cô lập lớp mình đang đo. Đây là họ hàng của các cái bẫy biến ẩn suốt sê-ri, lần này biến ẩn là một cơ chế dự phòng làm phép đo trông như không có tác dụng.
Vì sao điều này quan trọng khi lập trình
Hệ quả đầu tiên là malloc đa luồng của glibc đã scale tốt sẵn — hiếm khi cần thay bằng bộ cấp phát khác. Nhờ tcache và arena, các cấp phát nhỏ lặp lại của nhiều luồng gần như không tranh khóa. Trước đây người ta hay thay glibc malloc bằng jemalloc hay tcmalloc để tránh contention; ngày nay khoảng cách đã hẹp đi nhiều nhờ tcache. Trước khi đổi bộ cấp phát, hãy đo xem contention có thật sự là nút thắt của bạn không — nhiều khi không.
Hệ quả thứ hai là hiểu vì sao "tôi đã free mà RSS không giảm" — một câu hỏi kinh điển. RSS không giảm không có nghĩa là rò rỉ: bộ cấp phát giữ bộ nhớ đã free trong bins để tái dùng, và chỉ trả về hệ điều hành trong vài điều kiện nhất định (khối lớn được mmap riêng, hay khi đỉnh heap có vùng trống đủ lớn để cắt). Một tiến trình sống lâu, cấp phát và giải phóng nhiều kích cỡ khác nhau, có thể giữ RSS cao vì phân mảnh — bộ nhớ trống nằm rải rác thành mảnh nhỏ không gộp lại được. Muốn biết có rò rỉ thật hay chỉ phân mảnh, đừng nhìn RSS đơn thuần (nhớ RSS đo cái gì); đo bộ nhớ live thật sự, hoặc dùng công cụ như malloc_stats/valgrind.
Hệ quả thứ ba, về đo lường: để đo tác dụng của một cơ chế, phải tắt các cơ chế dự phòng khác. Con số mang theo: glibc chống tranh khóa malloc đa luồng bằng hai lớp độc lập — tcache (đệm cục bộ luồng, không khóa) và arena (heap riêng mỗi luồng) — mỗi lớp đủ tự cứu, contention chỉ bùng nổ (23 so với 556 triệu op/s) khi thiếu CẢ HAI; và free không trả bộ nhớ về OS ngay (free 128 MB mà RSS vẫn 146 MB), nên RSS cao không đồng nghĩa rò rỉ. Khi nhiều cơ chế cùng gánh một việc, chỉ đo được vai trò từng cái bằng cách tắt lần lượt.
Thử ba mươi giây
Xem chương trình của bạn tạo bao nhiêu arena: cat /proc/<pid>/maps | grep -c heap chỉ đếm một phần, nhưng dùng strace -e trace=mmap khi khởi động các luồng sẽ thấy glibc mmap các arena mới. Xem thống kê bộ cấp phát bằng cách gọi malloc_stats() (in ra stderr số arena, bộ nhớ đang giữ). Chỉnh số arena tối đa bằng biến môi trường MALLOC_ARENA_MAX=2 (giảm để tiết kiệm bộ nhớ ảo, đổi lại có thể tăng contention nếu tcache không đủ). Và để thấy free không trả bộ nhớ về OS: viết một chương trình malloc vài trăm MB khối nhỏ, free hết, rồi đọc VmRSS trong /proc/self/status — nó gần như không giảm, đúng cái phân mảnh bài này đo. Muốn nhìn tác dụng thật của arena, nhớ tắt tcache (GLIBC_TUNABLES=glibc.malloc.tcache_count=0) — nếu không, nó sẽ che mất.