0

Go Zero to Hero - Bài 61: Làm chủ sync.WaitGroup và Tuyệt kỹ WaitGroup Timeout

1. Bản chất thực sự của WaitGroup

Bên dưới lớp vỏ, sync.WaitGroup chỉ là một Struct chứa một biến đếm (Counter) được bảo vệ an toàn khỏi Race Condition (nhờ Mutex ngầm).

  • Add(n): Cộng thêm n vào biến đếm.
  • Done(): Trừ đi 1 khỏi biến đếm (Thực chất nó gọi Add(-1)).
  • Wait(): Khóa cứng (Block) luồng hiện tại cho đến khi biến đếm bằng đúng 0.

Cơ chế thì đơn giản, nhưng cách lập trình viên đặt 3 hàm này vào code lại quyết định sự sống còn của hệ thống.


2. Ba cái bẫy "Sập Server" với WaitGroup

Bẫy 1: Truyền WaitGroup dưới dạng Tham trị (Pass-by-Value)

Đây là lỗi kinh điển nhất. Khi bạn truyền biến wg vào một hàm con mà không dùng con trỏ, Go sẽ copy toàn bộ cái WaitGroup đó sang một vùng nhớ mới. Hàm con gọi Done() trên bản copy, trong khi hàm main lại đang Wait() trên bản gốc.

  • Hậu quả: Bản gốc vĩnh viễn không bao giờ về 0 -> Deadlock toàn tập.
  • Cách khắc phục: LUÔN LUÔN truyền bằng con trỏ *sync.WaitGroup, hoặc sử dụng Closure (Hàm ẩn danh) để truy cập trực tiếp biến wg ở scope bên ngoài.

Bẫy 2: Lệnh Done() đếm lố quá tay (Negative Counter)

Nếu bạn gọi Add(1) nhưng lại lỡ tay gọi Done() tới 2 lần (hoặc có 1 luồng gọi Done thừa), biến đếm sẽ tụt xuống -1.

  • Hậu quả: Go Runtime không chấp nhận số âm và sẽ ném ra PANIC ngay lập tức: panic: sync: negative WaitGroup counter.
  • Cách khắc phục: Luôn gọi defer wg.Done() ngay ở dòng đầu tiên bên trong Goroutine. Cú pháp defer đảm bảo Done chỉ chạy đúng 1 lần khi luồng kết thúc.

Bẫy 3: Gọi lệnh Add() BÊN TRONG Goroutine

Hãy xem đoạn code chết người này:

func main() {
    var wg sync.WaitGroup
    for i := 0; i < 5; i++ {
        go func() {
            // 🚨 SAI LẦM CHÍ MẠNG: Đặt Add bên trong luồng con
            wg.Add(1) 
            defer wg.Done()
            // ... làm việc ...
        }()
    }
    
    // Hàm main chạy quá nhanh, nó đến dòng Wait() trước cả khi 
    // luồng con đầu tiên kịp khởi động và gọi Add(1)
    // Lúc này biến đếm vẫn đang là 0 -> Wait() bị bỏ qua ngay lập tức!
    wg.Wait() 
    fmt.Println("Main kết thúc sớm, các luồng con bị giết!")
}
  • Cách khắc phục: Luôn gọi wg.Add() ở luồng chính (Main) TRƯỚC KHI tung từ khóa go.

3. Tuyệt kỹ Backend: Pattern "WaitGroup Timeout"

WaitGroup.Wait() là một cái hố đen. Nếu có 1 Goroutine con bị treo (ví dụ kết nối Database bị kẹt mạng, API bên thứ 3 không phản hồi) và không bao giờ gọi Done(), hàm main sẽ bị Block ở lệnh Wait() vĩnh viễn.

Trong Backend, tuyệt đối không được phép chờ đợi vô thời hạn. Để khắc phục việc WaitGroup thiếu tính năng Timeout, chúng ta sẽ kết hợp nó với kỹ thuật select và Channel.

Hãy xem cách thiết kế hệ thống đối soát vé (Reconciliation) chờ báo cáo từ 3 máy chủ trạm, nhưng tối đa chỉ chờ 5 giây:

package main

import (
    "fmt"
    "sync"
    "time"
)

// Hàm xử lý nghiệp vụ của từng trạm
func fetchStationData(stationID string, wg *sync.WaitGroup) {
    defer wg.Done()
    
    fmt.Printf("[Trạm %s] Đang tổng hợp dữ liệu...\n", stationID)
    
    // Giả lập trạm B bị rớt mạng, mất tới 10 giây mới xong
    if stationID == "B" {
        time.Sleep(10 * time.Second)
    } else {
        time.Sleep(2 * time.Second)
    }
    
    fmt.Printf("[Trạm %s] Gửi báo cáo thành công!\n", stationID)
}

func main() {
    var wg sync.WaitGroup
    stations := []string{"A", "B", "C"}

    // 1. Phân chia công việc an toàn
    for _, st := range stations {
        wg.Add(1)
        go fetchStationData(st, &wg)
    }

    // 2. TẠO LUỒNG THEO DÕI (Monitor Goroutine)
    // Thay vì gọi thẳng wg.Wait() ở main, ta đưa nó vào một luồng riêng
    // Luồng này sẽ báo cáo kết quả qua một Channel khi WaitGroup hoàn thành
    doneChan := make(chan struct{})
    
    go func() {
        wg.Wait() // Đứng chờ tất cả các trạm
        close(doneChan) // Báo hiệu đã xong toàn bộ!
    }()

    // 3. THIẾT LẬP THỜI GIAN CHỜ TỐI ĐA (Timeout)
    timeout := 5 * time.Second
    fmt.Println("⏳ Bắt đầu đối soát. Thời gian tối đa:", timeout)

    select {
    case <-doneChan:
        // Nếu doneChan đóng trước 5 giây, nhánh này sẽ chạy
        fmt.Println("✅ [Thành công] Đã nhận đủ báo cáo từ tất cả các trạm!")
        
    case <-time.After(timeout):
        // Nếu quá 5 giây mà doneChan chưa đóng, nhánh này kích hoạt
        fmt.Println("🚨 [TIMEOUT] Đã quá 5 giây! Hệ thống bỏ qua các trạm chậm trễ.")
    }
    
    fmt.Println("--- CHỐT SỔ CUỐI NGÀY ---")
}

Với Pattern này, hàm main sẽ tiếp tục chốt sổ sau 5 giây mà không bị trạm B (10 giây) kéo sập toàn bộ tiến trình. Các Goroutine vẫn sẽ chạy ngầm cho đến khi xong việc, nhưng tiến trình chính đã được giải phóng.


💡 Tổng kết

Tóm gọn cẩm nang sinh tồn với sync.WaitGroup:

  • Vị trí Add: Luôn gọi Add() TRƯỚC KHI sinh ra Goroutine.
  • Bảo chứng Done: Luôn dùng defer wg.Done() ở dòng đầu tiên bên trong Goroutine.
  • Tránh Copy: Nếu phải truyền WaitGroup vào hàm khác, bắt buộc dùng con trỏ *sync.WaitGroup.
  • Kiểm soát thời gian: Không bao giờ để wg.Wait() chặn ứng dụng vô thời hạn khi làm việc với API ngoại vi. Hãy bọc nó vào select cùng với time.After.

All rights reserved

Viblo
Hãy đăng ký một tài khoản Viblo để nhận được nhiều bài viết thú vị hơn.
Đăng kí