Có những bài toán đồng thời cần các goroutine đồng bước: tất cả phải hoàn thành pha N trước khi bất kỳ ai bắt đầu pha N+1. Ví dụ điển hình: thuật toán lặp song song, mô phỏng vật lý theo bước thời gian — mỗi bước phụ thuộc vào việc mọi worker đã cập nhật xong bước trước. Công cụ cho việc này là barrier (rào chắn): một điểm mà N goroutine cùng chờ tới rồi cùng đi tiếp. Bài này dựng barrier bằng sync.Cond và đo thật tác dụng của nó.

Ba cách dựng barrier trong Go

Go không có kiểu Barrier sẵn, nhưng dựng dễ từ nguyên thủy có sẵn:

  • sync.WaitGroup: rào một lần — main chờ tất cả goroutine tới một điểm. Đơn giản nhất, nhưng không đối xứng (worker không chờ nhau, chỉ main chờ).
  • channel close: rào một lần dạng broadcast — mọi goroutine chờ <-startCh, rồi close(startCh) mở cho tất cả cùng lúc.
  • sync.Cond: rào tuần hoàn (dùng lại nhiều vòng) — mạnh nhất, cho lockstep nhiều pha.

Ảnh chụp đoạn mã Go nền tối minh hoạ barrier đồng bộ nhiều goroutine tại một rào chắn, rào chắn N goroutine phải cùng tới điểm rào rồi mới cùng đi tiếp giữ các worker song song đồng bước, một ba cách dựng barrier trong Go sync.WaitGroup rào một lần main chờ tất cả tới điểm đơn giản nhất channel close rào một lần broadcast close mở cho tất cả cùng lúc sync.Cond rào tuần hoàn dùng lại nhiều vòng mạnh nhất, hai barrier tuần hoàn bằng sync.Cond func b Barrier Wait b.mu.Lock defer b.mu.Unlock g bằng b.gen b.count cộng cộng if b.count bằng b.n người cuối tới mở rào b.count bằng 0 b.gen cộng cộng b.cond.Broadcast đánh thức mọi goroutine chờ else for g bằng b.gen chờ tới khi thế hệ đổi rào mở b.cond.Wait nhả khóa cộng ngủ được đánh thức thì lấy lại gen thế hệ làm barrier tuần hoàn mỗi lần đủ N thì tăng gen các goroutine chờ theo gen cũ được thả dùng lại cho vòng sau, ba channel-barrier một lần đơn giản hơn start bằng make chan struct mỗi worker chờ tín hiệu bắt đầu thiếu start tất cả block ở đây điều phối mở rào cho tất cả cùng lúc close start mọi thiếu start trả về đồng thời, bốn khi nào cần barrier thuật toán lặp song song mỗi pha phụ thuộc mọi worker xong pha trước mô phỏng vật lý ma trận cập nhật đồng bộ theo bước thời gian khởi động đồng loạt benchmark mọi goroutine chạy cùng lúc

Hình 1: Barrier. Ba cách dựng (WaitGroup/channel/Cond), barrier tuần hoàn bằng sync.Cond với gen counter, channel-barrier một lần, và khi nào cần.

Barrier tuần hoàn bằng sync.Cond

func (b *Barrier) Wait() {
	b.mu.Lock(); defer b.mu.Unlock()
	g := b.gen
	b.count++
	if b.count == b.n {        // người CUỐI tới → mở rào
		b.count = 0; b.gen++
		b.cond.Broadcast()      // đánh thức MỌI goroutine chờ
	} else {
		for g == b.gen {         // chờ tới khi thế hệ đổi (rào mở)
			b.cond.Wait()        // nhả khóa + ngủ, được đánh thức thì lấy lại khóa
		}
	}
}

Cơ chế: mỗi goroutine tới tăng count. Người cuối cùng (count == n) đặt lại count, tăng gen (thế hệ), và Broadcast đánh thức tất cả. Những goroutine tới trước đang Wait trong vòng lặp kiểm g == b.gen — khi gen đổi, chúng thoát. gen làm barrier tuần hoàn: mỗi vòng có một thế hệ mới, nên barrier dùng lại được cho vòng sau. (Lưu ý cond.Wait phải trong vòng for kiểm điều kiện, không phải if — chống spurious wakeup.)

Đo thật: đồng bước vs mất đồng bộ

Cho 4 goroutine tốc độ khác nhau (id × 5ms) chạy 3 vòng:

Ảnh chụp bảng kết quả đo thật nền tối có barrier giữ đồng bước không barrier lệch 2 vòng go run sync.Cond Go 1.23 arm64 10 core 4 goroutine 3 vòng tốc độ khác nhau, có barrier tuần hoàn mỗi vòng đủ 4 mới qua barrier 4 goroutine 3 vòng vòng 0 đủ 4 goroutine tới rào cùng qua vòng 1 đủ 4 goroutine tới rào cùng qua vòng 2 đủ 4 goroutine tới rào cùng qua mọi vòng đủ 4 goroutine trước khi bất kỳ ai sang vòng sau dù 4 goroutine chạy tốc độ khác nhau id x 5ms barrier ép chúng đồng bước không ai sang vòng r cộng 1 tới khi cả 4 xong vòng r, không barrier goroutine nhanh vượt trước khoảng cách vòng lớn nhất giữa các goroutine bằng 2 goroutine nhanh đã sang vòng sau trong khi goroutine chậm còn vòng trước không barrier goroutine id bằng 0 nhanh đã ở vòng 2 khi id bằng 3 chậm còn vòng 0 lệch 2 vòng nếu vòng sau phụ thuộc dữ liệu vòng trước của mọi worker đây là bug đọc dữ liệu chưa cập nhật xong, ba cách so sánh sync.WaitGroup main chờ tất cả một lần tạo mới mỗi vòng channel close broadcast mở rào một lần channel mới mỗi lần sync.Cond đối xứng tuần hoàn có gen counter, cốt lõi barrier N goroutine cùng tới rào rồi cùng đi tiếp đồng bước đo thật có barrier mọi vòng đủ 4 không barrier lệch 2 vòng Cond barrier tuần hoàn Broadcast cộng Wait cộng gen counter channel barrier một lần đơn giản close mở cho tất cả dùng khi thuật toán lặp song song mô phỏng khởi động đồng loạt

Hình 2: Có barrier — mỗi vòng đủ 4 goroutine mới qua (đồng bước). Không barrier — goroutine nhanh vượt trước, lệch 2 vòng. Bảng so ba cách dựng.

  • Có barrier tuần hoàn: mỗi vòng đều đủ 4 goroutine tới rào rồi mới cùng qua — dù tốc độ khác nhau, barrier ép chúng đồng bước. Không ai sang vòng r+1 tới khi cả 4 xong vòng r.
  • Không barrier: khoảng cách vòng lớn nhất giữa các goroutine = 2 — goroutine id=0 (nhanh) đã ở vòng 2 khi id=3 (chậm) còn vòng 0. Mất đồng bộ hoàn toàn.

Nếu vòng sau phụ thuộc dữ liệu vòng trước của MỌI worker (như mô phỏng), việc mất đồng bộ này là bug: goroutine nhanh đọc dữ liệu vòng trước mà worker chậm chưa cập nhật xong.

Ứng dụng thực tế

Thuật toán lặp song song đồng bộ theo pha. Nhân ma trận theo khối, mô phỏng vật lý (mỗi hạt cập nhật theo trạng thái toàn cục bước trước), thuật toán đồ thị lặp — mọi worker phải xong pha hiện tại trước khi ai bắt đầu pha sau. Barrier giữ đúng ngữ nghĩa này.

Khởi động đồng loạt cho benchmark. Khi đo N goroutine song song, dùng channel-barrier (close(start)) để chúng bắt đầu cùng một khoảnh khắc — nếu goroutine đầu chạy trước khi goroutine cuối được tạo, phép đo lệch. Đây là mẫu chuẩn trong benchmark đồng thời.

Chọn barrier theo nhu cầu dùng lại. Cần một pha đồng bộ: WaitGroup hoặc channel đủ. Cần nhiều pha lặp lại: dùng barrier tuần hoàn (sync.Cond) để không phải tạo mới mỗi vòng. Đừng dùng Cond khi WaitGroup đủ — nó phức tạp hơn.

Đánh đổi cần cân nhắc

Barrier ép tốc độ về goroutine chậm nhất. Bản chất của đồng bước: mọi worker chờ người chậm nhất ở mỗi rào. Nếu tải không cân (một worker luôn chậm), barrier lãng phí thời gian các worker nhanh. Cân nhắc chia tải đều hơn, hoặc dùng mô hình khác (work-stealing) nếu đồng bước không thật sự cần — barrier chỉ đáng khi đúng đắn đòi hỏi nó.

sync.Cond khó dùng đúng, dễ sai. Cond là nguyên thủy cấp thấp: phải nhớ Wait trong vòng for (không phải if), giữ khóa đúng cách, và Broadcast vs Signal. Nhiều bug đồng thời đến từ dùng Cond sai. Nếu channel hoặc WaitGroup diễn đạt được, ưu tiên chúng — Cond chỉ khi thật cần barrier tuần hoàn hoặc điều kiện chờ phức tạp.

Barrier không chịu lỗi tốt. Nếu một goroutine chết (panic) trước khi tới rào, các goroutine khác chờ mãi mãi — deadlock (như bài trước). Barrier production cần cơ chế: context timeout, hoặc đếm goroutine còn sống và điều chỉnh n. Đừng dùng barrier "trần" nơi worker có thể thất bại.

Ba ý mang về

  1. Barrier bắt N goroutine cùng tới rào rồi cùng đi tiếp (đồng bước): đo thật, 4 goroutine tốc độ khác nhau vẫn đồng bộ từng vòng với barrier tuần hoàn — không ai sang vòng r+1 tới khi cả 4 xong vòng r; không barrier, goroutine nhanh vượt trước lệch tới 2 vòng.
  2. Ba cách dựng theo nhu cầu: sync.WaitGroup (rào một lần, main chờ), channel close (broadcast một lần, mở cho tất cả cùng lúc), sync.Cond (barrier tuần hoàn dùng lại qua gen counter với Broadcast + Wait) — chọn Cond chỉ khi cần nhiều pha lặp lại.
  3. Barrier ép tốc độ về goroutine chậm nhất và không chịu lỗi tốt: nó đáng khi đúng đắn đòi hỏi đồng bước (thuật toán lặp song song, mô phỏng, khởi động benchmark đồng loạt) — nhưng sync.Cond khó dùng đúng (Wait trong for), và một goroutine chết trước rào gây deadlock, nên cần timeout/context ở production.

Bài tập làm thử

Bài 1 (đọc hiểu). Cho cài đặt barrier tuần hoàn bằng sync.Cond:

func (b *Barrier) Wait() {
	b.mu.Lock(); defer b.mu.Unlock()
	g := b.gen
	b.count++
	if b.count == b.n {
		b.count = 0; b.gen++
		b.cond.Broadcast()
	} else {
		for g == b.gen {
			b.cond.Wait()
		}
	}
}

Biến gen (thế hệ) dùng để làm gì? Nếu bỏ biến gen và chỉ dùng for b.count != 0 để chờ, barrier có còn dùng lại được cho nhiều vòng không?

Đáp án

gen đánh dấu "thế hệ" hiện tại của barrier; mỗi khi đủ N goroutine tới (người cuối cùng), gen tăng lên để báo hiệu "rào đã mở cho vòng này". Các goroutine đang chờ kiểm tra g == b.gen (g là gen lúc chúng vào) — khi gen đổi, chúng biết rào đã mở và thoát vòng lặp. Nhờ gen, barrier có thể dùng lại nhiều vòng liên tiếp: vòng sau lại có gen mới, không bị lẫn với trạng thái vòng trước. Nếu bỏ gen và chỉ dựa vào count, sẽ khó phân biệt "count vừa được reset về 0 vì rào mở" với "count=0 vì chưa ai tới vòng mới" — dễ gây race hoặc goroutine bị kẹt sai thế hệ.

Bài 2 (sửa lỗi). Đoạn mã sau dùng sync.Cond sai một điểm quan trọng đã được bài nhắc tới. Tìm lỗi và sửa.

func (b *Barrier) Wait() {
	b.mu.Lock(); defer b.mu.Unlock()
	g := b.gen
	b.count++
	if b.count == b.n {
		b.count = 0; b.gen++
		b.cond.Broadcast()
	} else {
		if g == b.gen { // <-- lỗi ở đây
			b.cond.Wait()
		}
	}
}
Đáp án

Lỗi: dùng if thay vì for để kiểm tra điều kiện trước khi Wait(). cond.Wait() có thể bị "spurious wakeup" (đánh thức giả, không phải do Broadcast/Signal thật), nên sau khi thức dậy phải kiểm tra lại điều kiện trong vòng lặp for, không phải chỉ kiểm tra một lần bằng if. Sửa lại:

for g == b.gen {
	b.cond.Wait()
}

Đây là quy tắc bắt buộc khi dùng sync.Cond: luôn đặt Wait() trong for, không bao giờ trong if.

Bài 3 (vận dụng thực tế). Bạn cần chạy một benchmark với 8 goroutine, muốn tất cả bắt đầu công việc đo tại đúng cùng một thời điểm (không goroutine nào chạy trước trong khi các goroutine khác còn đang được tạo). Bạn nên dùng cách dựng barrier nào trong ba cách (WaitGroup, channel close, sync.Cond) và tại sao?

Đáp án

Dùng channel close (rào một lần dạng broadcast): tạo một start := make(chan struct{}), mỗi goroutine sau khi khởi tạo xong thì block ở <-start, và khi tất cả goroutine đã được tạo, chương trình chính gọi close(start) để mở cho tất cả goroutine cùng bắt đầu chạy đồng thời. Đây là mẫu chuẩn cho benchmark đồng thời. Không cần sync.Cond (tuần hoàn, dùng lại nhiều vòng) vì chỉ cần một lần đồng bộ; WaitGroup cũng không phù hợp bằng vì nó dùng để một bên (main) chờ các goroutine khác hoàn thành, không phải để phát tín hiệu "bắt đầu cùng lúc" cho chúng.

Bài 4 (đọc số liệu đo thật). Bài đo thật cho 4 goroutine tốc độ khác nhau (id × 5ms) chạy 3 vòng: có barrier thì mọi vòng đều "đủ 4 goroutine mới qua"; không có barrier thì khoảng cách vòng lớn nhất giữa các goroutine là 2 (goroutine nhanh đã ở vòng 2 trong khi goroutine chậm còn vòng 0). Nếu thuật toán của bạn có vòng sau phụ thuộc dữ liệu vòng trước của MỌI worker, việc bỏ barrier gây ra hậu quả gì cụ thể?

Đáp án

Hậu quả là bug đọc dữ liệu chưa cập nhật xong: một goroutine nhanh có thể đã sang vòng sau và đọc trạng thái/dữ liệu chung, trong khi một goroutine chậm khác vẫn còn đang ở vòng trước và chưa kịp ghi xong phần cập nhật của nó cho vòng đó. Kết quả là goroutine nhanh tính toán dựa trên dữ liệu cũ/thiếu, dẫn tới kết quả sai mà không có lỗi runtime nào báo hiệu — đây là lý do các thuật toán lặp song song và mô phỏng theo bước thời gian bắt buộc cần barrier để đảm bảo mọi worker hoàn thành pha hiện tại trước khi bất kỳ ai bắt đầu pha kế tiếp.

Bài 5 (bẫy/đánh đổi). Bài viết cảnh báo "barrier không chịu lỗi tốt". Giải thích cụ thể điều gì xảy ra nếu một trong N goroutine bị panic trước khi gọi Wait(), và nêu một cách giảm thiểu rủi ro này ở môi trường production.

Đáp án

Nếu một goroutine chết (panic) trước khi tới rào (gọi Wait()), biến đếm count sẽ không bao giờ đạt tới n, nên tất cả các goroutine còn lại đang cond.Wait() sẽ bị kẹt chờ mãi mãi — về bản chất đây là một dạng deadlock. Để giảm thiểu, cần thêm cơ chế chịu lỗi: ví dụ dùng context với timeout bao quanh Wait() để không chờ vô hạn, hoặc theo dõi số goroutine còn sống và điều chỉnh động giá trị n của barrier khi có goroutine chết, thay vì dùng một barrier "trần" giả định mọi worker luôn thành công.

Phần sau ta chuyển sang đo lường chính vấn đề đồng thời đã gặp nhiều lần: Phần sau mổ xẻ mutex profile — cách dùng runtime.SetMutexProfileFraction và pprof để đo tranh chấp khóa thật trong chương trình, tìm mutex nào là nút thắt.