Hình dung một văn phòng đông người, ai cũng làm việc riêng, không dùng chung công cụ gì của bạn cả — nhưng cả phòng có đúng một cái máy photocopy. Mỗi lần cần copy, họ phải xếp hàng, một người một lượt. Bạn không hề lắp cái "hệ thống xếp hàng" nào; chính cái máy dùng chung là hàng đợi ẩn. Càng đông người cần copy, hàng càng dài, và công việc chậm lại — dù nhìn vào bàn ai cũng thấy họ làm việc độc lập. Nhiều hàm thư viện chuẩn có đúng một cái "máy photocopy" như vậy giấu bên trong, và nó có thể tuần tự hóa cả chương trình song song của bạn mà bạn không viết lấy một dòng khóa.
Ở bài đọc profiler ta thấy nút thắt đồng bộ có thể trốn khỏi hồ sơ phẳng. Có một chỗ nút thắt trốn còn kín hơn: bên trong thư viện chuẩn. Bạn viết code đa luồng không có lấy một pthread_mutex, đo thấy nó không scale, và ngồi soi từng dòng của mình — trong khi cái khóa thật nằm trong rand(), printf, hay malloc. Đây là đồng bộ ẩn: những hàm quen thuộc giữ trạng thái chung nên phải tự khóa, và khi nhiều luồng gọi chúng, chúng tranh nhau cái khóa đó. Tôi đo trong container gcc:13 (10 lõi, ARM) để thấy nó rõ ràng.
Vì sao thư viện phải khóa
Nhiều hàm thư viện chuẩn giữ trạng thái toàn cục hoặc dùng chung một tài nguyên, nên để đúng đắn với đa luồng chúng phải tự bảo vệ bằng khóa — một khóa bạn không thấy trong code của mình. rand() giữ một hạt giống (seed) toàn cục, mỗi lần gọi cập nhật nó, nên phải khóa — đúng cái máy photocopy chung. printf và bạn bè ghi vào một FILE* có bộ đệm chung, mỗi thao tác lấy một khóa (flockfile) để dòng ra không bị trộn. malloc/free quản một heap chung, cần đồng bộ để hai luồng không cấp phát chồng lên nhau.
Điểm mấu chốt: "thread-safe" chỉ nghĩa "gọi từ nhiều luồng không hỏng", không nghĩa "gọi từ nhiều luồng thì nhanh hơn". Một hàm bọc mọi thứ trong một khóa toàn cục là thread-safe hoàn hảo — và cũng tuần tự hóa hoàn hảo. Để đo sự khác biệt, tôi cho 1→8 luồng cùng gọi ba hàm và đo tổng throughput.
Đo: rand() sụp, rand_r() bay
tổng triệu thao tác/giây | 1 luồng | 2 luồng | 4 luồng | 8 luồng
rand() (khóa ẩn) | 258 | 6,5 | 70 | 55
rand_r() (tái nhập) | 353 | 694 | 1261 | 1814
malloc/free (glibc arena)| 191 | 374 | 540 | ~380
rand() là ca kinh điển. Một luồng: 258 triệu op/s. Hai luồng: sụp còn 6,5 — tổng throughput tệ hơn 40 lần so với một luồng, vì hai luồng ping-pong futex trên đúng một khóa (lock convoy như bài lock convoy đã đo). Ở 4-8 luồng nó gượng lại chút (55-70) nhưng vẫn thấp hơn cả một luồng đơn độc. Thêm luồng làm chương trình chậm đi — một dấu hiệu scale âm không thể rõ hơn, và bạn sẽ không tìm thấy cái khóa gây ra nó ở đâu trong code của mình.
rand_r() làm chính xác cùng công việc (sinh số giả ngẫu nhiên) nhưng nhận seed qua tham số — trạng thái nằm ở biến cục bộ mỗi luồng, không khóa, mỗi người một máy photocopy riêng. Nó scale từ 353 lên 1814, tức 5,1 lần trên 8 luồng. Cùng thuật toán, khác một chỗ: trạng thái chung hay riêng.
malloc/free cho thấy một câu chuyện ở giữa, đáng khích lệ: glibc đã nhận ra vấn đề khóa ẩn và giải bằng arena riêng cho mỗi luồng — mỗi luồng cấp phát từ vùng heap riêng, hầu như không tranh. Nhờ vậy nó scale từ 191 lên 540 ở 4 luồng (2,8 lần), dù ở 8 luồng bắt đầu tranh arena và chững lại (~380, dao động). Đây là cùng nguyên tắc sharding mà sê-ri đã đo: chia trạng thái chung thành nhiều mảnh riêng để né khóa.
(Môi trường đo: container gcc:13 trên host ARM AArch64, 10 lõi, tổng throughput 1→8 luồng, mỗi số sau warmup. Con số đổi theo cài đặt libc; điều bền vững là hình dạng: hàm giữ trạng thái chung (rand) sụp khi thêm luồng, bản tái nhập (rand_r) scale, allocator có arena thì ở giữa.)
Hai niềm tin đối nghịch, cùng sai
Tôi vào đo với hai niềm tin phổ biến. Thứ nhất: "thư viện chuẩn thread-safe rồi, cứ gọi thoải mái từ nhiều luồng". Thứ hai, nguy hiểm hơn khi gỡ lỗi: "code của tôi không có một khóa nào, nên nếu nó không scale thì phải là lỗi thuật toán của tôi". Phép đo rand() phá tan cả hai: chương trình 8 luồng của tôi chậm hơn bản một luồng, mà tôi không viết một mutex nào cả — cái khóa nằm sâu trong libc, sau một lời gọi trông vô hại như rand(). "Thread-safe" không bằng "scale": một hàm bọc khóa toàn cục an toàn hoàn hảo mà tuần tự hóa hoàn hảo.
Đây là cái bẫy gỡ lỗi thật sự: khi thấy một chương trình đa luồng không scale, phản xạ là soi code của mình — nhưng nếu code gọi rand(), printf, localtime(), hay bất kỳ hàm nào giữ trạng thái toàn cục trong vòng nóng, nút thắt có thể hoàn toàn nằm ngoài tầm mắt. Cần đo tổng throughput theo số luồng trước tiên; nếu nó giảm khi thêm luồng mà code của bạn "không có khóa", hãy nghi các lời gọi thư viện. Dấu hiệu cứu bạn: cái đuôi _r (reentrant/tái nhập) — rand_r vs rand, localtime_r vs localtime, strtok_r vs strtok — nghĩa là "bản này không giữ trạng thái chung, an toàn và scale". Và một điểm phải thành thật: malloc scale tương đối tốt nhờ arena, nên không phải mọi hàm thư viện đều là thảm họa như rand() — mức độ tùy cài đặt, và đó chính là lý do phải đo chứ không đoán: cùng nhãn "thread-safe", rand() sụp còn malloc gần scale.
Về đến code hằng ngày
Điều đầu tiên: trong vòng nóng đa luồng, tránh các hàm thư viện giữ trạng thái toàn cục. rand(), srand(), printf ra chung một luồng, localtime(), strtok() — mỗi cái là một khóa ẩn. Dùng bản tái nhập (rand_r, localtime_r) hoặc giữ trạng thái ở thread-local. Một lời gọi trông vô hại có thể tuần tự hóa cả chương trình song song của bạn.
Điều thứ hai: khi đa luồng không scale, nghi cả thư viện, không chỉ code của mình. Đo tổng throughput theo số luồng; nếu nó giảm mà bạn "không có khóa nào", chạy dưới một profiler đo lock contention hoặc đơn giản thay các lời gọi thư viện nghi ngờ bằng bản tái nhập và đo lại. Nút thắt hay trốn sau một cái tên quen thuộc.
Chỗ này cái bẫy khóa-ẩn quen thuộc đến mức các ngôn ngữ cao cấp phải làm hẳn API riêng để né. Java là ví dụ hoàn hảo: Math.random() và java.util.Random dùng một seed chung có synchronized/CAS — tranh chấp y hệt rand() — nên Java 7 thêm ThreadLocalRandom (mỗi luồng một seed riêng), và mọi hướng dẫn hiệu năng đều bảo dùng nó trong code đa luồng. Go từng có math/rand toàn cục khóa chung, và Go 1.20+ chuyển các hàm top-level sang trạng thái per-P để bớt tranh. SimpleDateFormat của Java không thread-safe là một cú vấp kinh điển khác. Ở tầng allocator, jemalloc/tcmalloc (Facebook, Google) sinh ra chính để giải bài tranh heap bằng arena, đúng cái glibc làm. Mẫu số chung: hàm nào giữ trạng thái chung đều là cái máy-photocopy-dùng-chung — và giải pháp luôn là cho mỗi luồng một bản riêng (thread-local/tái nhập/arena).
Muốn tự thấy thì nhìn vòng lặp đa luồng nặng nhất trong một chương trình của bạn và liệt kê mọi lời gọi thư viện trong đó: có rand() không? có printf/fprintf ra chung một stream không? có malloc/new cho từng phần tử không? có localtime, strtok, hay hàm nào tên không kết thúc bằng _r không? Mỗi cái là một khóa ẩn tiềm tàng đang tuần tự hóa các luồng của bạn. Vài phút liệt kê đó thường tìm ra lý do một chương trình "không có khóa nào" lại không chịu scale — và cách chữa (bản _r, thread-local, hay allocator có arena) thường chỉ là đổi một lời gọi, không phải viết lại thuật toán.