sync.WaitGroup, Mutex và errgroup: điều phối goroutine trong Go
1. Tổng quan
Chạy một goroutine trong Go rất dễ, chỉ cần thêm từ khóa go. Phần khó hơn là biết khi nào các goroutine xong việc, bảo vệ dữ liệu dùng chung và xử lý lỗi khi một goroutine thất bại.
Bài này đi qua ba công cụ cho ba việc đó: sync.WaitGroup, sync.Mutex và errgroup.Group. Đọc xong, bạn sẽ biết chọn công cụ nào cho từng tình huống, cách viết test chạy với -race và các lỗi hay gặp cần tránh.
Các ví dụ dùng Go 1.22 trở lên. Từ phiên bản này, mỗi vòng lặp for tạo biến mới cho từng lần lặp, nên bạn không cần viết id := id nữa.
2. Bài toán mẫu
Giả sử bạn có một danh sách mã đơn hàng (Order). Mỗi đơn cần gọi một service chậm, mất khoảng 100ms. Nếu gọi tuần tự 10 đơn, bạn mất khoảng 1 giây. Nếu gọi song song, bạn chỉ mất khoảng 100ms.
Đây là hàm giả lập việc gọi service:
package main
import (
"fmt"
"time"
)
func fetchOrder(id int) string {
time.Sleep(100 * time.Millisecond)
return fmt.Sprintf("order-%d", id)
}
Nếu bạn chỉ viết go fetchOrder(id) trong vòng lặp, hàm main sẽ kết thúc trước khi các goroutine chạy xong. Chương trình thoát và kết quả bị mất. Mục tiếp theo xử lý vấn đề này.
3. sync.WaitGroup: chờ một nhóm goroutine
3.1. Cách dùng cơ bản
WaitGroup là một bộ đếm. Bạn tăng bộ đếm bằng Add, giảm bằng Done, và Wait chặn cho tới khi bộ đếm về 0.
func main() {
var wg sync.WaitGroup
ids := []int{1, 2, 3}
for _, id := range ids {
wg.Add(1)
go func() {
defer wg.Done()
fmt.Println("fetched", fetchOrder(id))
}()
}
wg.Wait()
fmt.Println("all done")
}
Code trên hoạt động như sau:
wg.Add(1)được gọi trước khi khởi chạy goroutine, trong goroutine cha.defer wg.Done()bảo đảm bộ đếm luôn giảm, kể cả khi goroutine kết thúc sớm hoặc panic.wg.Wait()chặnmaincho tới khi cả ba goroutine gọiDone.
Kết quả có thể như sau. Thứ tự các dòng fetched thay đổi giữa các lần chạy:
fetched order-2
fetched order-1
fetched order-3
all done
3.2. WaitGroup.Go trong Go 1.25
Từ Go 1.25, WaitGroup có thêm phương thức Go. Phương thức này gộp Add, go và Done thành một lời gọi:
var wg sync.WaitGroup
for _, id := range ids {
wg.Go(func() {
fmt.Println("fetched", fetchOrder(id))
})
}
wg.Wait()
Viết như vậy ngắn hơn và tránh được hai lỗi phổ biến: quên gọi Done, hoặc gọi Add bên trong goroutine.
3.3. Thu kết quả mà không cần lock
Bạn thường cần giữ lại kết quả, không chỉ in ra màn hình. Một cách an toàn là tạo slice có sẵn độ dài, rồi để mỗi goroutine ghi vào một chỉ số riêng:
results := make([]string, len(ids))
var wg sync.WaitGroup
for i, id := range ids {
wg.Go(func() {
results[i] = fetchOrder(id)
})
}
wg.Wait()
fmt.Println(results)
[order-1 order-2 order-3]
Không có goroutine nào ghi chung một phần tử, nên ở đây không có data race. wg.Wait() còn tạo quan hệ happens-before. Vì vậy, sau Wait, goroutine cha đọc được đầy đủ dữ liệu mà các goroutine con đã ghi.
Lưu ý: Không gọi
wg.Add(1)bên trong goroutine. Nếu làm vậy,Wait()có thể chạy trướcAddvà trả về ngay khi công việc chưa xong.
4. sync.Mutex: bảo vệ dữ liệu dùng chung
4.1. Data race trông như thế nào
Bây giờ giả sử bạn cần cộng dồn doanh thu theo từng khách hàng (Customer), và nhiều goroutine cùng cập nhật một map:
revenue := map[string]float64{}
var wg sync.WaitGroup
for i := 0; i < 1000; i++ {
wg.Go(func() {
revenue["alice"] += 10
})
}
wg.Wait()
Chương trình này có thể crash với lỗi sau:
fatal error: concurrent map writes
Trường hợp chỉ dùng một biến int còn nguy hiểm hơn. Chương trình không crash mà âm thầm cho ra kết quả sai, ví dụ 9730 thay vì 10000. Để phát hiện loại lỗi này, hãy chạy với race detector:
go run -race main.go
WARNING: DATA RACE
Write at 0x00c000012345 by goroutine 8:
...
4.2. Sửa bằng Mutex
sync.Mutex bảo đảm tại mỗi thời điểm chỉ có một goroutine chạy đoạn code nằm giữa Lock và Unlock. Cách làm phổ biến là gói mutex chung với dữ liệu mà nó bảo vệ, trong cùng một struct:
package shop
import "sync"
type RevenueTracker struct {
mu sync.Mutex
total map[string]float64
}
func NewRevenueTracker() *RevenueTracker {
return &RevenueTracker{total: make(map[string]float64)}
}
func (r *RevenueTracker) Add(customer string, amount float64) {
r.mu.Lock()
defer r.mu.Unlock()
r.total[customer] += amount
}
func (r *RevenueTracker) Total(customer string) float64 {
r.mu.Lock()
defer r.mu.Unlock()
return r.total[customer]
}
Một vài điểm cần chú ý:
- Mọi truy cập vào
total, kể cả đọc, đều phải đi qua lock. defer r.mu.Unlock()giúp lock luôn được nhả, kể cả khi hàm có nhiều nhánhreturn.- Các method dùng receiver con trỏ (
*RevenueTracker). Nếu dùng receiver giá trị, mỗi lần gọi sẽ copy cả mutex, và lock đó không còn bảo vệ được gì.
Khi lượng đọc nhiều hơn hẳn lượng ghi, bạn có thể dùng sync.RWMutex. Phương thức đọc gọi RLock/RUnlock, phương thức ghi gọi Lock/Unlock. Nhiều goroutine có thể đọc cùng lúc, còn khi ghi thì chỉ một goroutine được giữ lock.
Lưu ý: Không copy một struct chứa
Mutexsau khi đã dùng nó. Lệnhgo vetsẽ cảnh báo trường hợp này với thông báocopylocks. Ngoài ra, Go không có reentrant lock: nếu một goroutine gọiLock()hai lần trên cùng một mutex, nó sẽ deadlock.
4.3. Khi nào không cần Mutex
Với một bộ đếm đơn giản, sync/atomic gọn và nhanh hơn:
var processed atomic.Int64
processed.Add(1)
fmt.Println(processed.Load())
Khi dữ liệu cần đi từ goroutine này sang goroutine khác theo dạng luồng, channel thường hợp hơn. Mutex phù hợp nhất khi bạn có một trạng thái dùng chung, như map hoặc struct, và nhiều goroutine cần đọc hoặc ghi vào đó.
5. errgroup: WaitGroup có xử lý lỗi
WaitGroup không biết gì về lỗi. Nếu fetchOrder có thể trả về error, bạn phải tự gom lỗi bằng mutex hoặc channel, và tự hủy các goroutine còn lại. Gói errgroup làm sẵn những việc này.
5.1. Thêm dependency
errgroup nằm trong module golang.org/x/sync, không thuộc thư viện chuẩn:
go get golang.org/x/sync
5.2. Gom lỗi và hủy bằng context
Bước đầu tiên là viết lại fetchOrder để hàm nhận context.Context và có thể trả về lỗi:
package shop
import (
"context"
"fmt"
"time"
"golang.org/x/sync/errgroup"
)
type Order struct {
ID int
Customer string
}
func fetchOrder(ctx context.Context, id int) (Order, error) {
if id < 0 {
return Order{}, fmt.Errorf("invalid order id %d", id)
}
select {
case <-time.After(100 * time.Millisecond):
return Order{ID: id, Customer: "alice"}, nil
case <-ctx.Done():
return Order{}, ctx.Err()
}
}
func LoadOrders(ctx context.Context, ids []int) ([]Order, error) {
g, ctx := errgroup.WithContext(ctx)
g.SetLimit(3)
orders := make([]Order, len(ids))
for i, id := range ids {
g.Go(func() error {
o, err := fetchOrder(ctx, id)
if err != nil {
return err
}
orders[i] = o
return nil
})
}
if err := g.Wait(); err != nil {
return nil, err
}
return orders, nil
}
Code trên hoạt động như sau:
errgroup.WithContext(ctx)tạo một group và một context con. Ngay khi một goroutine trả về lỗi đầu tiên, context con bị hủy.g.SetLimit(3)cho phép tối đa 3 goroutine chạy cùng lúc. Lần gọig.Gotiếp theo sẽ chặn cho tới khi có chỗ trống. Nhờ vậy bạn không làm quá tải service phía sau khi danh sách có hàng nghìn mã đơn.g.Gonhận một hàm có kiểufunc() error, và bạn không cần gọiAdd/Done.g.Wait()chờ tất cả goroutine kết thúc rồi trả về lỗi đầu tiên. Các lỗi xảy ra sau đó bị bỏ qua.fetchOrderlắng nghectx.Done(), nên các goroutine còn lại dừng sớm thay vì chạy hết 100ms.
Gọi hàm với một mã đơn không hợp lệ:
_, err := shop.LoadOrders(context.Background(), []int{1, 2, -5, 4})
fmt.Println(err)
invalid order id -5
Nếu bạn cần chạy thử mà không chặn khi đã đạt giới hạn, hãy dùng g.TryGo(f). Hàm này trả về false thay vì chờ.
Lưu ý: Việc hủy context chỉ có tác dụng khi code bên trong goroutine chủ động kiểm tra
ctx. NếufetchOrderdùngtime.Sleepmà không lắng nghe context, goroutine vẫn chạy hết thời gian dù nhóm đã thất bại.
6. Viết test với -race
Go không có JUnit hay AssertJ. Bạn dùng gói testing của thư viện chuẩn. Điều quan trọng nhất là luôn chạy test với cờ -race, vì nhiều lỗi concurrency chỉ lộ ra khi có race detector.
package shop
import (
"context"
"sync"
"testing"
)
func TestRevenueTracker_ConcurrentAdd(t *testing.T) {
tracker := NewRevenueTracker()
var wg sync.WaitGroup
for i := 0; i < 1000; i++ {
wg.Go(func() {
tracker.Add("alice", 10)
})
}
wg.Wait()
if got := tracker.Total("alice"); got != 10000 {
t.Fatalf("Total(alice) = %v, want 10000", got)
}
}
func TestLoadOrders_ReturnsAllOrders(t *testing.T) {
orders, err := LoadOrders(context.Background(), []int{1, 2, 3})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
for i, o := range orders {
if o.ID != i+1 {
t.Errorf("orders[%d].ID = %d, want %d", i, o.ID, i+1)
}
}
}
func TestLoadOrders_StopsOnFirstError(t *testing.T) {
orders, err := LoadOrders(context.Background(), []int{1, -1, 3})
if err == nil {
t.Fatal("expected error, got nil")
}
if orders != nil {
t.Errorf("expected nil orders, got %v", orders)
}
}
Chạy test:
go test -race ./...
ok example.com/shop 0.412s
Hãy thử xóa hai dòng Lock/Unlock trong Add rồi chạy lại. Test sẽ thất bại với WARNING: DATA RACE hoặc crash vì concurrent map writes. Đây là cách nhanh để kiểm tra rằng mutex thật sự cần thiết.
Test thứ hai cũng xác nhận thứ tự kết quả được giữ đúng: orders[i] khớp với ids[i], dù các goroutine kết thúc theo thứ tự bất kỳ.
7. Lỗi hay gặp và cách chọn công cụ
Các lỗi thường gặp nhất:
- Gọi
Addtrong goroutine.Waitcó thể trả về quá sớm. Hãy gọiAddtrước lệnhgo, hoặc dùngwg.Go. - Truyền
WaitGrouphayMutextheo giá trị. Hàm nhận được một bản copy, và bộ đếm hoặc lock gốc không thay đổi. Hãy truyền con trỏ*sync.WaitGroup, hoặc dùng closure. - Giữ lock trong lúc gọi I/O. Ví dụ gọi HTTP bên trong
Lock. Mọi goroutine khác phải xếp hàng chờ, và chương trình mất tác dụng của việc chạy song song. Hãy chỉ giữ lock trong đoạn code ngắn nhất có thể. - Dùng
errgroup.Grouprỗng (var g errgroup.Group) nhưng vẫn mong context bị hủy. ChỉWithContextmới tạo context có khả năng hủy. - Bỏ qua giá trị trả về của
g.Wait(). Khi đó lỗi biến mất mà không ai biết. - Khởi chạy không giới hạn goroutine cho hàng chục nghìn request ra ngoài. Hãy dùng
SetLimithoặc một worker pool.
Cách chọn công cụ:
| Tình huống | Công cụ |
|---|---|
| Chờ nhiều tác vụ không trả lỗi | sync.WaitGroup |
| Nhiều goroutine sửa chung map hoặc struct | sync.Mutex / sync.RWMutex |
| Bộ đếm hoặc cờ đơn giản | sync/atomic |
| Tác vụ có thể lỗi, cần hủy sớm, giới hạn số goroutine | errgroup |
Lưu ý:
errgroupchỉ giữ lỗi đầu tiên. Nếu bạn cần biết tất cả các lỗi, ví dụ để báo cáo đơn nào hỏng, hãy để mỗi goroutine ghi lỗi vàoerrs[i], rồi gộp lại bằngerrors.Join(errs...)sau khi gọiWait.
8. Kết luận
sync.WaitGroup trả lời câu hỏi “khi nào xong?”. sync.Mutex trả lời câu hỏi “ai được sửa dữ liệu lúc này?”. errgroup kết hợp việc chờ với xử lý lỗi, hủy bằng context và giới hạn số goroutine chạy cùng lúc.
Trong code thực tế, khi gọi nhiều service song song, errgroup.WithContext kèm SetLimit thường là lựa chọn mặc định hợp lý. Dù dùng công cụ nào, hãy luôn chạy go test -race. Đây là cách rẻ nhất để bắt data race trước khi chúng lên production.