Map trong Go và lỗi concurrent map writes

1. Tổng quan

Map là cấu trúc dữ liệu được dùng nhiều nhất trong Go, sau slice. Map lại không an toàn khi nhiều goroutine cùng truy cập. Nếu bạn ghi vào một map từ nhiều goroutine, chương trình có thể dừng hẳn với thông báo fatal error: concurrent map writes.

Bài này ôn lại các thao tác cơ bản với map. Sau đó bài giải thích vì sao lỗi trên xảy ra, cách phát hiện nó bằng race detector, và ba cách sửa: sync.Mutex, sync.RWMutex, sync.Map. Các ví dụ dùng Go 1.22 trở lên.

2. Map trong Go: những điều cần nhớ

2.1. Thao tác cơ bản

package main

import "fmt"

func main() {
	stock := map[string]int{"apple": 10, "banana": 5}
	stock["orange"] = 7

	qty, ok := stock["grape"]
	fmt.Println(qty, ok)

	delete(stock, "banana")
	fmt.Println(len(stock))
}

Giải thích:

  • map[string]int{...} tạo map với key kiểu string, value kiểu int.
  • stock["grape"] trả về zero value (0) khi key không tồn tại. Biến ok cho bạn biết key có thật sự nằm trong map hay không.
  • delete xóa một key. Gọi delete với key không tồn tại không gây lỗi.
0 false
2

2.2. Nil map

Khai báo var m map[K]V mà không khởi tạo sẽ cho bạn một nil map. Đọc từ nil map vẫn được, nhưng ghi vào thì panic:

package main

func main() {
	var orders map[string]int
	_ = orders["A-001"] // OK, trả về 0
	orders["A-001"] = 1 // panic
}
panic: assignment to entry in nil map

Cách sửa là luôn khởi tạo map bằng make(map[string]int) hoặc bằng literal map[string]int{}. Lỗi này hay gặp khi map là field trong struct và bạn quên khởi tạo trong constructor.

2.3. Map là kiểu tham chiếu, thứ tự duyệt ngẫu nhiên

Khi gán map cho biến khác hoặc truyền vào hàm, cả hai cùng trỏ tới một cấu trúc dữ liệu bên dưới. Vì vậy hai goroutine nhận “cùng một map” thực chất đang chia sẻ chung một vùng nhớ. Đây là gốc rễ của vấn đề ở mục tiếp theo.

Ngoài ra, thứ tự duyệt for k, v := range m được runtime cố ý xáo trộn. Đừng viết code hay test phụ thuộc vào thứ tự đó. Nếu cần thứ tự cố định, hãy lấy key ra, sắp xếp, rồi duyệt.

3. Lỗi concurrent map writes

3.1. Tái hiện lỗi

Giả sử bạn đếm lượt truy cập theo user, mỗi request chạy trên một goroutine:

package main

import (
	"fmt"
	"sync"
)

func main() {
	visits := make(map[string]int)
	var wg sync.WaitGroup

	for i := 0; i < 100; i++ {
		wg.Add(1)
		go func() {
			defer wg.Done()
			for j := 0; j < 1000; j++ {
				visits[fmt.Sprintf("user-%d", j%10)]++
			}
		}()
	}

	wg.Wait()
	fmt.Println(len(visits))
}

Chạy vài lần, gần như chắc chắn bạn sẽ thấy:

fatal error: concurrent map writes

goroutine 23 [running]:
main.main.func1()
	/tmp/visits/main.go:17 +0x...
...
exit status 2

Dòng visits[...]++ gồm một lần đọc và một lần ghi. Khi 100 goroutine cùng làm việc đó, nhiều goroutine có thể sửa cấu trúc nội bộ của map (bucket, quá trình grow) cùng lúc. Runtime của Go có một cơ chế kiểm tra đơn giản: map giữ một cờ “đang ghi”. Nếu một goroutine thấy cờ đó đã bật khi nó bắt đầu ghi, runtime dừng chương trình ngay.

Có một biến thể tương tự khi một goroutine đọc trong lúc goroutine khác ghi:

fatal error: concurrent map read and map write

3.2. Vì sao không recover được

Nhiều người thử bọc code bằng recover:

go func() {
	defer func() {
		if r := recover(); r != nil {
			fmt.Println("recovered:", r)
		}
	}()
	visits["user-1"]++
}()

Cách này không có tác dụng. concurrent map writes không phải là panic. Đây là một fatal error do runtime gọi throw, và nó kết thúc toàn bộ tiến trình. recover không bắt được nó. Lựa chọn này là có chủ đích: map đã bị hỏng thì tiếp tục chạy còn nguy hiểm hơn là dừng hẳn.

Lưu ý: Cơ chế phát hiện của runtime chỉ là “best-effort”. Chương trình không crash không có nghĩa là code an toàn. Có thể bạn chỉ chưa gặp đúng thời điểm hai goroutine va chạm, và dữ liệu đã sai một cách âm thầm.

3.3. Phát hiện sớm bằng race detector

Công cụ đáng tin cậy hơn là race detector, được bật bằng cờ -race:

go run -race main.go
==================
WARNING: DATA RACE
Read at 0x00c000... by goroutine 8:
  runtime.mapaccess1_faststr()
  main.main.func1()
      /tmp/visits/main.go:17 +0x...

Previous write at 0x00c000... by goroutine 7:
  runtime.mapassign_faststr()
  main.main.func1()
      /tmp/visits/main.go:17 +0x...
==================

Race detector chỉ ra chính xác dòng đọc và dòng ghi xung đột. Bạn nên bật -race khi chạy test trong CI. Chương trình chạy với -race chậm hơn và tốn nhiều bộ nhớ hơn, vì vậy thường không dùng cờ này cho bản build production.

4. Cách sửa thứ nhất: sync.Mutex

Cách phổ biến và dễ hiểu nhất là bọc map trong một struct có mutex, rồi chỉ cho phép truy cập map qua method:

package visits

import (
	"maps"
	"sync"
)

type VisitCounter struct {
	mu     sync.Mutex
	counts map[string]int
}

func NewVisitCounter() *VisitCounter {
	return &VisitCounter{counts: make(map[string]int)}
}

func (c *VisitCounter) Inc(user string) {
	c.mu.Lock()
	defer c.mu.Unlock()
	c.counts[user]++
}

func (c *VisitCounter) Get(user string) int {
	c.mu.Lock()
	defer c.mu.Unlock()
	return c.counts[user]
}

func (c *VisitCounter) Snapshot() map[string]int {
	c.mu.Lock()
	defer c.mu.Unlock()
	return maps.Clone(c.counts)
}

Giải thích:

  • Field counts viết thường nên code bên ngoài package không thể chạm trực tiếp vào map.
  • Inc và Get đều khóa mutex, nên tại mỗi thời điểm chỉ một goroutine thao tác với map.
  • Snapshot trả về bản sao bằng maps.Clone (có từ Go 1.21). Nếu trả về c.counts trực tiếp, người gọi sẽ duyệt map đó mà không có khóa, và bạn lại quay về lỗi ban đầu.
  • Constructor NewVisitCounter khởi tạo map, tránh lỗi nil map ở mục 2.2.

4.1. Viết test với -race

Go không dùng JUnit hay AssertJ. Gói testing chuẩn là đủ cho ví dụ này:

package visits

import (
	"sync"
	"testing"
)

func TestVisitCounter_ConcurrentInc(t *testing.T) {
	counter := NewVisitCounter()
	var wg sync.WaitGroup

	for i := 0; i < 100; i++ {
		wg.Add(1)
		go func() {
			defer wg.Done()
			for j := 0; j < 1000; j++ {
				counter.Inc("alice")
			}
		}()
	}
	wg.Wait()

	if got := counter.Get("alice"); got != 100_000 {
		t.Fatalf("Get(alice) = %d, want 100000", got)
	}
}
go test -race ./...
ok  	example.com/visits	1.214s

Test kiểm tra hai điều. Thứ nhất, kết quả đúng 100.000, tức là không mất lượt tăng nào. Thứ hai, race detector không báo DATA RACE. Thử bỏ c.mu.Lock() trong Inc rồi chạy lại, test sẽ fail ngay.

4.2. Lỗi hay gặp với mutex

Sao chép struct chứa mutex. Nếu bạn khai báo method với value receiver, như func (c VisitCounter) Inc(...), mỗi lần gọi method sẽ dùng một bản sao của mutex. Khóa trên bản sao không bảo vệ được gì. Hãy luôn dùng pointer receiver. go vet sẽ cảnh báo trường hợp này qua check copylocks:

./visits.go:18:9: Inc passes lock by value: visits.VisitCounter contains sync.Mutex

Giữ khóa quá lâu. Đừng gọi HTTP, query database hay ghi file trong lúc đang giữ khóa. Hãy lấy dữ liệu cần thiết ra, mở khóa, rồi mới làm việc chậm.

Check-then-act tách rời. Đoạn code dưới đây có hai lần khóa riêng biệt, nên giữa hai lần đó goroutine khác có thể chen vào:

if counter.Get("bob") == 0 {
	counter.Inc("bob") // có thể đã có goroutine khác tăng trước
}

Nếu cần logic “nếu chưa có thì tạo”, hãy viết thành một method và giữ khóa trong suốt method đó.

5. Cách sửa thứ hai: sync.RWMutex

Với dữ liệu đọc rất nhiều nhưng hiếm khi ghi, ví dụ cache cấu hình hoặc bảng giá sản phẩm, sync.RWMutex cho phép nhiều goroutine đọc song song:

package pricing

import "sync"

type PriceTable struct {
	mu     sync.RWMutex
	prices map[string]int64
}

func NewPriceTable() *PriceTable {
	return &PriceTable{prices: make(map[string]int64)}
}

func (p *PriceTable) Price(sku string) (int64, bool) {
	p.mu.RLock()
	defer p.mu.RUnlock()
	v, ok := p.prices[sku]
	return v, ok
}

func (p *PriceTable) SetPrice(sku string, cents int64) {
	p.mu.Lock()
	defer p.mu.Unlock()
	p.prices[sku] = cents
}

RLock cho phép nhiều goroutine cùng đọc. Lock là khóa độc quyền: nó chờ mọi reader xong và chặn reader mới cho tới khi ghi xong.

Lưu ý: RWMutex không phải lúc nào cũng nhanh hơn Mutex. Bản thân nó có chi phí quản lý cao hơn. Khi tỉ lệ ghi đáng kể, hoặc khi đoạn code trong khóa rất ngắn, Mutex thường ngang hoặc nhanh hơn. Hãy đo bằng benchmark trước khi đổi.

Một lỗi dễ mắc: gọi p.mu.RLock() rồi bên trong lại gọi một method khác cũng RLock. Nếu giữa hai lần đó có một writer đang chờ, chương trình có thể deadlock. Lý do là RWMutex chặn reader mới khi đã có writer xếp hàng.

6. Cách sửa thứ ba: sync.Map

Gói sync có sẵn sync.Map, một map an toàn cho concurrency mà không cần tự quản lý khóa:

package main

import (
	"fmt"
	"sync"
)

type Session struct {
	UserID string
}

func main() {
	var sessions sync.Map

	sessions.Store("token-abc", Session{UserID: "alice"})

	actual, loaded := sessions.LoadOrStore("token-abc", Session{UserID: "bob"})
	fmt.Println(actual.(Session).UserID, loaded)

	if v, ok := sessions.Load("token-abc"); ok {
		fmt.Println(v.(Session).UserID)
	}

	sessions.Range(func(key, value any) bool {
		fmt.Println(key, value.(Session).UserID)
		return true
	})
}
alice true
alice
token-abc alice

Giải thích:

  • Store ghi một cặp key/value.
  • LoadOrStore là thao tác nguyên tử. Nếu key đã tồn tại, method trả về giá trị cũ và loaded = true, đồng thời không ghi đè. Đây là cách đúng để xử lý tình huống check-then-act với sync.Map.
  • Range duyệt map. Hàm callback trả về false để dừng sớm.

sync.Map có những hạn chế bạn cần biết:

  • Key và value có kiểu any, nên bạn mất kiểm tra kiểu lúc compile và phải type assertion khắp nơi.
  • Không có Len(). Muốn đếm phần tử, bạn phải duyệt bằng Range.
  • sync.Map được tối ưu cho hai trường hợp cụ thể. Một là mỗi key được ghi một lần rồi đọc rất nhiều lần, ví dụ cache chỉ tăng thêm. Hai là các goroutine đọc và ghi trên những tập key không giao nhau.

Lưu ý: Ngoài hai trường hợp trên, một map thường kèm Mutex hoặc RWMutex thường nhanh hơn, dễ đọc hơn và an toàn kiểu hơn. Đừng mặc định thay mọi map bằng sync.Map.

7. Chọn giải pháp nào?

Tình huống Lựa chọn gợi ý
Đọc/ghi lẫn lộn, logic có điều kiện map + sync.Mutex
Đọc chiếm phần lớn, ghi hiếm map + sync.RWMutex (đo trước)
Cache chỉ thêm, hoặc mỗi goroutine dùng tập key riêng sync.Map
Map chỉ được dùng trong một goroutine map thường, không cần khóa

Còn một cách nữa: để một goroutine duy nhất sở hữu map, các goroutine khác gửi yêu cầu qua channel. Cách này hợp khi việc cập nhật map gắn với một vòng xử lý sẵn có. Với một bộ đếm đơn giản, cách này dài dòng hơn mutex mà không mang lại lợi ích rõ ràng.

Lưu ý thêm về map chỉ đọc: nếu map được khởi tạo xong trước khi các goroutine bắt đầu và sau đó không ai ghi nữa, nhiều goroutine đọc đồng thời là an toàn và không cần khóa.

8. Kết luận

Map trong Go nhanh và tiện, nhưng không an toàn cho concurrency. Khi nhiều goroutine cùng ghi, hoặc vừa đọc vừa ghi, runtime có thể dừng chương trình bằng fatal error: concurrent map writes. Lỗi này không thể recover. Chương trình không crash cũng chưa chứng minh code đúng, vì vậy hãy chạy test với go test -race để phát hiện data race sớm.

Trong đa số trường hợp, bạn chỉ cần bọc map trong một struct có sync.Mutex, truy cập qua method với pointer receiver, và trả về bản sao thay vì map gốc. Hãy dùng sync.RWMutex khi tải đọc thật sự lấn át, và chỉ dùng sync.Map cho đúng những kịch bản mà nó được thiết kế.

golangmapconcurrencygoroutinesync