sync.Map thường bị hiểu nhầm là "map an toàn luồng nhanh hơn" — thay thế mọi map + mutex. Sai. Tài liệu Go nói thẳng: nó tối ưu cho hai kịch bản cụ thể, và ngoài đó map + Mutex thường tốt hơn. Bí mật nằm ở cấu trúc bên trong: một map read không khóa cho đường nhanh, và một map dirty có mutex cho key mới. Bài này mổ xẻ cơ chế hai tầng đó và đo thật khi nào nó thắng, khi nào chỉ hòa.
Cấu trúc hai tầng: read và dirty
Bên trong, sync.Map giữ hai map:
type Map struct {
mu Mutex
read atomic.Pointer[readOnly] // đọc KHÔNG khóa (phần lớn truy cập)
dirty map[any]*entry // key mới, có Mutex bảo vệ
misses int // đếm lần đọc trượt read → dirty
}
read: một ảnh chụp bất biến, đọc bằng atomic — không cần khóa. Đây là đường nhanh.dirty: chứa các key chưa có trongread(mới thêm); mọi truy cập cầnMutex.misses: đếm số lần đọc trượtreadphải xuốngdirty. Khi trượt quá nhiều,dirtyđược thăng cấp thànhreadmới.

Hình 1: Cấu trúc hai tầng. Load thử read trước (không khóa); trượt thì xuống dirty (có khóa) và đếm miss; miss đủ nhiều thì thăng dirty→read. Store key cũ dùng CAS, key mới qua dirty.
Load và Store hoạt động thế nào
Load thử read trước — đường nhanh không khóa:
func Load(k):
if k trong read { return value } // KHÔNG khóa → cực nhanh
// trượt read:
mu.Lock()
if k trong dirty { misses++; return value }
if misses >= len(dirty) { thăng dirty → read } // dọn định kỳ
mu.Unlock()
Store key đã có trong read dùng CAS (đường nhanh); key mới phải qua dirty dưới mutex. Đây chính là điểm mấu chốt: đọc key có sẵn không bao giờ khóa, nên đọc-nhiều cực nhanh; nhưng thêm key mới luôn khóa, nên ghi-nhiều không có lợi thế.
Đo thật: thắng lớn đọc-nhiều, hòa ghi-nhiều
Hai kịch bản song song trên 10 core, 1000 key:

Hình 2: Đọc-nhiều — sync.Map 2,98 ns so với RWMutex 89,3 ns (~30x). Ghi-nhiều — sync.Map 89,3 ns so với RWMutex 98,9 ns (gần bằng). Bảng chọn cấu trúc.
- Đọc-nhiều: sync.Map 2,98 ns so với map+RWMutex 89,3 ns — nhanh hơn ~30 lần. Load trúng
readkhông khóa, mỗi core đọc độc lập, không tranh chấp cache. RWMutex phải đụng biến khóa chung (đếm reader), cache-line nảy giữa core. - Ghi-nhiều: sync.Map 89,3 ns so với RWMutex 98,9 ns — gần bằng nhau. Store key mới phải qua
dirtydưới mutex cộng chi phí thăng cấp, nên không còn lợi thế; đôi khi còn thuamap + Mutextùy mẫu.
Hai kịch bản sync.Map được thiết kế cho
Tài liệu Go nêu rõ hai ca sync.Map tối ưu:
- Key ghi một lần, đọc nhiều lần: cache đọc-nhiều, config, bảng tra cứu ít đổi. Sau khi key vào
read, mọi đọc là không khóa. - Nhiều goroutine với tập key rời nhau: mỗi goroutine thao tác trên key riêng, ít đụng nhau — giảm tranh chấp trên
dirty.
Ngoài hai ca này, map + Mutex/RWMutex thường tốt hơn — đặc biệt khi ghi thường xuyên hoặc key mới liên tục.
Ứng dụng thực tế
Dùng sync.Map cho cache/registry đọc-nhiều. Một bảng ánh xạ id→object, kết nối→session, hoặc feature flag mà đọc áp đảo ghi — đây là đất diễn của sync.Map. Load không khóa cho hiệu năng đọc gần như hoàn hảo dưới song song, như đo thấy ~30x RWMutex.
Dùng map + Mutex cho mọi thứ khác. Nếu ghi thường xuyên, cần biết size chính xác (sync.Map không có Len() O(1)), hoặc cần thao tác phức tạp (duyệt rồi sửa nguyên tử), map thường với mutex đơn giản hơn và thường nhanh hơn hoặc bằng. Đừng mặc định dùng sync.Map vì nghe "concurrent".
Cẩn thận API khác biệt. sync.Map dùng Load/Store/Delete/Range/LoadOrStore với any — không type-safe (phải type-assert), không dùng được [k], không len(), Range không đảm bảo ảnh chụp nhất quán. API rườm rà hơn map thường — thêm một lý do chỉ dùng khi thật cần.
Đánh đổi cần cân nhắc
sync.Map đánh đổi bộ nhớ và độ phức tạp lấy tốc độ đọc. Nó giữ hai bản sao dữ liệu (read + dirty) trong giai đoạn chuyển tiếp, và cơ chế thăng cấp/expunge tốn bộ nhớ hơn một map đơn. Với dữ liệu nhỏ hoặc ghi nhiều, chi phí này không đáng — map+mutex gọn hơn.
Ghi key mới liên tục làm dirty "thrash". Mỗi key mới đi vào dirty, và khi read bị coi là lỗi thời, cả dirty được copy sang read — chi phí O(n) theo số key. Nếu workload liên tục thêm key mới (không phải cập nhật key cũ), sync.Map thrash và chậm. Đây là anti-pattern rõ.
Đo trên mẫu tải thật của bạn. Như hai kịch bản cho thấy, lợi ích sync.Map hoàn toàn phụ thuộc tỷ lệ đọc/ghi và mẫu key. "sync.Map nhanh hơn" chỉ đúng cho đọc-nhiều. Luôn benchmark với tỷ lệ đọc/ghi và phân bố key giống production trước khi chọn.
Ba ý mang về
- sync.Map dùng cấu trúc hai tầng: read (atomic, không khóa) + dirty (map có Mutex): Load key có sẵn trúng
readkhông cần khóa (đường nhanh), key mới và trượt read phải quadirtydưới mutex; miss đủ nhiều thìdirtythăng cấp thànhread. - sync.Map thắng lớn khi đọc-nhiều, chỉ hòa khi ghi-nhiều: đo thật đọc-nhiều 2,98 ns so với map+RWMutex 89,3 ns (~30x, nhờ đọc không khóa không tranh chấp cache), nhưng ghi-nhiều 89,3 so 98,9 ns (gần bằng, vì key mới luôn qua dirty + mutex + thăng cấp).
- sync.Map KHÔNG phải "map nhanh hơn" mặc định: nó tối ưu cho hai kịch bản (key ghi-1-đọc-nhiều, hoặc tập key rời nhau giữa goroutine) — ngoài đó map+Mutex thường tốt hơn; ghi key mới liên tục làm dirty thrash, và API
anykhông type-safe, khôngLen()O(1).
Bài tập làm thử
Bài 1 (đọc hiểu cấu trúc). Mô tả vai trò của ba trường read, dirty, misses bên trong sync.Map, và cho biết trường nào cho phép đọc mà không cần khóa.
type Map struct {
mu Mutex
read atomic.Pointer[readOnly]
dirty map[any]*entry
misses int
}
Đáp án
read là một ảnh chụp gần như bất biến của dữ liệu, được truy cập qua atomic.Pointer — đọc key có trong read không cần khóa, đây là đường nhanh của sync.Map. dirty là một map thường chứa các key mới chưa kịp đưa vào read; mọi truy cập vào dirty phải giữ mu (Mutex). misses đếm số lần một lượt đọc trượt read và phải xuống tra dirty; khi số lần trượt đủ lớn (so với kích thước dirty), toàn bộ dirty được "thăng cấp" thành read mới để giảm số lần phải xuống tầng có khóa.
Bài 2 (đọc hiểu quy trình). Theo mô tả Load trong bài, khi gọi Load(k) với k là một key đã tồn tại từ trước trong map, thao tác này có bao giờ phải giữ Mutex không? Còn khi k là key vừa mới được Store gần đây (còn nằm trong dirty, chưa được thăng cấp)?
Đáp án
Nếu k đã có trong read (trường hợp phổ biến với key cũ, ổn định), Load tìm thấy ngay và trả về — không bao giờ cần giữ Mutex, đây chính là đường nhanh. Nếu k chỉ mới được thêm và còn nằm trong dirty (chưa được thăng cấp lên read), Load sẽ trượt ở bước kiểm tra read, buộc phải mu.Lock(), tra trong dirty, tăng biến đếm misses, rồi mới trả kết quả (và mu.Unlock()) — trường hợp này có giữ Mutex, chậm hơn đường nhanh.
Bài 3 (đọc hiểu số liệu — trọng tâm bất ngờ). Bài đo hai kịch bản trên 10 core, 1000 key: đọc-nhiều — sync.Map 2,98 ns/op so với map+RWMutex 89,3 ns/op (nhanh hơn ~30 lần); ghi-nhiều — sync.Map 89,3 ns/op so với map+RWMutex 98,9 ns/op (gần bằng nhau). Giải thích vì sao lợi thế của sync.Map biến mất gần hết khi chuyển sang kịch bản ghi-nhiều.
Đáp án
Lợi thế lớn của sync.Map ở đọc-nhiều đến từ việc Load trúng read hoàn toàn không khóa — mỗi core đọc độc lập, không có tranh chấp cache, trong khi RWMutex phải ghi vào một biến đếm reader dùng chung khiến cache-line "nảy" giữa các core. Nhưng khi ghi-nhiều, đặc biệt là thêm key mới liên tục, mỗi lần Store một key chưa có trong read phải đi qua dirty — và mọi truy cập dirty đều cần giữ Mutex, cộng thêm chi phí thăng cấp dirty lên read định kỳ. Ở kịch bản này, sync.Map không còn đường nhanh không-khóa để tận dụng nữa — nó phải trả gần như toàn bộ chi phí đồng bộ giống một map+mutex bình thường, nên hai bên gần như hòa nhau (thậm chí đôi khi sync.Map còn thua tùy mẫu tải).
Bài 4 (vận dụng thực tế). Hai tình huống sau, tình huống nào nên dùng sync.Map, tình huống nào nên dùng map + Mutex? Giải thích theo hai kịch bản mà tài liệu Go khuyến nghị cho sync.Map.
- (a) Một bảng feature-flag được nạp một lần lúc khởi động, sau đó hàng nghìn goroutine chỉ đọc để kiểm tra flag, gần như không có cập nhật mới.
- (b) Một bộ đếm số lượt truy cập theo từng route API, route mới xuất hiện liên tục theo thời gian và số lượt được cập nhật (ghi) thường xuyên trên các route.
Đáp án
(a) Nên dùng sync.Map — đây đúng kịch bản "key ghi một lần, đọc nhiều lần" mà tài liệu Go khuyến nghị: sau khi các flag được nạp vào read, mọi lượt đọc sau đó hoàn toàn không khóa, tận dụng đúng lợi thế ~30 lần đã đo được. (b) Nên dùng map + Mutex (hoặc RWMutex) — đây là kịch bản ghi thường xuyên và có key mới liên tục (route mới), đúng loại tải khiến dirty liên tục phải nhận key mới và "thrash" (không còn lợi thế, tốn thêm chi phí thăng cấp); ngoài ra nếu cần biết chính xác số lượng route (kích thước) hoặc duyệt-rồi-sửa nguyên tử, sync.Map cũng không hỗ trợ tốt bằng map thường.
Bài 5 (bẫy/đánh đổi). Bài viết cảnh báo sync.Map "không phải map nhanh hơn" theo mặc định. Nêu ba nhược điểm/đánh đổi cụ thể của sync.Map so với map thường (kèm mutex khi cần), được nêu trong bài.
Đáp án
(1) API không type-safe: sync.Map dùng kiểu any cho key/value trong Load/Store/Delete/Range/LoadOrStore, buộc phải type-assert khi đọc, không dùng được cú pháp chỉ số m[k] quen thuộc của map thường. (2) Không có Len() O(1): muốn biết chính xác số phần tử phải tự đếm, khác với việc biết ngay kích thước của một map thường được bảo vệ bằng mutex. (3) Ghi key mới liên tục gây "thrash": mỗi key mới đi vào dirty, và khi read bị coi là lỗi thời, toàn bộ dirty phải được copy sang read — chi phí O(n) theo số key; nếu workload liên tục thêm key mới thay vì cập nhật key đã có, sync.Map sẽ chậm đi rõ rệt, đây là "anti-pattern" đối với cấu trúc này. Ngoài ra, sync.Map còn tốn bộ nhớ hơn (giữ hai bản sao dữ liệu trong giai đoạn chuyển tiếp giữa read và dirty) so với một map đơn.
Phần sau ta xem một nguyên thủy đồng bộ ít gặp nhưng mạnh cho kiểm soát tài nguyên: Phần sau mổ xẻ semaphore có trọng số (golang.org/x/sync/semaphore) — cách giới hạn số tài nguyên đồng thời có trọng số khác nhau, khác gì với channel làm semaphore, và khi nào cần.