Suốt sê-ri này, một lời khuyên lặp đi lặp lại: muốn code đồng thời nhanh, hãy giảm chia sẻ ghi — cho mỗi luồng một chỗ riêng thay vì để tất cả cùng ghi một ô. Nhưng "cho mỗi luồng một chỗ riêng" thực hiện thế nào cho gọn? Câu trả lời chính là thread-local storage (TLS): biến _Thread_local (hay __thread) mà mỗi luồng có một bản riêng. Tôi đo hai thứ về TLS: truy cập nó tốn hơn một biến thường bao nhiêu, và dùng nó thay cho một biến chia sẻ nhanh hơn bao nhiêu — và một trong hai con số làm tôi phải sửa lại phép đo.
TLS truy cập thế nào
Một biến _Thread_local không nằm ở một địa chỉ cố định như biến toàn cục — mỗi luồng có một bản của nó ở một vùng nhớ riêng. Nên để truy cập, CPU phải tính địa chỉ của luồng hiện tại. Trên ARM, objdump cho thấy cơ chế: đọc thanh ghi con trỏ luồng (mrs x0, tpidr_el0 — thanh ghi giữ địa chỉ vùng TLS của luồng đang chạy), cộng offset của biến, rồi ldr/str như bình thường. So với biến toàn cục — dùng adrp cộng offset để lấy địa chỉ cố định — TLS chỉ thêm đúng một lệnh mrs. Không gọi vào nhân, không tra bảng phức tạp (với mô hình TLS tĩnh mặc định).
Đo: đắt hơn global 4%, nhanh hơn atomic 385 lần
Đầu tiên, chi phí truy cập, một luồng, đo tăng một biến _Thread_local so với một biến toàn cục:
global thường : 0,677 ns
TLS : 0,707 ns -> chỉ đắt hơn ~4% (1,04×)
TLS không miễn phí như global (nó thêm cái mrs), nhưng chỉ đắt hơn 4% — gần như không đáng kể. Đây là điểm thứ nhất: đừng ngại dùng TLS vì sợ chậm; truy cập nó rẻ gần bằng một biến thường.
Điểm thứ hai mới là lý do TLS quan trọng. Tôi cho 8 luồng cùng đếm, hai cách: mỗi luồng atomic_fetch_add vào một biến chung, so với mỗi luồng cộng vào bộ đếm TLS riêng rồi gộp lại một lần ở cuối:
atomic chung (8 luồng) : 58 triệu op/giây
TLS cục bộ (8 luồng) : 22.380 triệu op/giây -> TLS nhanh 385 lần!
385 lần. Và khoảng cách này rộng ra theo số luồng: ở 1 luồng TLS nhanh 6,9×, ở 4 luồng 92×, ở 8 luồng 385×. Vì biến atomic chung sụt khi thêm luồng (cache-line bouncing, scale âm), còn bộ đếm TLS tăng theo luồng (mỗi luồng ghi bản riêng, không tranh chấp, scale gần tuyến tính). TLS biến một điểm nóng chia sẻ thành các phép cộng cục bộ độc lập.
Một lần tôi đo hớ: chính phép đo TLS suýt lừa tôi
Ở đây có hai đo hớ. Đo hớ về kết quả: tôi từng nghĩ TLS hoặc "miễn phí như global" hoặc "chậm quá không đáng" — cả hai đều sai; nó đắt hơn global đúng 4% (rẻ), và rẻ hơn đồng bộ chia sẻ hàng trăm lần (đáng dùng).
Nhưng đo hớ thứ hai thú vị hơn, và nó về cách đo. Lần đầu tôi viết vòng đếm TLS là local_cnt = 0; for(i<N) local_cnt++; — và đo ra TLS nhanh hơn atomic 13.000 lần, một con số vô lý. Lý do: compiler thấy vòng đó có kết quả biết trước (local_cnt cuối cùng bằng N), nên nó gập cả vòng thành local_cnt = N — không thực thi phép cộng nào. Tôi đang đo một vòng lặp đã bị xóa, đúng cái bẫy khử mã chết khi đo một cờ mà sê-ri cảnh báo. Còn vòng atomic thì compiler không xóa được (atomic có tác dụng phụ nhìn thấy được từ luồng khác), nên phép so sánh hoàn toàn khập khiễng.
Tôi sửa bằng cách cộng một biến volatile mỗi vòng (compiler buộc phải đọc thật, không gập được), để cả hai bên làm việc như nhau mỗi lần lặp. Lúc đó con số về đúng: 385×, không phải 13.000×. Bài học đo lường: khi một con số đẹp đến vô lý, gần như chắc chắn phép đo sai — thường là do tối ưu xóa mất thứ bạn tưởng đang đo. Chính sự cẩn thận này (chặn khử mã chết, làm hai bên đối xứng) là ranh giới giữa một con số thật (385×) và một con số rác (13.000×).
Vì sao điều này quan trọng khi lập trình
Hệ quả đầu tiên: dùng TLS để cho mỗi luồng trạng thái riêng, thay vì chia sẻ rồi khóa. Bộ đếm thống kê, đệm tạm, bộ nhớ đệm nhỏ, hạt giống ngẫu nhiên — bất cứ thứ gì mỗi luồng cần riêng — đặt vào TLS thì mỗi luồng thao tác trên bản của mình, không tranh chấp với ai. Đây là hiện thân cụ thể của mẫu "mỗi luồng một ô, gộp cuối" mà atomic tranh chấp và contention đã chỉ là cách thoát khỏi điểm nóng.
Hệ quả thứ hai: giá của TLS là nhỏ và đáng. Truy cập TLS đắt hơn biến thường một chút (một mrs), nhưng rẻ hơn rất nhiều so với một atomic bị tranh chấp hay một mutex. Khi cân nhắc "biến chia sẻ có khóa" so với "biến TLS cục bộ", cái sau gần như luôn thắng về tốc độ ở tải đa luồng — chỉ cần bạn có thể gộp các bản cục bộ lại ở cuối.
Hệ quả thứ ba là tinh thần đo lường: kiểm phép đo của chính mình, đặc biệt khi kết quả quá đẹp. Con số mang theo: truy cập TLS (_Thread_local) chỉ đắt hơn biến toàn cục ~4% (0,707 so 0,677 ns; thêm một lệnh mrs tpidr_el0 tính địa chỉ per-thread, không vào nhân); và dùng TLS làm bộ đếm cục bộ cho mỗi luồng — thay vì một atomic chung — nhanh hơn 385 lần ở 8 luồng (22.380 so 58 triệu op/s) vì loại bỏ tranh chấp; TLS là công cụ chính để giảm chia sẻ ghi. Rẻ hơn global một chút, rẻ hơn đồng bộ chia sẻ rất nhiều — và nhớ chặn khử mã chết khi đo.
Thử ba mươi giây
Khai một biến _Thread_local long counter; và một biến long global_counter;, viết hai hàm tăng mỗi cái, rồi gcc -O2 -S so assembly: bạn sẽ thấy hàm TLS có thêm một lệnh đọc thanh ghi con trỏ luồng (mrs tpidr_el0 trên ARM, %fs/%gs trên x86) mà hàm global không có — đó chính là "cái giá 4%". Rồi thử cho 8 luồng cùng tăng một biến đếm: một lần bằng atomic_fetch_add vào biến chung, một lần mỗi luồng tăng biến TLS riêng rồi cộng lại — đo thời gian. Bản TLS sẽ nhanh hơn nhiều lần. (Nhớ: cho phần đếm cộng một giá trị volatile để compiler không gập vòng thành hằng, kẻo bạn đo một vòng lặp đã bị xóa.) Ba mươi giây đó cho bạn công cụ thực tế nhất của cả sê-ri: khi muốn nhanh trong đa luồng, đừng chia sẻ — cho mỗi luồng một bản, và TLS là cách gọn nhất để làm điều đó.