
Quản lý goroutine: errgroup, leak-proof, backpressure
- Golang
- August 25, 2025
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 → fetchUsers và fetchStats 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ặcclose(channel) - [ Dùng
errgroupkhi 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. 🚀


