Ba công cụ phối hợp trong gói sync mà bạn sẽ gõ hằng ngày. Hình dung chúng như bộ đồ nghề của một trưởng đoàn: một cái để điểm danh cho đủ người rồi mới đi, một cái cổng để người đầu tiên mở cho cả nhóm, và một giá đồ thuê ai cũng mượn rồi trả.

WaitGroup: chờ nhóm goroutine

var wg sync.WaitGroup
for i := 0; i < n; i++ {
	wg.Add(1)
	go func() {
		defer wg.Done()
		lam()
	}()
}
wg.Wait()

Ba method: Add(n) tăng bộ đếm, Done() giảm một, Wait() chặn tới khi về 0 — đúng kiểu điểm danh: đếm đủ số người trước khi thả họ đi, mỗi người về báo một tiếng, và không nhổ trại cho tới khi đủ mặt.

Bốn quy tắc, và cả bốn đều là nguồn lỗi thật:

Add phải gọi TRƯỚC go, không phải bên trong goroutine. Gọi bên trong thì Wait() có thể chạy trước khi bộ đếm kịp tăng, và nó trả về ngay lập tức — trưởng đoàn điểm danh khi sổ còn ghi số 0.

Done luôn trong defer. Panic giữa chừng mà không Done là Wait() chặn vĩnh viễn.

Truyền *WaitGroup, không truyền giá trị. Sao chép WaitGroup là bản sao có bộ đếm riêng, và go vet bắt được lỗi này.

Bộ đếm âm là panic: sync: negative WaitGroup counter — nghĩa là bạn gọi Done nhiều hơn Add, điểm danh về một người chưa từng ghi tên đi.

Từ Go 1.20 có context kết hợp sẵn qua errgroup — bài 41 sẽ nói, và nó thường tốt hơn WaitGroup trần vì xử lý được lỗi.

Once: chạy đúng một lần

  100 goroutine cùng gọi Once.Do -> n=1
var once sync.Once
once.Do(func() { khoiTao() })

An toàn với mọi số goroutine. Do chặn cho tới khi hàm bên trong chạy xong, nên goroutine thứ hai không đi tiếp khi khởi tạo còn dở — người đầu tiên tới mở cổng, cả nhóm đứng chờ ở cổng cho tới khi nó mở hẳn rồi mới đi qua. Đây là điểm khác biệt quan trọng so với một cờ boolean tự viết.

Dùng cho khởi tạo lười: kết nối, biên dịch regex tốn kém, nạp cấu hình.

Ba cái bẫy:

Do chạy đúng một lần kể cả khi hàm bên trong hỏng. Không có cơ chế thử lại — ổ khoá kẹt thì cũng coi như đã mở, không ai thử lại. Cần thử lại thì phải tự viết bằng Mutex.

Không lấy lại được lỗi. Mẫu chuẩn là cất vào biến ngoài:

var (
	once sync.Once
	db   *sql.DB
	err  error
)
func Lay() (*sql.DB, error) {
	once.Do(func() { db, err = sql.Open(...) })
	return db, err
}

Không sao chép Once sau khi dùng — cùng lý do với Mutex.

Từ Go 1.21 có sync.OnceValue và sync.OnceValues gói sẵn mẫu trên:

var layDB = sync.OnceValues(func() (*sql.DB, error) { return sql.Open(...) })

Pool: tái dùng bộ nhớ

  Pool tái dùng cùng đối tượng: true
var p = sync.Pool{
	New: func() any { return new(bytes.Buffer) },
}

b := p.Get().(*bytes.Buffer)
b.Reset()                    // BẮT BUỘC: đối tượng cũ còn dữ liệu
defer p.Put(b)

Pool giảm áp lực lên bộ thu gom rác bằng cách tái dùng đối tượng thay vì cấp mới — giá đồ thuê, mượn rồi trả thay vì mua mới mỗi lần.

Ba điều phải biết trước khi dùng:

Bộ thu gom rác có thể dọn sạch Pool bất cứ lúc nào. Nó không phải cache — không bao giờ dựa vào việc đối tượng còn đó; cửa hàng có thể thu cả giá đồ bất cứ lúc nào.

Phải Reset sau khi Get. Đối tượng lấy ra mang theo dữ liệu của lần dùng trước. Quên Reset là rò rỉ dữ liệu giữa các request — mượn phải bộ đồ người trước chưa lau, đúng loại lỗi bảo mật ở bài 78 sê-ri Java.

Chỉ dùng khi đã đo. Pool chỉ đáng khi bạn cấp phát cùng loại đối tượng rất nhiều lần trong đường chạy nóng. Với mã thường, nó thêm phức tạp mà không đổi lấy gì.

Ví dụ điển hình trong thư viện chuẩn: fmt dùng Pool cho bộ đệm định dạng, và net/http dùng cho bộ đệm đọc ghi.

Vài công cụ khác trong sync

sync.RWMutex — bài 34 đã đo: chỉ thắng khi phần đọc đủ dài.

sync.Cond — biến điều kiện, tương đương wait/notify của Java. Rất hiếm khi cần trong Go vì channel đã giải cùng bài toán gọn hơn. Nếu thấy mình cần Cond, hãy thử thiết kế lại bằng channel trước.

sync/atomic — bài 40 sẽ nói kỹ.

Nếu chỉ chạy một thứ sau bài này, chạy đúng cái lỗi điểm-danh-sổ-rỗng trong ba mươi giây:

var wg sync.WaitGroup
for i := 0; i < 3; i++ {
	go func() {
		wg.Add(1)          // SAI: Add bên trong goroutine
		defer wg.Done()
		time.Sleep(10 * time.Millisecond)
	}()
}
wg.Wait()
fmt.Println("xong")

In ra xong ngay lập tức, không chờ gì cả — vì Wait() chạy khi bộ đếm còn 0. Chuyển wg.Add(1) ra trước go và chạy lại. Ba mươi giây đó là lỗi WaitGroup phổ biến nhất.

Mẫu số chung

Đồng thời chạy trên một bộ từ vựng nhỏ các nguyên thuỷ phối hợp, và runtime nào cũng có đúng bộ ấy: một rào-cản/hợp-lưu để chờ cả nhóm xong (WaitGroup của Go, CountDownLatch của Java, Promise.all của JS, một cú join luồng), một chạy-đúng-một-lần để khởi tạo lười an toàn (Once của Go, mẫu holder / khoá kép của Java, std::sync::Once và lazy_static của Rust), và biến điều kiện. Sợi chỉ chung: tự chế mấy thứ này bằng tay — một cờ boolean cho "đã khởi tạo chưa", một bộ đếm tự quản cho "xong hết chưa" — là tái tạo đúng cái cuộc đua mà nguyên thuỷ sinh ra để diệt. Hãy dùng nguyên thuỷ, và ưu tiên cái mức-cao-hơn khi có (errgroup thay cho WaitGroup trần, vì nó còn mang theo lỗi và huỷ).

Điều thứ hai: cái gì tái dùng qua nhiều lượt thì phải vệ sinh — chùi sạch trạng thái giữa hai người dùng, nếu không dữ liệu của người trước rỉ sang người sau. Cái Reset-sau-Get bắt buộc của sync.Pool cùng một họ lỗi với một bộ đệm chưa xoá, một kết nối trong pool còn mang trạng thái phiên, hay một ThreadLocal không reset giữa các request (một vụ rò dữ liệu xuyên-request thật). Và một tầng tái dùng như Pool chỉ đáng cái phức tạp của nó khi phép đo cho thấy cấp phát mới thật sự là chi phí trên đường chạy nóng — lại là điệp khúc đo-đừng-đoán, mặc-định-chọn-đơn-giản như RWMutex-với-Mutex và pool-to-hơn-không-nhanh-hơn. Đừng bao giờ tin một đối tượng tái dùng là sạch, và đừng thêm tầng tái dùng cho tới khi con số đòi hỏi.

Ngày mai: race detector — công cụ bắt lỗi đua mà bạn nên bật trong CI.

Bài tập làm thử

Bài 1 (đọc hiểu — code Go). Đoạn mã sau in ra gì và tại sao?

var wg sync.WaitGroup
for i := 0; i < 3; i++ {
	go func() {
		wg.Add(1)
		defer wg.Done()
		time.Sleep(10 * time.Millisecond)
	}()
}
wg.Wait()
fmt.Println("xong")
Đáp án

In ra xong gần như ngay lập tức, không chờ các goroutine hoàn thành. wg.Add(1) được gọi bên trong goroutine thay vì trước go, nên có khả năng cao (gần như chắc chắn trên thực tế) wg.Wait() chạy ở goroutine chính trước khi bất kỳ goroutine con nào kịp gọi Add(1) — lúc đó bộ đếm vẫn là 0, nên Wait() trả về ngay lập tức. Sửa bằng cách chuyển wg.Add(1) ra trước go func(){...}().

Bài 2 (sửa lỗi — code Go). Đoạn mã sau dùng sync.Once để khởi tạo kết nối CSDL nhưng lỗi (err) bị mất khi khởi tạo thất bại lần đầu, và các lần gọi sau vẫn trả về db = nil mà không có cách nào biết vì sao. Sửa lại theo đúng mẫu bài viết đưa ra:

var (
	once sync.Once
	db   *sql.DB
)

func Lay() *sql.DB {
	once.Do(func() {
		db, _ = sql.Open("postgres", dsn)
	})
	return db
}
Đáp án
var (
	once sync.Once
	db   *sql.DB
	err  error
)

func Lay() (*sql.DB, error) {
	once.Do(func() { db, err = sql.Open("postgres", dsn) })
	return db, err
}

Cất lỗi vào biến ngoài để mọi lần gọi sau đều lấy lại được kết quả (thành công hoặc lỗi) của lần chạy duy nhất — vì Do chỉ chạy hàm bên trong đúng một lần, kể cả khi nó hỏng (không có cơ chế thử lại tự động). Có thể dùng sync.OnceValues (Go 1.21+) để viết gọn hơn: var layDB = sync.OnceValues(func() (*sql.DB, error) { return sql.Open("postgres", dsn) }).

Bài 3 (vận dụng — code Go). Viết một sync.Pool để tái dùng *bytes.Buffer cho một hàm xử lý request, đảm bảo tuân thủ đúng quy tắc "phải Reset sau khi Get" mà bài viết nhấn mạnh để tránh rò rỉ dữ liệu giữa các request.

Đáp án
var bufPool = sync.Pool{
	New: func() any { return new(bytes.Buffer) },
}

func XuLyRequest(du []byte) string {
	b := bufPool.Get().(*bytes.Buffer)
	b.Reset() // bắt buộc: đối tượng cũ có thể còn dữ liệu của request trước
	defer bufPool.Put(b)

	b.Write(du)
	return b.String()
}

Quên Reset() sẽ khiến dữ liệu của request trước đó (còn sót trong buffer khi được Put lại pool) bị rò rỉ sang request hiện tại — một lỗi bảo mật nghiêm trọng nếu buffer chứa dữ liệu nhạy cảm.

Bài 4 (bẫy/đánh đổi). Một lập trình viên viết mã dựa vào giả định "đối tượng tôi Put vào sync.Pool sẽ luôn còn đó khi tôi Get lại sau, miễn là không có ai khác Get nó trước". Giải thích tại sao giả định này sai, theo đúng cảnh báo trong bài viết.

Đáp án

sync.Pool không phải một cache đáng tin cậy — bộ thu gom rác (GC) của Go có thể dọn sạch toàn bộ Pool bất cứ lúc nào (thường là giữa các chu kỳ GC), không có gì đảm bảo một đối tượng đã Put vào sẽ còn tồn tại khi Get lại. Vì vậy không bao giờ nên dựa vào Pool để lưu trữ trạng thái quan trọng hoặc để đảm bảo tính sẵn có của một đối tượng cụ thể — hàm New luôn phải sẵn sàng tạo một đối tượng mới thay thế bất kỳ lúc nào Pool trống.

Bài 5 (đọc hiểu/vận dụng). Bài viết đưa ra bốn quy tắc cho WaitGroup và nói mỗi quy tắc "đều là nguồn lỗi thật". Với đoạn mã sau, hãy chỉ ra quy tắc nào bị vi phạm và hậu quả cụ thể (thông điệp lỗi runtime, nếu có):

func XuLySongSong(items []Item, wg sync.WaitGroup) {
	for _, it := range items {
		wg.Add(1)
		go func(it Item) {
			defer wg.Done()
			xuLy(it)
		}(it)
	}
	wg.Wait()
}
Đáp án

Vi phạm quy tắc "truyền *WaitGroup, không truyền giá trị". Tham số wg sync.WaitGroup (không có dấu *) khiến hàm nhận một bản sao của WaitGroup — mọi wg.Add(1), wg.Done(), wg.Wait() bên trong hàm thao tác trên bản sao cục bộ này, hoàn toàn tách biệt với WaitGroup gốc mà người gọi có thể đang giữ. go vet sẽ bắt được lỗi này (cảnh báo copy locks). Hậu quả thực tế: nếu người gọi kỳ vọng chờ được các goroutine này xong thông qua WaitGroup gốc của họ, họ sẽ không bao giờ thấy tác dụng — dù chính hàm XuLySongSong bản thân nó vẫn hoạt động đúng nội bộ (vì wg.Wait() trong hàm dùng đúng bản sao mà Add/Done đã thao tác). Sửa bằng cách nhận wg *sync.WaitGroup.