Ở 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. 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. 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ột lần tôi đo hớ: tôi không viết khóa nào
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 điều thứ 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().
Đây là cái bẫy gỡ lỗi thật sự: khi bạn 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 của bạn 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 — trong thư viện. Bài học đo lường: đ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. Và nhớ phân biệt: rand_r vs rand, localtime_r vs localtime, strtok_r vs strtok — cái đuôi _r (reentrant/tái nhập) chính là dấu hiệu "bản này không giữ trạng thái chung, an toàn và scale cho đa luồng".
Cần thành thật một điểm: 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. Đó cũng 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à chỉ đo mới cho biết hàm nào trong vòng nóng của bạn là kẻ giết throughput.
Vì sao điều này quan trọng khi lập trình
Hệ quả đầ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.
Hệ quả 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.
Hệ quả thứ ba là tinh thần đo lường: "thread-safe" không bằng "scale" — phải đo. Con số mang theo: nhiều hàm thư viện chuẩn có khóa ẩn vì giữ trạng thái chung — rand() (seed toàn cục) khiến tổng throughput SỤP từ 258 xuống 6,5 triệu op/s ở 2 luồng và 8 luồng (55) vẫn chậm hơn 1 luồng (258), dù ta không viết mutex nào; rand_r() cùng thuật toán nhưng tái nhập scale 5,1× (353->1814); malloc/free đỡ hơn nhờ arena riêng mỗi luồng (scale tới 4 luồng) nhưng vẫn tranh ở 8. Khóa ẩn trong thư viện cũng là tranh chấp — và nó trốn ngoài code của bạn.
Thử ba mươi giây
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. Ba mươi giây 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.