Go cung cấp hai cách tiếp cận chính để xử lý đồng thời: channel và package sync. Mỗi phương pháp phù hợp với những tình huống cụ thể; không phải lúc nào channel cũng là lựa chọn tối ưu, đôi khi dùng các nguyên hàm đồng bộ trong sync đơn giản và hiệu quả hơn. Bài viết này mô phỏng lại các cơ chế khóa đồng bộ phổ biến của package sync bằng channel nhằm mục đích học hỏi và hiểu sâu hơn về sức mạnh của channel, chứ không khuyến khích dùng trong thực tế sản xuất. Tình huống này cũng thường xuất hiện trong các buổi phỏng vấn, nơi nhà tuyển dụng muốn kiểm tra khả năng tư duy linh hoạt và am hiểu bản chất của ứng viên.
Chúng ta sẽ lần lượt tái hiện các nguyên hàm đồng bộ sau đây chỉ bằng channel và select.
Once (Đảm bảo thực thi một lần)
sync.Once đảm bảo một hàm chỉ được gọi một lần duy nhất trong môi trường đồng thời. Ta có thể dùng channel có bộ đệm kích thước 1: goroutine đầu tiên nhận được giá trị từ channel sẽ thực thi hàm f, sau đó đóng channel lại. Các goroutine khác kiểm tra channel đã đóng và thoát ngay lập tức.
type Once chan struct{}
func NewOnce() Once {
o := make(Once, 1)
// Chỉ một goroutine có thể nhận, phần còn lại bị chặn
o <- struct{}{}
return o
}
func (o Once) Do(f func()) {
_, ok := <-o
if !ok {
// Channel đã đóng, hàm f đã được thực thi
return
}
// Chỉ có một goroutine tới được đây
f()
close(o)
}
Mutex (Khóa độc quyền)
Mutex là trường hợp đặc biệt của semaphore với kích thước 1. Trước hết, ta xây dựng semaphore tổng quát:
type Semaphore chan struct{}
func NewSemaphore(size int) Semaphore {
return make(Semaphore, size)
}
func (s Semaphore) Lock() {
// Chỉ gửi thành công nếu channel còn chỗ
s <- struct{}{}
}
func (s Semaphore) Unlock() {
// Giải phóng một chỗ trong channel
<-s
}
Từ đó, Mutex được xây dựng đơn giản:
type Mutex Semaphore
func NewMutex() Mutex {
return Mutex(NewSemaphore(1))
}
RWMutex (Khóa đọc-ghi)
RWMutex cho phép nhiều goroutine đọc đồng thời, nhưng chỉ một goroutine ghi tại một thời điểm. Nếu có goroutine đang ghi, sẽ không ai được đọc hoặc ghi thêm. Trong bản triển khai này (bỏ qua tránh starvation để đơn giản), ta dùng hai channel: một để khóa ghi, một để quản lý số lượng khóa đọc.
type RWMutex struct {
write chan struct{}
readers chan int
}
func NewRWMutex() RWMutex {
return RWMutex{
write: make(chan struct{}, 1),
readers: make(chan int, 1),
}
}
func (l *RWMutex) Lock() { l.write <- struct{}{} }
func (l *RWMutex) Unlock() { <-l.write }
func (l *RWMutex) RLock() {
var rs int
select {
case l.write <- struct{}{}:
// Không có khóa đọc nào, lấy khóa ghi để ngăn ghi đồng thời
case rs = <-l.readers:
// Đã có khóa đọc, cập nhật số lượng
}
rs++
l.readers <- rs
}
func (l *RWMutex) RUnlock() {
rs := <-l.readers
rs--
if rs == 0 {
// Không còn khóa đọc nào, giải phóng khóa ghi
<-l.write
return
}
l.readers <- rs
}
WaitGroup (Nhóm chờ)
WaitGroup chờ một tập hợp goroutine hoàn thành. Ta mô phỏng bằng channel chứa struct generation đại diện cho từng "thế hệ" – bắt đầu khi bộ đếm chuyển từ 0 lên dương và kết thúc khi về 0.
type generation struct {
wait chan struct{} // channel chỉ đóng, không gửi dữ liệu
n int
}
func newGeneration() generation {
return generation{wait: make(chan struct{})}
}
func (g *generation) end() {
close(g.wait)
}
type WaitGroup chan generation
func NewWaitGroup() WaitGroup {
wg := make(WaitGroup, 1)
g := newGeneration()
g.end() // Để Wait() trên WaitGroup mới không bị chặn
wg <- g
return wg
}
func (wg WaitGroup) Add(delta int) {
g := <-wg
if g.n == 0 {
g = newGeneration()
}
g.n += delta
if g.n < 0 {
panic("negative WaitGroup counter")
}
if g.n == 0 {
g.end()
}
wg <- g
}
func (wg WaitGroup) Done() { wg.Add(-1) }
func (wg WaitGroup) Wait() {
g := <-wg
wait := g.wait
wg <- g
<-wait // chờ đến khi channel được đóng
}
Bài viết này chỉ mang tính minh họa, nhằm cho thấy tính linh hoạt và uyển chuyển của channel trong Go. Trong dự án thực tế, hãy luôn sử dụng sync package vì hiệu năng và độ tin cậy cao hơn.