Hình dung một văn phòng. Có một bảng thông báo chung treo cố định trên tường: ai cũng biết chính xác nó ở đâu, chạy tới đọc/ghi tức thì — đó là biến toàn cục. Nhưng nếu mười người cùng lao tới ghi lên đúng cái bảng đó, họ chen nhau, giành bút, tắc nghẽn. Giờ hình dung mỗi người có một tủ đồ riêng: để dùng, bạn phải liếc thẻ nhân viên xem tủ của mình số mấy trước đã (một bước nhỏ thêm), nhưng khi đã tới tủ mình thì viết gì tùy ý, không ai tranh. Thread-local storage là cái tủ riêng đó — nhỉnh hơn cái bảng chung một chút vì phải "tìm tủ của mình", nhưng đổi lại xóa sạch cảnh chen nhau.

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 (tìm số tủ của mình). 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 (cả phòng ghi tủ riêng thay vì chen một bảng).

(Môi trường đo: container gcc:13 trên host ARM AArch64, mrs tpidr_el0 cho TLS, mô hình TLS tĩnh mặc định, 8 luồng, kiểm assembly bằng objdump. Con số tuyệt đối đổi theo máy/kiến trúc — x86 dùng %fs/%gs — nhưng hình dạng bền vững: TLS đắt hơn global một chút, rẻ hơn đồng bộ chia sẻ rất nhiều.)

Hai niềm tin đối nghịch, cùng sai — và chính phép đo 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 niềm tin đối nghịch đều sai; nó đắt hơn global đúng 4% (rẻ), và rẻ hơn đồng bộ chia sẻ hàng trăm lần (rất đáng dùng). Sự thật nằm ở giữa, và cái giữa đó nghiêng hẳn về "nên 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×. Và đây mới là điều đáng nhớ nhất: 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ề đến code hằng ngày

Điều đầ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.

Điều 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.

Chỗ này TLS là công cụ bạn dùng hằng ngày qua các API cao cấp, và biết cả cái bẫy của chúng giúp dùng đúng. Java có ThreadLocal<T> — mỗi luồng một bản, dùng khắp nơi: giữ SimpleDateFormat (vốn không thread-safe) riêng mỗi luồng, giữ ngữ cảnh request trong web server (mỗi luồng phục vụ một request), đệm tạm per-thread; nhưng có một cạm bẫy kinh điển với thread pool — luồng được tái dùng nên ThreadLocal không tự xóa giữa các request, phải remove() thủ công kẻo rò bộ nhớ hoặc lộ dữ liệu request cũ. ThreadLocalRandom của Java ra đời chính vì bài toán này: Random chung có seed chia sẻ (CAS tranh chấp), nên Java cho mỗi luồng một RNG riêng — đúng "đổi biến chung lấy biến TLS" tôi đo, và cùng lý do LongAdder giữ mảng ô per-thread. Go thì cố ý không có thread-local (goroutine di chuyển giữa các luồng OS, TLS sẽ vô nghĩa): Go buộc bạn truyền context.Context tường minh cho trạng thái theo request, và dùng sync.Pool cho đệm per-P — một lựa chọn thiết kế để tránh cái bẫy "trạng thái ẩn theo luồng". Mẫu số chung: "mỗi luồng một bản" là cách gọn nhất để giảm chia sẻ ghi, nhưng nhớ dọn nó khi luồng được tái dùng.

Muốn tự thấy thì 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.) Vài phút đó 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 đó.