BeeTool.Dev - Essential Tools for developers

Stories.Tool Tabs VaultPersonal Documents
BeeTool.Dev

Essential tools, knowledge, and playgrounds for developers.

Explore

  • Stories
  • Personal Documents
  • Features
  • Beetool for Mac
  • Algorithms Playground

Tool Tabs

  • JSON Compare
  • SQL Formatter
  • Text Diff
  • All tools →

Learn

  • Sorting algorithms
  • Searching algorithms
  • Algorithm Race Mode
© 2024 – 2026 BeeTool.Dev
AboutContactPrivacy policyTerms
All stories
GolangCourse

Golang: Package sync và atomic

Goroutine rất rẻ để tạo ra, nhưng sự "rẻ" đó đi kèm một cái bẫy: mọi goroutine trong chương trình đều dùng chung một không gian địa chỉ bộ nhớ. Khi hai hay nhiều goroutine cùng đọc và ghi vào cùng một biến tại cùng thời điểm, bạn có một data race, và kết quả là không xác định — chương trình có thể chạy tốt suốt nhiều tháng rồi đột nhiên làm hỏng dữ liệu trên production đúng lần scheduler sắp xếp thứ tự khác đi. Package sync (cùng với người anh em cấp thấp hơn là sync/atomic) cung cấp cho bạn các công cụ để phối hợp goroutine một cách an toàn: chờ công việc hoàn thành, bảo vệ các đoạn code nhạy cảm (critical section), và thực hiện các thao tác không thể chia nhỏ trên giá trị dùng chung. Trong bài này, chúng ta sẽ đi qua WaitGroup, Mutex, RWMutex, Cond, Once, Pool, sync.Map và atomic, sau đó xem xét những lỗi mà lập trình viên Go hay mắc phải nhất khi dùng chúng.

DUKE261 viewsJuly 27, 2026

WaitGroup

WaitGroup chờ một tập hợp goroutine hoàn thành. Nó hoạt động dựa trên một bộ đếm nội bộ đơn giản cùng ba phương thức: Add(delta) tăng bộ đếm (gọi trước khi khởi chạy goroutine, không phải bên trong nó), Done() giảm bộ đếm đi 1 (gọi bên trong goroutine, thường qua defer), và Wait() chặn goroutine đang gọi cho đến khi bộ đếm về 0.

func main() {
var wg sync.WaitGroup

wg.Add(1)
go func() {
defer wg.Done()
work()
}()

wg.Wait()
}

Ở đây main cộng 1 vào bộ đếm, khởi chạy một goroutine duy nhất, rồi chặn tại Wait() cho đến khi goroutine đó gọi Done(). Nếu bạn quên gọi Add trước khi khởi chạy, hoặc quên Done bên trong goroutine, Wait() sẽ trả về ngay lập tức (đếm thiếu) hoặc chặn mãi mãi (đếm thừa) — runtime không có cách nào đoán được ý định của bạn.

Khi cần chia sẻ một WaitGroup giữa các hàm, nó phải được truyền bằng con trỏ, vì sync.WaitGroup không bao giờ được phép sao chép sau khi đã sử dụng:

func work(wg *sync.WaitGroup) {
defer wg.Done()
fmt.Println("working...")
}

Gọi wg.Add(4) rồi bốn lần go work(&wg) tạo ra bốn goroutine độc lập, mỗi cái giảm bộ đếm dùng chung khi hoàn thành:

$ go run main.go
working...
working...
working...
working...

Thứ tự bốn dòng này không được đảm bảo — goroutine scheduler quyết định cái nào chạy khi nào — nhưng Wait() trong main sẽ không trả về cho đến khi cả bốn đã in xong và gọi Done().

Các lỗi thường gặp với WaitGroup

var wg sync.WaitGroup

for i := 0; i < 4; i++ {
go func() {
wg.Add(1) // ⚠️ race: Add() phải xảy ra trước khi goroutine bắt đầu
defer wg.Done()
work()
}()
}
wg.Wait()

Gọi Add bên trong goroutine là một race: Wait() có thể chạy trước khi bất kỳ goroutine nào chạm tới lệnh Add, thấy bộ đếm bằng 0, và trả về ngay trong khi vẫn còn goroutine đang chờ xử lý. Cách khắc phục là gọi Add từ goroutine khởi chạy công việc, trước từ khóa go:

for i := 0; i < 4; i++ {
wg.Add(1) // ✅ đếm trước khi goroutine bắt đầu
go func() {
defer wg.Done()
work()
}()
}
wg.Wait()

Một lỗi kinh điển khác là truyền WaitGroup bằng giá trị vào một hàm:

func launch(wg sync.WaitGroup) { // ❌ sao chép WaitGroup
defer wg.Done()
work()
}

Vì hàm nhận vào một bản sao, Done() giảm bộ đếm của bản sao đó, không phải wg gốc trong main — nên Wait() sẽ chặn mãi mãi. go vet bắt được chính xác loại lỗi này (xem phần Nâng cao về Mutex bên dưới); luôn dùng *sync.WaitGroup.

Mutex

Mutex (mutual exclusion lock — khóa loại trừ lẫn nhau) bảo vệ một critical section — đoạn code chạm vào trạng thái dùng chung — sao cho chỉ một goroutine được thực thi nó tại một thời điểm. Nó cung cấp Lock(), Unlock(), và (từ Go 1.18) TryLock(), cố gắng chiếm khóa mà không chặn.

Để thấy vì sao mutex quan trọng, hãy xem kiểu Counter này không có mutex:

type Counter struct {
value int
}

func (c *Counter) Update(n int, wg *sync.WaitGroup) {
defer wg.Done()
fmt.Printf("Adding %d to %d\n", n, c.value)
c.value += n
}

Nếu bốn goroutine gọi Update đồng thời với các giá trị 10, -5, 25 và 19, bạn có thể thấy output như thế này:

$ go run main.go
Adding 10 to 0
Adding -5 to 0
Adding 25 to 0
Adding 19 to 0
Result is 49

Mọi goroutine đều in ra "Adding X to 0" vì mỗi cái đọc c.value trước khi bất kỳ goroutine nào khác kịp ghi lại giá trị cập nhật của nó — tất cả đang chạy đua để đọc và ghi cùng một vị trí bộ nhớ mà không có sự phối hợp nào cả.

Đây là một data race thực sự, không chỉ là một sự lộn xộn về thứ tự. Đọc và ghi c.value từ nhiều goroutine mà không đồng bộ hóa là hành vi không xác định trong Go. Chạy go run -race main.go với đoạn code này sẽ báo cáo một data race trên c.value. Việc dòng "Result is 49" cuối cùng lại đúng về mặt số học ở đây thuần túy là may mắn — với số lượng goroutine khác, giá trị GOMAXPROCS khác, hay một ngày khác, bạn hoàn toàn có thể thấy một update bị mất, khi hai goroutine cùng đọc một giá trị cũ và một trong các phép cộng âm thầm biến mất. Đừng bao giờ coi output kiểu này là bằng chứng cho việc code có race "vẫn ổn trong thực tế".

Thêm một Mutex vào struct sẽ sửa được race này bằng cách buộc mỗi lần gọi Update phải chờ đến lượt:

type Counter struct {
m sync.Mutex
value int
}

func (c *Counter) Update(n int, wg *sync.WaitGroup) {
c.m.Lock()
defer wg.Done()
fmt.Printf("Adding %d to %d\n", n, c.value)
c.value += n
c.m.Unlock()
}

Unlock nên được defer, không nên gọi thủ công ở cuối hàm. Phiên bản trên gọi c.m.Unlock() là dòng cuối cùng của hàm, điều này chỉ đúng khi không có gì giữa Lock() và dòng đó từng panic hay return sớm. Nhưng code thực tế luôn phát triển: ai đó thêm một return sớm để kiểm tra hợp lệ, hoặc một lệnh gọi bên trong critical section bị panic, và giờ khóa không bao giờ được giải phóng — mọi goroutine khác gọi Lock() sau đó sẽ chặn mãi mãi. Dạng idiomatic và an toàn trước panic là defer việc unlock ngay sau khi chiếm khóa:

func (c *Counter) Update(n int, wg *sync.WaitGroup) {
c.m.Lock()
defer c.m.Unlock()
defer wg.Done()

fmt.Printf("Adding %d to %d\n", n, c.value)
c.value += n
}

Các lệnh defer chạy theo thứ tự LIFO (vào sau ra trước), nên wg.Done() chạy trước (báo hiệu hoàn thành) rồi c.m.Unlock() chạy ngay sau đó, giải phóng khóa khi hàm return — bất kể hàm đi theo đường nào để đến đó.

Với mutex, bốn lần update được tuần tự hóa — chỉ một goroutine chạm vào c.value tại một thời điểm — nên output trở nên hoàn toàn nhất quán:

$ go run main.go
Adding -5 to 0
Adding 19 to -5
Adding 25 to 14
Adding 10 to 39
Result is 49

Thứ tự chính xác mà các goroutine chiếm khóa vẫn không được đảm bảo (scheduler quyết định), nên bạn có thể thấy -5, 19, 25, 10 theo trình tự khác ở lần chạy khác. Điều được đảm bảo bây giờ là mỗi dòng "Adding X to Y" phản ánh đúng giá trị thực, cập nhật đầy đủ của counter tại thời điểm đó, và kết quả cuối cùng luôn là 49 — không phải nhờ may mắn, mà vì mutex khiến bốn phép cộng xảy ra tuần tự thay vì chồng lấp lên nhau.

Nâng cao về Mutex

Vì sao defer Unlock() an toàn trước panic. Một lệnh defer được đảm bảo sẽ chạy khi hàm chứa nó return, bất kể return đó xảy ra bình thường, qua một return sớm, hay do unwind từ một panic. Nếu bạn gọi Unlock() như một câu lệnh thường ở cuối hàm và một panic xảy ra trước khi dòng đó thực thi, khóa sẽ bị giữ mãi mãi — mọi goroutine khác đang chặn tại Lock() cho mutex đó sẽ deadlock vĩnh viễn, và panic thậm chí không cần nằm trong critical section của bạn; nó có thể nằm sâu vài lệnh gọi hàm. Defer Unlock() ngay sau Lock() đảm bảo khóa luôn được giải phóng, biến "chương trình panic" thành một lỗi có thể phục hồi, phạm vi rõ ràng, thay vì một deadlock toàn diện.

go vet bắt được việc sao chép lock. Một sync.Mutex (giống sync.WaitGroup, sync.RWMutex, và hầu hết các kiểu khác trong sync) không bao giờ được sao chép sau khi đã sử dụng. Việc sao chép nhân đôi trạng thái nội bộ theo dõi ai đang giữ khóa, nên bản sao và bản gốc có thể lệch pha nhau — một goroutine khóa bản sao trong khi goroutine khác khóa bản gốc, và cả hai đều tiến hành như thể chúng có quyền truy cập độc quyền. Bộ phân tích tĩnh của Go bắt được lỗi này tự động:

type Counter struct {
m sync.Mutex
value int
}

func launch(c Counter) { // ❌ sao chép Mutex nhúng bên trong
c.m.Lock()
defer c.m.Unlock()
}

Chạy go vet trên đoạn này sẽ báo ra thứ tương tự launch passes lock by value: Counter contains sync.Mutex. Cách sửa luôn là truyền một con trỏ — func launch(c *Counter) — để mọi goroutine dùng chung cùng một khóa bên dưới. Đây chính xác là lý do vì sao WaitGroup, Mutex và các kiểu tương tự luôn được truyền đi dưới dạng con trỏ, hoặc nhúng trong struct mà bản thân struct đó được truyền bằng con trỏ.

Các lỗi thường gặp với Mutex

func launch(m sync.Mutex) { // ❌ vet: passes lock by value
m.Lock()
defer m.Unlock()
}

Hãy dùng con trỏ thay vào đó: func launch(m *sync.Mutex).

func (c *Counter) Update(n int) {
c.m.Lock()
c.value += n
// ❌ quên Unlock — mọi lần gọi Lock() sau đó sẽ chặn mãi mãi
}

Bất kỳ đường code nào chiếm khóa mà không bao giờ giải phóng nó sẽ làm deadlock phần còn lại của chương trình ngay khi goroutine khác cố chiếm cùng khóa đó. defer c.m.Unlock() ngay sau Lock() loại bỏ hoàn toàn nhóm lỗi này.

RWMutex

Mutex đối xử với mọi truy cập — đọc hay ghi — như nhau: chỉ một goroutine tại một thời điểm, chấm hết. Nhưng rất nhiều dữ liệu trong thực tế được đọc nhiều hơn hẳn số lần được ghi, và nhiều reader không thực sự xung đột với nhau; chúng chỉ xung đột với một writer. sync.RWMutex mô hình hóa trực tiếp điều này: nó cho phép hoặc bất kỳ số lượng reader nào chạy đồng thời hoặc một writer duy nhất, không bao giờ cả hai cùng lúc. Bên cạnh cặp Lock()/Unlock() thông thường (dùng để ghi), nó thêm RLock()/RUnlock() để đọc.

func (c *Counter) GetValue(wg *sync.WaitGroup) {
defer wg.Done()

c.m.RLock()
defer c.m.RUnlock()
fmt.Println("Get value:", c.value)
time.Sleep(400 * time.Millisecond)
}

Nếu nhiều goroutine gọi GetValue gần như cùng lúc, tất cả chúng có thể giữ read lock đồng thời — không cái nào chặn cái nào, nên mọi dòng "Get value" có thể được in ra trước khi bất kỳ khoảng sleep 400ms nào kết thúc. So sánh với những gì sẽ xảy ra nếu dùng Mutex thường: mỗi lần gọi GetValue phải chờ lần gọi trước hoàn tất sleep trước khi có thể bắt đầu, biến bốn lần đọc nhanh thành một chuỗi chậm chạp, tuần tự hoàn toàn. RWMutex là công cụ đúng đắn bất cứ khi nào số lần đọc vượt trội hẳn số lần ghi và giá trị được bảo vệ được nhiều goroutine đọc đồng thời.

Cả Mutex lẫn RWMutex đều thỏa mãn interface sync.Locker (Lock() và Unlock()), đó là lý do vì sao sync.NewCond — sẽ nói tới ngay sau đây — có thể nhận cả hai loại.

Các lỗi thường gặp với RWMutex

func (c *Counter) Bad() {
c.m.RLock()
defer c.m.RUnlock()

c.m.Lock() // ❌ deadlock: goroutine này đã giữ RLock rồi
defer c.m.Unlock()
c.value++
}

RWMutex không phải là reentrant (không thể lồng lại chính nó): một goroutine đã giữ read lock không thể chiếm thêm write lock (hoặc read lock lần nữa) mà không giải phóng cái đầu tiên trước — làm vậy sẽ deadlock với chính nó, vì Lock() chờ tất cả reader hiện có, kể cả bên gọi, giải phóng trước. Nếu một hàm cần vừa đọc vừa ghi, hãy giải phóng read lock trước, hoặc tái cấu trúc để việc đọc và ghi diễn ra trong các lệnh gọi tách biệt.

Cond

sync.Cond là một biến điều kiện (condition variable): nó cho phép các goroutine đang chờ một điều kiện dùng chung nào đó ngủ một cách hiệu quả cho đến khi goroutine khác báo hiệu rằng điều kiện đã thay đổi, thay vì đốt CPU trong một vòng lặp polling. Bạn tạo nó bằng sync.NewCond(l Locker), truyền vào một Mutex hoặc RWMutex bảo vệ trạng thái dùng chung. Ba phương thức điều khiển nó: Wait() unlock khóa liên kết một cách atomic rồi tạm dừng goroutine đang gọi, sau đó lock lại trước khi trả về khi được đánh thức; Signal() đánh thức một goroutine đang chờ; và Broadcast() đánh thức tất cả chúng.

var done = false

func read(name string, c *sync.Cond) {
c.L.Lock()
for !done {
c.Wait()
}
fmt.Println(name, "starts reading")
c.L.Unlock()
}

func write(name string, c *sync.Cond) {
fmt.Println(name, "starts writing")
time.Sleep(time.Second)

c.L.Lock()
done = true
c.L.Unlock()

fmt.Println(name, "wakes all")
c.Broadcast()
}

Chú ý rằng read kiểm tra done bên trong một vòng lặp for, không phải một if đơn thuần. Điều này quan trọng vì Wait() trong một số trường hợp hiếm có thể trả về mà điều kiện chưa thực sự đúng (gọi là "spurious wakeup" — thức giấc giả), và vì nhiều goroutine có thể chạy đua để quan sát điều kiện sau khi được đánh thức. Kiểm tra lại điều kiện trong vòng lặp sau mỗi lần gọi Wait() là pattern chuẩn, an toàn cho condition variable ở mọi ngôn ngữ có khái niệm này, không riêng gì Go. Cũng lưu ý rằng mọi truy cập vào done — đọc nó trong điều kiện vòng lặp và gán nó trong write — đều diễn ra khi đang giữ c.L; chính khóa đó ngăn việc kiểm tra điều kiện và việc thay đổi điều kiện chạy đua với nhau.

Nếu ba reader gọi read và một writer gọi write, cả ba reader đều chặn bên trong c.Wait() cho đến khi writer hoàn thành công việc (giả lập), đổi done thành true, và gọi Broadcast(). Điều đó đánh thức cả ba reader cùng lúc; mỗi cái chiếm lại khóa, thấy done giờ là true, thoát khỏi vòng lặp, in ra rằng nó bắt đầu đọc, rồi giải phóng khóa.

Nâng cao về Cond

Cond so với channel. Hầu hết code Go idiomatic sẽ dùng channel trước khi nghĩ tới sync.Cond — việc đóng một channel hay gửi tín hiệu trên channel có thể diễn đạt "có gì đó đã xảy ra" tốt không kém, và channel kết hợp tự nhiên với select và hủy bằng context. Cond chứng tỏ giá trị trong một trường hợp hẹp hơn: khi bạn cần đánh thức nhiều goroutine đang chờ một sự thay đổi trên trạng thái dùng chung được bảo vệ bởi mutex, và việc đóng channel đơn thuần (vốn là cách tốt để broadcast một sự kiện "xong" duy nhất, vĩnh viễn) không phù hợp vì điều kiện có thể trở thành đúng, sai, rồi lại đúng nhiều lần trong vòng đời chương trình. Nếu bạn thấy mình đang tự xây dựng một cơ chế broadcast thủ công từ channel và mutex, đó thường là dấu hiệu cho thấy sync.Cond mới là công cụ trực tiếp hơn cho công việc này.

Once

sync.Once đảm bảo một hàm chạy đúng một lần, bất kể có bao nhiêu goroutine gọi nó hay chúng gọi bao nhiêu lần. Phương thức duy nhất của nó, Do(f), chạy f ở lần gọi đầu tiên trên tất cả goroutine và không làm gì ở mọi lần gọi sau đó — kể cả những lần xảy ra đồng thời với lần gọi đầu tiên.

var once sync.Once

Nếu 100 goroutine đều gọi once.Do(increment) gần như cùng lúc, đúng một trong số chúng thực sự thực thi increment; 99 lần gọi Do còn lại trả về ngay mà không chạy hàm, và bất kỳ goroutine nào gọi Do trong khi lần gọi đầu tiên vẫn đang chạy sẽ chặn cho đến khi lần gọi đầu tiên đó hoàn tất rồi mới trả về. Kết quả cuối cùng, sau khi cả 100 goroutine hoàn thành, là counter dùng chung đã được tăng đúng một lần — giá trị của nó là 1, không bao giờ nhiều hơn.

Nâng cao về Once

Khởi tạo singleton lười (lazy). Cách dùng thực tế phổ biến nhất của sync.Once là khởi tạo một cách lười biếng thứ gì đó tốn kém — kết nối database, file config đã parse, bảng tra cứu tính sẵn — vào lần đầu tiên cần đến nó, một cách an toàn, kể cả khi nhiều goroutine cùng cần đến nó đồng thời:

var (
instance *Client
once sync.Once
)

func GetClient() *Client {
once.Do(func() {
instance = newExpensiveClient()
})
return instance
}

Dù bao nhiêu goroutine gọi GetClient() đồng thời, newExpensiveClient() chỉ chạy đúng một lần, và mọi bên gọi — kể cả những bên tới trong lúc khởi tạo vẫn đang diễn ra — đều nhận được cùng một instance đã khởi tạo đầy đủ.

sync.OnceFunc và sync.OnceValue (Go 1.21+). Go 1.21 bổ sung một bộ hàm tiện ích nhỏ bọc sẵn chính pattern này, để bạn không cần khai báo biến sync.Once cấp package nữa. sync.OnceFunc(f) trả về một hàm mới gọi f tối đa một lần:

var initialize = sync.OnceFunc(func() {
fmt.Println("initializing...")
})

sync.OnceValue(f) cùng ý tưởng đó cho các hàm trả về giá trị — mọi lần gọi sau lần đầu trả về kết quả đã lưu thay vì tính lại:

var getConfig = sync.OnceValue(func() *Config {
return loadConfig()
})

Những hàm này tiện lợi hơn hẳn so với việc tự bọc once.Do thủ công cho trường hợp phổ biến là "tính giá trị này một cách lười, đúng một lần, rồi cache kết quả lại".

Pool

sync.Pool là một pool các đối tượng tạm thời an toàn khi dùng đồng thời, được thiết kế để giảm áp lực lên garbage collector khi bạn cấp phát và loại bỏ nhiều đối tượng ngắn hạn cùng loại. Thay vì cấp phát một đối tượng mới mỗi khi cần, bạn Get() một cái từ pool — tái sử dụng một đối tượng có sẵn nếu có — rồi Put() nó trở lại khi xong việc, để lần gọi tiếp theo có thể tái sử dụng thay vì kích hoạt một lần cấp phát mới.

var pool = sync.Pool{
New: func() any {
fmt.Println("Creating a new person...")
return &Person{}
},
}

Hàm New được Get() gọi chỉ khi pool đang trống; nó là thứ tạo ra một giá trị khi không có gì để tái sử dụng. Vì Get() trả về any, bạn cần ép kiểu (type assertion) để lấy lại kiểu cụ thể: pool.Get().(*Person). Một chuỗi thao tác điển hình — Get, dùng, Put, Get lần nữa — cho thấy việc tái sử dụng diễn ra: lần Get() đầu tiên thấy pool trống và gọi New, in ra "Creating a new person..."; sau khi bạn Put() đối tượng trở lại, lần Get() thứ hai tìm thấy nó trong pool và trả về chính đối tượng đó mà không gọi New lần nữa, nên không có gì được in ra ở lần thứ hai.

Nâng cao về Pool

Pool có thể bị làm trống bất cứ lúc nào. sync.Pool rõ ràng không phải là một cache với khả năng giữ lại dữ liệu có thể đoán trước: runtime hoàn toàn được phép loại bỏ các mục khỏi pool trong lúc garbage collection, không báo trước, bất cứ khi nào nó muốn. Đây là một lựa chọn thiết kế có chủ ý — nó có nghĩa là Pool không bao giờ có thể gây rò rỉ bộ nhớ bằng cách giữ đối tượng mãi mãi, nhưng nó cũng có nghĩa là bạn không bao giờ có thể chắc chắn Get() sẽ trả về một đối tượng đã từng được Put() trước đó; code của bạn phải hoạt động đúng bất kể New được kích hoạt hay một đối tượng tái chế quay trở lại.

Reset đối tượng trước khi tái sử dụng. Vì Pool trả lại chính đối tượng nó được cấp, mọi trạng thái còn sót lại từ lần dùng trước vẫn còn đó trừ khi bạn tự xóa nó:

p := pool.Get().(*Person)
p.Name = "Alice"
pool.Put(p)

p2 := pool.Get().(*Person)
fmt.Println(p2.Name) // "Alice" — dữ liệu cũ từ người dùng trước!

Pool không hề biết "sạch" nghĩa là gì đối với kiểu dữ liệu của bạn, nên hoàn toàn là trách nhiệm của bạn để reset các trường về giá trị zero (hoặc làm sạch đối tượng theo cách khác) ngay trước khi Put nó trở lại, hoặc ngay sau khi Get nó, trước khi giao nó cho một bên gọi mới.

Chỉ đáng dùng cho cấp phát tần suất cao. Bản thân sync.Pool không miễn phí — nó có đồng bộ hóa nội bộ và cache cục bộ theo từng P mang chi phí thực sự, và với các đối tượng nhỏ, ít được cấp phát, chi phí đó dễ dàng vượt quá chi phí của việc cứ để garbage collector tự làm việc của nó. Hãy dùng Pool khi profiling cho thấy việc cấp phát và áp lực GC từ một loại đối tượng cụ thể, nóng, được luân chuyển thường xuyên là một điểm nghẽn thực sự (buffer trong một HTTP server bận rộn, struct tạm trong vòng lặp parsing chặt chẽ), chứ không phải như một tối ưu hóa theo phản xạ cho mọi struct trong chương trình. Và giống các kiểu khác trong sync, một Pool không bao giờ được sao chép sau khi đã dùng lần đầu.

sync.Map

sync.Map là một cài đặt map an toàn khi dùng đồng thời bởi nhiều goroutine mà không cần khóa bên ngoài, với các phương thức như Store, Load, Delete, LoadAndDelete, LoadOrStore, và Range thay cho cú pháp m[key] = value / v := m[key] thông thường bạn dùng với một map bình thường.

Rất dễ bị cám dỗ dùng sync.Map bất cứ khi nào cần một map an toàn khi đồng thời, nhưng tài liệu chính thức của Go nói rõ rằng hầu hết code nên dùng một map bình thường được bảo vệ bởi một Mutex hoặc RWMutex riêng — nó đơn giản hơn để suy luận và, với các pattern truy cập điển hình, thường cũng nhanh hơn. sync.Map được tối ưu hóa đặc biệt cho hai kịch bản hẹp hơn: (1) một cache được ghi một lần rồi đọc nhiều lần bởi nhiều goroutine, nơi tập hợp các key phần lớn ổn định, và (2) các workload mà mỗi goroutine đọc và ghi một tập hợp key tách biệt, nên chúng hiếm khi tranh chấp với nhau. Ngoài hai trường hợp đó, phần sổ sách nội bộ mà sync.Map phải làm để tránh khóa thực ra có thể khiến nó chậm hơn một map thường được bảo vệ bằng mutex.

var m sync.Map

func main() {
var wg sync.WaitGroup
wg.Add(10)

for i := 0; i < 5; i++ {
go func(n int) {
defer wg.Done()
m.Store(n, n*n)
}(i)
}

for i := 0; i < 5; i++ {
go func(n int) {
defer wg.Done()
v, ok := m.Load(n)
fmt.Println(n, v, ok)
}(i)
}

wg.Wait()
}

Năm goroutine writer gọi Store và năm goroutine reader gọi Load cho cùng năm key, tất cả được khởi chạy đồng thời. Mỗi lần gọi Store và Load riêng lẻ đều an toàn — sync.Map đảm bảo bạn sẽ không bao giờ crash hay làm hỏng cấu trúc nội bộ của map — nhưng không có đảm bảo nào về thứ tự các writer và reader chạy so với nhau. Một goroutine reader cho key n có thể chạy trước khi goroutine writer tương ứng cho chính key đó gọi Store, trong trường hợp đó Load trả về (nil, false): giá trị đơn giản là chưa có ở đó. Đây là điều bình thường, không phải bug — nếu logic của bạn cần một giá trị chắc chắn tồn tại trước khi được đọc, bạn cần tự phối hợp thêm (một WaitGroup, một channel, hoặc tái cấu trúc để việc ghi xảy ra trước việc đọc) chồng lên trên sự an toàn theo từng thao tác của sync.Map. Một lưu ý nữa: Range không cung cấp một snapshot nhất quán của map — nếu một Store hoặc Delete xảy ra đồng thời với một lệnh gọi Range, việc duyệt có thể phản ánh hoặc không phản ánh thay đổi đó.

Các lỗi thường gặp với map đồng thời

m := make(map[string]int)

for i := 0; i < 10; i++ {
go func(n int) {
m[fmt.Sprint(n)] = n // ❌ fatal error: concurrent map writes
}(i)
}

Một map thường của Go không có bất kỳ tính an toàn đồng thời tích hợp nào cả — thậm chí không chỉ đơn thuần là một data race chỉ tạo ra kết quả sai, mà là một kiểm tra ở runtime chủ động làm crash chương trình với fatal error: concurrent map writes (hoặc concurrent map read and map write) ngay khi nó phát hiện truy cập đồng thời. Đây cũng không phải một panic mà bạn có thể recover() — nó chấm dứt luôn tiến trình. Hãy bảo vệ một map thường bằng Mutex/RWMutex, hoặc chuyển sang sync.Map nếu pattern truy cập của bạn phù hợp với một trong hai trường hợp ở trên.

atomic

Package sync/atomic cung cấp các thao tác atomic cấp thấp — Add, Load, Store, Swap, CompareAndSwap — đọc-sửa-ghi một giá trị đơn (một số nguyên hoặc con trỏ) như một bước duy nhất không thể chia nhỏ ở cấp phần cứng, không có khả năng goroutine khác quan sát thấy một update dở dang. Chúng là một lựa chọn nhẹ hơn so với Mutex khi thứ duy nhất bạn cần bảo vệ là một word bộ nhớ đơn.

func add(w *sync.WaitGroup, num *int32) {
defer w.Done()
atomic.AddInt32(num, 1)
}

Khởi chạy 1000 goroutine, mỗi cái gọi add trên một counter *int32 dùng chung, với một WaitGroup để chờ tất cả hoàn thành, đảm bảo kết quả cuối cùng chính xác là 1000 — mọi lần tăng đều được ghi nhận, không bị mất update nào, vì atomic.AddInt32 không bao giờ để hai goroutine xen kẽ chu trình đọc-sửa-ghi của chúng trên cùng một vị trí bộ nhớ.

Nâng cao về atomic

Atomic có kiểu (typed atomics) từ Go 1.19. API atomic nguyên bản — các hàm như atomic.AddInt32(&x, 1) — nhận một con trỏ thô, dễ bị dùng sai: không có gì ngăn bạn cũng đọc hoặc ghi x trực tiếp bằng một x++ thường ở đâu đó khác trong code, âm thầm tái tạo lại chính xác cái race mà lệnh gọi atomic được cho là phải ngăn chặn. Go 1.19 giới thiệu các kiểu bọc atomic có kiểu — atomic.Int32, atomic.Int64, atomic.Uint32, atomic.Uint64, atomic.Bool, atomic.Pointer[T], và nhiều kiểu khác — giữ giá trị bên trong và chỉ để lộ nó thông qua các phương thức atomic:

var counter atomic.Int32

func add(w *sync.WaitGroup) {
defer w.Done()
counter.Add(1)
}

func main() {
var wg sync.WaitGroup
for i := 0; i < 1000; i++ {
wg.Add(1)
go add(&wg)
}
wg.Wait()
fmt.Println("Result:", counter.Load())
}

Vì counter là một giá trị kiểu atomic.Int32, không phải một int32 thường, nên không có biến thô nào nằm lơ lửng để lỡ tay truy cập không atomic — mọi lần đọc đi qua .Load() và mọi lần ghi đi qua .Add(), .Store(), hoặc .CompareAndSwap(). Đây là phong cách được ưu tiên cho code mới; chỉ dùng các hàm dựa trên con trỏ kiểu cũ khi bạn buộc phải làm việc với một trường int32/int64 thường mà không thể thay đổi (ví dụ, một trường nhúng trong struct có layout cố định vì lý do khác).

Đánh đổi giữa atomic và mutex. Atomic là lựa chọn đúng khi toàn bộ phần trạng thái dùng chung bạn cần bảo vệ là một word đơn — một counter, một cờ (flag), một lần hoán đổi con trỏ — vì chúng tránh được chi phí chiếm và giải phóng khóa và không bao giờ có nguy cơ deadlock. Nhưng ngay khi bạn cần cập nhật nhiều hơn một giá trị liên quan cùng lúc và giữ chúng nhất quán với nhau (ví dụ, một struct có balance và một timestamp lastUpdated phải luôn khớp nhau), atomic không còn đủ nữa: không có cách nào cập nhật atomic hai biến atomic độc lập như một đơn vị duy nhất, và cố phối hợp chúng bằng các thao tác atomic riêng lẻ sẽ mở ra một khoảng hở mà goroutine khác có thể quan sát thấy một tổ hợp không nhất quán. Đó chính xác là tình huống mà Mutex được sinh ra để giải quyết — nó bảo vệ một bất biến trên nhiều trường, không chỉ một giá trị đơn lẻ. Quy tắc chung: một counter hay flag đơn, hãy dùng atomic; bất cứ điều gì liên quan đến nhiều trường phải thay đổi cùng nhau, hãy dùng Mutex.


#Backend#Go Advance
DUKE
Written byDUKEView all stories by DUKE

Bình luận