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ó trong read (mới thêm); mọi truy cập cần Mutex.
  • misses: đếm số lần đọc trượt read phải xuống dirty. Khi trượt quá nhiều, dirty được thăng cấp thành read mới.

Ảnh chụp đoạn mã Go nền tối minh hoạ sync.Map internals cấu trúc read dirty hai tầng, không phải map nhanh hơn nó tối ưu riêng cho đọc-nhiều bằng một map read không khóa cộng một map dirty có khóa, một hai tầng bên trong type Map struct mu Mutex read atomic.Pointer readOnly đọc không khóa phần lớn dirty map any entry key mới có Mutex bảo vệ misses int đếm lần đọc trượt read xuống dirty read là ảnh chụp bất biến đọc bằng atomic không khóa dirty chứa các key chưa vào read mọi truy cập cần Mutex, hai 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 cộng cộng return value if misses lớn hơn bằng len dirty thăng dirty sang read dọn mu.Unlock, ba Store key có sẵn trong read CAS key mới dirty func Store k v if k trong read CAS cập nhật entry đường nhanh else mu.Lock dirty k bằng entry v mu.Unlock key mới qua dirty, bốn hai kịch bản sync.Map được thiết kế cho 1 key ghi một lần đọc nhiều lần cache config đọc-nhiều 2 nhiều goroutine tập key rời nhau mỗi goroutine key riêng ngoài hai ca này map cộng Mutex RWMutex thường tốt hơn tài liệu Go nói rõ sync.Map không phải thay thế map thường chỉ dùng cho hai mẫu trên ghi nhiều key mới liên tục làm dirty thrash

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:

Ảnh chụp bảng kết quả đo thật nền tối sync.Map thắng lớn khi đọc-nhiều hòa khi ghi-nhiều go test bench song song RunParallel Go 1.23 arm64 10 core 1000 key, kịch bản 1 đọc-nhiều Load key có sẵn sync.Map 2,98 ns map cộng RWMutex 89,3 ns sync.Map nhanh hơn khoảng 30 lần khi đọc-nhiều Load trúng map read không khóa mỗi core đọc độc lập không tranh chấp RWMutex phải đụng biến khóa chung đếm reader cache-line nảy giữa core, kịch bản 2 ghi-nhiều Store liên tục sync.Map 89,3 ns map cộng RWMutex 98,9 ns khi ghi-nhiều hai bên gần bằng nhau 89 vs 99 ns Store key mới của sync.Map phải qua dirty dưới Mutex cộng chi phí thăng cấp không còn lợi thế đôi khi còn thua map cộng Mutex tùy mẫu, chọn cái nào sync.Map đọc-nhiều-ghi-hiếm hoặc key rời nhau giữa goroutine cache đọc-nhiều config đăng ký một-lần map cộng RWMutex Mutex ghi thường xuyên hoặc cần size chính xác hoặc cần thao tác phức tạp iterate cộng sửa sự thật sync.Map không phải map nhanh hơn mặc định, cốt lõi sync.Map read atomic không khóa cộng dirty map có Mutex đọc-nhiều 30x nhanh hơn RWMutex 2,98 vs 89,3 ns ghi-nhiều hòa 89 vs 99 ns dirty qua Mutex cộng thăng cấp thiết kế key ghi-1-đọc-nhiều hoặc tập key rời nhau đừng dùng như map thường ghi key mới liên tục thrash

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 read khô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 dirty dưới mutex cộng chi phí thăng cấp, nên không còn lợi thế; đôi khi còn thua map + Mutex tù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:

  1. 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.
  2. 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ề

  1. 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 read không cần khóa (đường nhanh), key mới và trượt read phải qua dirty dưới mutex; miss đủ nhiều thì dirty thăng cấp thành read.
  2. 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).
  3. 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 any không type-safe, không Len() O(1).

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.