bài về luồng ta thấy các luồng chia sẻ chung một không gian địa chỉ — đó là ưu điểm (giao tiếp nhanh) và cũng là cạm bẫy: hai luồng chạm cùng một biến sẽ giẫm chân nhau. Bộ nhớ cục bộ luồng (thread-local storage, TLS) là lời giải cho một lớp lớn các trường hợp: cho mỗi luồng một bản riêng của biến. Tôi đo trong container xem nó đúng và nhanh cỡ nào — bắt đầu bằng một con số làm 73% dữ liệu của tôi biến mất không dấu vết.

Bộ nhớ cục bộ luồng

Vấn đề: một biến chung, nhiều luồng giẫm chân

Một biến toàn cục là một ô nhớ duy nhất mà mọi luồng cùng thấy. Nghe tiện, nhưng khi nhiều luồng cùng sửa nó thì hỏng. Tôi cho 4 luồng, mỗi luồng tăng một biến toàn cục shared lên hai triệu lần — kỳ vọng tổng cuối cùng là 8.000.000. Đo:

4 luồng, mỗi luồng shared++ 2 triệu lần (mong tổng = 8.000.000):
  biến chung, KHÔNG khóa: 0,010 s | tổng = 2.144.770   <-- SAI

Chỉ còn 2.144.770 — mất 73% số lần tăng, mà chương trình không hề báo một lỗi nào, không crash, chạy tỉnh bơ. Lý do là shared++ không phải một thao tác nguyên tử: nó là đọc – sửa – ghi (đọc giá trị, cộng 1, ghi lại). Hai luồng cùng đọc shared được 100, cả hai cùng tính 101, cả hai cùng ghi 101 — một trong hai lần tăng bị nuốt mất. Đây là đua tranh dữ liệu (data race): kết quả sai một cách âm thầm, phụ thuộc vào thời điểm ngẫu nhiên của các luồng, và không có tín hiệu nào cảnh báo.

Hai cách sửa, và cái giá của mỗi cách

Cách quen thuộc là bọc mỗi lần truy cập bằng một khóa (mutex):

  biến chung + mutex: 0,182 s | tổng = 8.000.000   (đúng)

Đúng rồi, nhưng chậm hơn 18 lần so với bản không khóa — vì bốn luồng liên tục tranh nhau một cái khóa, và tranh chấp khóa đắt (như bài futex/mutex đã đo). Ta trả tốc độ để mua sự đúng đắn.

Nhưng để ý: trong bài toán này, các luồng không cần chia sẻ biến đếm với nhau chút nào — mỗi luồng chỉ đếm phần của riêng nó, và ta cộng lại ở cuối. Khi đó không cần khóa, không cần chia sẻ; ta chỉ cần cho mỗi luồng một bản riêng. Đó chính là __thread (hay thread_local trong C++):

  __thread (TLS): 0,002 s | tổng = 8.000.000   (đúng)

Vừa đúng vừa nhanh — nhanh hơn bản dùng mutex khoảng 87 lần. Vì mỗi luồng có ô nhớ riêng, không có gì để giẫm chân, không có đua tranh, không cần khóa. Cuối cùng tôi cộng bốn bản riêng lại thành tổng đúng.

Một lần tôi đo hớ: bản nhanh nhất là bản sai

Nhìn lại ba con số, có một cái bẫy tôi suýt sập vào. Bản chung không khóa là bản nhanh nhất (0,010 s) — nhanh hơn cả bản __thread. Nếu tôi chỉ đo tốc độ và chọn bản nhanh nhất, tôi đã chọn đúng cái bản cho kết quả sai. Con số 2.144.770 trông như một con số bình thường; nếu không biết trước tổng phải là 8.000.000, tôi có thể chẳng bao giờ nhận ra nó thiếu.

Đây là bài học "nhanh vô nghĩa nếu chưa hỏi đúng chưa" ở dạng gắt nhất: tốc độ và tính đúng là hai trục khác nhau, và trong lập trình đồng thời chúng thường đối nghịch — cái nhanh nhất (không đồng bộ gì) lại sai, còn cái đúng cần thêm chi phí. __thread phá thế đối nghịch đó cho trường hợp "mỗi luồng một trạng thái riêng": nó đúng không chia sẻ, và nhanh cũng vì không chia sẻ. Không phải phép màu — chỉ là tránh hẳn cái phải đồng bộ, thay vì đồng bộ cho nhanh.

TLS truy cập bằng cách nào, tốn thêm bao nhiêu

Một biến __thread được cấp phát cho mỗi luồng, nên trình biên dịch không thể dùng một địa chỉ cố định như biến toàn cục thường. Nhìn vào assembly sinh ra trên aarch64:

Biến thường:   adrp x0, bien      ; nạp địa chỉ cố định
               ldr  x0, [x0]

Biến TLS:      mrs  x0, tpidr_el0 ; đọc thanh ghi con-trỏ-luồng
               add  x0, x0, #offset ; cộng offset của biến trong khối TLS
               ldr  x0, [x0]

Mỗi luồng có một thanh ghi con-trỏ-luồng (tpidr_el0 trên ARM, fs trên x86) trỏ tới khối TLS của riêng nó. Truy cập một biến TLS = đọc thanh ghi đó rồi cộng offset — thêm đúng một hai lệnh so với biến thường. Và chi phí đó gần như bằng không trong thực tế; tôi đo tốc độ truy cập:

biến thường: 0,236 ns mỗi lần
biến TLS   : 0,234 ns mỗi lần

Ngang nhau, trong sai số đo. Cái thanh ghi con-trỏ-luồng đã được nạp sẵn, nên phần cộng offset không thêm gì đáng kể trong một vòng lặp nóng. TLS cho bạn sự riêng biệt gần như miễn phí.

(Ghi chú đo lường: vòng đếm ban đầu tôi để -O2 biên dịch ra 0,000 giây — trình biên dịch gộp cả triệu lần shared++ thành một phép cộng, xóa mất đua tranh lẫn phép đo. Con số bất khả đó buộc tôi biên dịch vòng đua tranh ở -O0 và vòng đo truy cập với biến volatile để mỗi thao tác là thật.)

Vì sao điều này quan trọng khi lập trình

Hệ quả đầu tiên: dùng TLS khi mỗi luồng cần trạng thái riêng. Rất nhiều thứ hợp với mẫu này: một bộ đệm nháp cho mỗi luồng, một errno riêng (chính errno là TLS!), một bộ sinh số ngẫu nhiên riêng, một con trỏ "ngữ cảnh hiện tại". Thay vì một biến chung + khóa (chậm, dễ sai), cho mỗi luồng một bản là vừa nhanh vừa an toàn. errno là ví dụ kinh điển: nếu nó là biến chung, hai luồng gọi syscall cùng lúc sẽ ghi đè mã lỗi của nhau — nên nó bắt buộc phải là TLS.

Hệ quả thứ hai: TLS không phải liều thuốc cho mọi chia sẻ. Nó chỉ đúng khi các luồng không cần thấy giá trị của nhau. Khi chúng thật sự phải chia sẻ và cùng cập nhật một trạng thái (một bộ đếm tổng dùng ngay, một cấu trúc dữ liệu chung), bạn vẫn cần khóa hoặc thao tác nguyên tử. TLS giải quyết "mỗi luồng một bản", không giải quyết "nhiều luồng một bản dùng chung".

Hệ quả thứ ba là bài học đo lường: trong đồng thời, luôn kiểm tính đúng trước, rồi mới nói tới tốc độ. Con số mang theo: bốn luồng tăng một biến chung không khóa cho ra 2.144.770 thay vì 8.000.000 — sai 73% âm thầm do đua tranh; thêm mutex thì đúng nhưng chậm 87 lần vì tranh chấp; __thread cho mỗi luồng bản riêng thì vừa đúng vừa nhanh và truy cập gần như ngang biến thường. Bản nhanh nhất là bản sai — trong lập trình đồng thời, "nhanh" mà chưa "đúng" là con số nguy hiểm nhất.

Thử ba mươi giây

Trong ngôn ngữ của bạn, tìm từ khóa cho biến cục bộ luồng: C11 _Thread_local, C++ thread_local, GCC/Clang __thread, Rust thread_local!, Java ThreadLocal<T>, Go — mỗi goroutine không có TLS thật nhưng có mẫu tương đương qua context. Thử viết một chương trình nhỏ cho vài luồng cùng tăng một biến static (không khóa) vài triệu lần rồi in tổng: bạn sẽ thấy nó nhỏ hơn kỳ vọng, và khác nhau mỗi lần chạy — dấu hiệu chắc chắn của đua tranh. Đổi biến đó thành thread-local (mỗi luồng một bản, cộng lại ở cuối) và tổng sẽ đúng, ổn định, và nhanh hơn. Đó là toàn bộ khác biệt bài này đo, gói trong mười dòng code.