Quản lý goroutine: errgroup, leak-proof, backpressure

Quản lý goroutine: errgroup, leak-proof, backpressure

Table of Contents

Tháng trước, một service của team mình đột nhiên chậm như rùa. p50 latency từ 50ms nhảy lên 3 giây. Mở Grafana lên - 120,000 goroutine đang chạy. Bình thường chỉ 200. Ai đó vừa push code quên defer cancel() trong một vòng lặp xử lý batch.

Goroutine là thứ làm Go ngon: nhẹ (~2KB stack), rẻ, bật cái là chạy. Nhưng chính vì quá dễ dùng nên quá dễ leak. Bài này là những pattern mình dùng để giữ goroutine trong tầm kiểm soát - từ errgroup đến worker pool đến backpressure. Tất cả có code chạy được, hầu hết rút từ production.

![Quản lý goroutine(https://blog-bucket.luandnh.com/images/covers/manage-routine.jpg)

Vấn đề đầu tiên: goroutine leak

Goroutine leak xảy ra khi goroutine không bao giờ kết thúc. Nguyên nhân #1 mình thấy trong code review: channel bị block vô hạn vì không có context.

// ❌ Sai: goroutine này sẽ chạy mãi nếu ch không bao giờ close
go func() {
    for item := range ch {
        process(item)
    }
}()
// ✅ Đúng: luôn có lối thoát qua context
go func() {
    for {
        select {
        case <-ctx.Done():
            return
        case item, ok := <-ch:
            if !ok {
                return
            }
            process(item)
        }
    }
}()

Cách phát hiện leak: pprof là bạn thân nhất:

go tool pprof http://localhost:6060/debug/pprof/goroutine
# Trong pprof: top10 -> xem function nào đang giữ nhiều goroutine nhất

Nếu số goroutine tăng đều mà không giảm, bạn có leak. Mình từng phát hiện một leak 15K goroutine/giờ chỉ bằng cách check pprof trước và sau deploy.

Errgroup: chạy song song, cancel đồng loạt

Khi cần chạy 3-4 tác vụ song song và muốn hủy tất cả nếu một tác vụ lỗi - errgroup là câu trả lời.

import "golang.org/x/sync/errgroup"

func fetchDashboard(ctx context.Context, userID string) (*Dashboard, error) {
    g, ctx := errgroup.WithContext(ctx)

    var users *UserList
    var orders *OrderList
    var stats *Stats

    g.Go(func() error {
        var err error
        users, err = fetchUsers(ctx, userID)
        return err
    })
    g.Go(func() error {
        var err error
        orders, err = fetchOrders(ctx, userID)
        return err
    })
    g.Go(func() error {
        var err error
        stats, err = fetchStats(ctx, userID)
        return err
    })

    if err := g.Wait(); err != nil {
        return nil, err
    }
    return &Dashboard{users, orders, stats}, nil
}

Khi fetchOrders trả về lỗi, errgroup tự động cancel context → fetchUsersfetchStats nhận được ctx.Done() và dừng sớm. Không cần sync.WaitGroup + context.Cancel thủ công.

Errgroup với limit concurrency

import "golang.org/x/sync/errgroup"

func processBatchLimited(ctx context.Context, items [Item, maxConcurrent int) error {
    g, ctx := errgroup.WithContext(ctx)
    g.SetLimit(maxConcurrent) // Go 1.20+

    for _, item := range items {
        item := item // capture
        g.Go(func() error {
            return process(ctx, item)
        })
    }
    return g.Wait()
}

Set limit = 10, errgroup chỉ chạy tối đa 10 goroutine đồng thời. Mình dùng cái này để xử lý batch 10K items mà không đè chết DB connection pool.

Worker pool: giới hạn số barista

Không phải lúc nào cũng tạo goroutine mới. Khi tải cao, worker pool giới hạn số goroutine chạy đồng thời - như giới hạn quầy bar trong quán.

func processBatch(ctx context.Context, jobs [Job, numWorkers int) []Result {
    ch := make(chan Job, len(jobs))
    results := make(chan Result, len(jobs))

    var wg sync.WaitGroup
    for i := 0; i < numWorkers; i++ {
        wg.Add(1)
        go func(workerID int) {
            defer wg.Done()
            for {
                select {
                case <-ctx.Done():
                    return
                case job, ok := <-ch:
                    if !ok {
                        return
                    }
                    select {
                    case <-ctx.Done():
                        return
                    case results <- process(job):
                    }
                }
            }
        }(i)
    }

    // Send jobs - cũng respect context
    go func() {
        defer close(ch)
        for _, job := range jobs {
            select {
            case <-ctx.Done():
                return
            case ch <- job:
            }
        }
    }()

    wg.Wait()
    close(results)

    var output [Result
    for r := range results {
        output = append(output, r)
    }
    return output
}

Pattern này mình dùng trong service xử lý batch import merchant menu - 500 items/batch, 20 workers, p99 latency từ 8s xuống 1.2s.

Backpressure: khi quán đông quá, đừng nhận thêm khách

Backpressure là kỹ thuật giúp hệ thống tự giảm tốc khi quá tải - thay vì nhận hết rồi crash.

Buffered channel + timeout

ch := make(chan Job, 100)

select {
case ch <- job:
    // OK, job được nhận
case <-time.After(50 * time.Millisecond):
    // Queue đầy 100ms - báo quá tải
    metrics.OverloadCounter.Inc()
    return ErrTooBusy
}

Client nhận 429 hoặc ErrTooBusy → biết phải retry sau. Hệ thống không sập.

Rate limiting với token bucket

import "golang.org/x/time/rate"

var limiter = rate.NewLimiter(100, 200) // 100 req/s, burst 200

func handler(w http.ResponseWriter, r *http.Request) {
    if err := limiter.Wait(r.Context()); err != nil {
        http.Error(w, "Too Many Requests", http.StatusTooManyRequests)
        return
    }
    // Process...
}

Mình dùng rate limiter cho endpoint public-facing để tránh 1 user spam làm sập service. Burst 200 cho phép spike ngắn, steady 100 req/s cho sustained load.

Observability: biết goroutine đang làm gì

Khi chạy nhiều goroutine, debug mà không có trace là mò kim đáy bể.

func HandleRequest(ctx context.Context, req *Request) (*Response, error) {
    span, ctx := opentracing.StartSpanFromContext(ctx, "HandleRequest")
    defer span.Finish()

    // Gán attribute cho span
    span.SetTag("user_id", req.UserID)
    span.SetTag("item_count", len(req.Items))

    // ctx tự động propagate trace ID xuống mọi goroutine
    result, err := processConcurrently(ctx, req.Items)
    if err != nil {
        span.SetTag("error", true)
        span.LogFields(log.Error(err))
    }
    return result, err
}

Mỗi goroutine share context có cùng trace ID → Jaeger/Grafana Tempo hiển thị toàn bộ waterfall.

Checklist kiểm soát goroutine

  • [ Luôn có lối thoát: ctx.Done() hoặc close(channel)
  • [ Dùng errgroup khi cần cancel đồng loạt
  • [ Set g.SetLimit(n) khi xử lý batch lớn
  • [ Worker pool cho sustained workload (không spawn vô tội vạ)
  • [ Backpressure (buffered channel + timeout) cho endpoint public
  • [ Rate limiter cho endpoint nhạy cảm
  • [ Propagate trace ID qua context
  • [ Monitor goroutine count với pprof

“Goroutine rẻ không có nghĩa là miễn phí. Mỗi goroutine leak là một barista đứng không, làm việc không ai tiếp khách.”

Bottom line

Goroutine là thứ làm Go khác biệt - nhưng cũng là thứ dễ bắn vào chân nhất. Errgroup cho parallel + cancel, worker pool cho batch, backpressure cho high load. Ba pattern này cover 90% use case mình gặp trong production. Pattern thứ tư là… mở pprof lên kiểm tra trước khi deploy. Luôn luôn.

Bạn đã từng gặp goroutine leak kiểu gì? Có pattern nào mình chưa đề cập mà team bạn dùng không? Kể mình nghe - mình vẫn đang sưu tầm war story về concurrency đây. 🚀

Share :

Related Posts

API Filtering: gọi dữ liệu như dân sành cà phê

API Filtering: gọi dữ liệu như dân sành cà phê

Năm đầu đi làm, mình từng viết một endpoint GET /api/menus trả về… toàn bộ menu. 200 items mỗi lần gọi. JSON nặng 1.2MB. Frontend chỉ cần tên + giá của 10 món đang active. Mình nhớ câu đầu tiên anh lead nói: “Mày đang gửi cả cái kho hàng cho người ta chỉ cần xem menu đấy à?”

Read More
Context trong Go: truyền dữ liệu, deadline, cancel - dùng đúng hay chết hiệu năng

Context trong Go: truyền dữ liệu, deadline, cancel - dùng đúng hay chết hiệu năng

Hồi mới code Go, mình có một bug production kỳ lạ: cứ sau 3-4 ngày chạy, service tự dưng ngốn 8GB RAM rồi OOM. Mò suốt chiều thứ Sáu mới phát hiện: một cái goroutine không bao giờ kết thúc vì mình quên truyền context xuống gRPC call. Mỗi request leak một goroutine, sau 100K request thì server “đội mồ” luôn.

Read More
Khi nào nên dùng cache (và khi nào không)

Khi nào nên dùng cache (và khi nào không)

Có một bug production mà đến giờ mình vẫn nhớ như in: user A hủy đơn hàng, nhưng app vẫn hiển thị “đang giao” trong 10 phút tiếp theo. Support nhận 30 ticket trong 1 buổi sáng. Nguyên nhân: cache TTL 10 phút, nhưng không ai invalidate khi order status thay đổi.

Read More