2am, một service Go đang chậm dần đến mức timeout. PagerDuty vẫn im, nhưng latency tăng đều. Mở Grafana: goroutine count đang leo lên không dừng. Mình đã reproduce được bug đó ngay ngày hôm sau — và nó chỉ là 5 dòng code thiếu context.Done().
Go dễ bắt đầu. Nhưng có một khoảng cách khá lớn giữa “code chạy được” và “code chạy được 6 tháng mà không wake up ai lúc 2am”.
Context không phải chỉ để cancel request
Cái mình thấy nhiều nhất trong code review: pass context.Background() hoặc context.TODO() xuống tận DB layer, external API call. Kiểu như context chỉ là boilerplate để compiler vui.
Context có ba việc thực sự quan trọng:
1. Deadline propagation — timeout ở HTTP handler tự động cancel mọi thứ downstream.
func (h *Handler) GetUser(w http.ResponseWriter, r *http.Request) {
// Request có timeout 3s từ nginx/load balancer
// Context đó cần chạy xuyên suốt xuống DB
ctx := r.Context()
user, err := h.userRepo.FindByID(ctx, userID)
if err != nil {
// Nếu client disconnect trước 3s, ctx đã cancelled
// DB query tự cancel theo — không tốn resource nữa
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
// ...
}
func (r *UserRepo) FindByID(ctx context.Context, id int64) (*User, error) {
// context được pass vào query — Postgres driver biết cancel khi ctx done
row := r.db.QueryRowContext(ctx, "SELECT * FROM users WHERE id = $1", id)
// ...
}
2. Cancellation — khi anh em cần cancel manually, không đợi deadline.
func processItems(ctx context.Context, items []Item) error {
ctx, cancel := context.WithCancel(ctx)
defer cancel() // luôn defer cancel, không leak
for _, item := range items {
select {
case <-ctx.Done():
return ctx.Err() // caller đã cancel
default:
if err := process(ctx, item); err != nil {
cancel() // cancel các goroutine khác nếu có
return err
}
}
}
return nil
}
3. Value propagation — dùng cho request-scoped data như trace ID, user ID. Không lạm dụng cho business logic.
type ctxKey string
const TraceIDKey ctxKey = "trace_id"
func WithTraceID(ctx context.Context, traceID string) context.Context {
return context.WithValue(ctx, TraceIDKey, traceID)
}
func TraceIDFrom(ctx context.Context) string {
v, _ := ctx.Value(TraceIDKey).(string)
return v
}
// Middleware tự inject, handler tự lấy ra khi cần log
func TraceMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
traceID := r.Header.Get("X-Trace-ID")
if traceID == "" {
traceID = uuid.New().String()
}
ctx := WithTraceID(r.Context(), traceID)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
Rule ngắn: context.WithTimeout cho external calls, context.WithCancel khi anh em control cancellation, context.WithValue chỉ cho infra concerns (trace, auth).
Worker pool: đừng go func() không kiểm soát
Pattern hay gặp nhất gây goroutine leak:
// Nguy hiểm: tạo goroutine không giới hạn
func processAll(items []Item) {
for _, item := range items {
go process(item) // 10k items = 10k goroutines
}
}
10k items bình thường. Nhưng nếu process gọi một service đang slow, 10k goroutine block, memory tăng, và anh em có incident.
Worker pool giải quyết bằng cách giới hạn concurrency:
func processAll(ctx context.Context, items []Item, workers int) error {
jobs := make(chan Item, len(items))
errs := make(chan error, len(items))
// Spawn fixed số worker
var wg sync.WaitGroup
for i := 0; i < workers; i++ {
wg.Add(1)
go func() {
defer wg.Done()
for item := range jobs {
if err := process(ctx, item); err != nil {
errs <- err
}
}
}()
}
// Feed jobs
for _, item := range items {
jobs <- item
}
close(jobs) // signal workers không còn job nữa
wg.Wait()
close(errs)
// Collect errors
var errsOut []error
for err := range errs {
errsOut = append(errsOut, err)
}
return errors.Join(errsOut...)
}
Với context cancellation:
func processAll(ctx context.Context, items []Item, workers int) error {
jobs := make(chan Item)
errs := make(chan error, len(items))
var wg sync.WaitGroup
for i := 0; i < workers; i++ {
wg.Add(1)
go func() {
defer wg.Done()
for {
select {
case item, ok := <-jobs:
if !ok {
return
}
if err := process(ctx, item); err != nil {
errs <- err
}
case <-ctx.Done():
return
}
}
}()
}
go func() {
defer close(jobs)
for _, item := range items {
select {
case jobs <- item:
case <-ctx.Done():
return
}
}
}()
wg.Wait()
close(errs)
var errsOut []error
for err := range errs {
errsOut = append(errsOut, err)
}
return errors.Join(errsOut...)
}
workers thường là runtime.NumCPU() cho CPU-bound work, hoặc tune theo I/O throughput của external service cho I/O-bound work.
Error wrapping: %w thay đổi cách debug
Trước Go 1.13, error handling kiểu này rất phổ biến:
// Cũ: mất context hoàn toàn
if err != nil {
return fmt.Errorf("failed to get user: %s", err) // %s → string, mất type
}
Với %w, error được wrap — caller có thể unwrap và check type:
// Sentinel error — check bằng errors.Is
var ErrNotFound = errors.New("not found")
func (r *UserRepo) FindByID(ctx context.Context, id int64) (*User, error) {
row := r.db.QueryRowContext(ctx, "SELECT * FROM users WHERE id = $1", id)
var user User
if err := row.Scan(&user.ID, &user.Name, &user.Email); err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil, fmt.Errorf("user %d: %w", id, ErrNotFound) // wrap
}
return nil, fmt.Errorf("user %d scan: %w", id, err)
}
return &user, nil
}
// Caller có thể check sentinel qua chuỗi wrap bất kỳ độ sâu
func (h *Handler) GetUser(w http.ResponseWriter, r *http.Request) {
user, err := h.userRepo.FindByID(r.Context(), userID)
if err != nil {
if errors.Is(err, ErrNotFound) { // vẫn match dù đã wrap
http.Error(w, "not found", http.StatusNotFound)
return
}
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
// ...
}
Khi cần carry thêm data trong error, dùng custom error type:
type ValidationError struct {
Field string
Message string
}
func (e *ValidationError) Error() string {
return fmt.Sprintf("validation: %s — %s", e.Field, e.Message)
}
func validateAge(age int) error {
if age < 0 || age > 150 {
return &ValidationError{Field: "age", Message: "must be between 0 and 150"}
}
return nil
}
// errors.As để unwrap vào concrete type
func handleRequest(age int) {
if err := validateAge(age); err != nil {
var ve *ValidationError
if errors.As(err, &ve) { // unwrap qua chain
fmt.Printf("Bad field: %s\n", ve.Field)
}
}
}
Rule: dùng errors.Is cho sentinel errors (so sánh identity), errors.As khi cần access fields của concrete type.
sync.Once và sync.RWMutex: hai pattern hay bị dùng sai
sync.Once cho lazy initialization — đảm bảo đoạn code chỉ chạy đúng một lần, safe với concurrent access:
type DBPool struct {
once sync.Once
db *sql.DB
err error
}
func (p *DBPool) Get() (*sql.DB, error) {
p.once.Do(func() {
p.db, p.err = sql.Open("postgres", os.Getenv("DATABASE_URL"))
if p.err != nil {
return
}
p.db.SetMaxOpenConns(25)
p.db.SetMaxIdleConns(5)
p.db.SetConnMaxLifetime(5 * time.Minute)
})
return p.db, p.err
}
once.Do chỉ chạy một lần dù 1000 goroutine call Get() cùng lúc. Không cần lock bên ngoài.
sync.RWMutex cho workload đọc nhiều hơn ghi — nhiều reader có thể chạy đồng thời, writer thì exclusive:
type Cache struct {
mu sync.RWMutex
items map[string]Item
}
func (c *Cache) Get(key string) (Item, bool) {
c.mu.RLock() // nhiều goroutine có thể RLock cùng lúc
defer c.mu.RUnlock()
item, ok := c.items[key]
return item, ok
}
func (c *Cache) Set(key string, item Item) {
c.mu.Lock() // exclusive — block tất cả readers và writers
defer c.mu.Unlock()
c.items[key] = item
}
func (c *Cache) Delete(key string) {
c.mu.Lock()
defer c.mu.Unlock()
delete(c.items, key)
}
Dùng sync.Mutex (không phải RWMutex) khi write nhiều ngang đọc — overhead của RWMutex khi write-heavy thực ra cao hơn Mutex thông thường.
Kết
defer cancel()ngay sau mỗicontext.WithCancelhoặccontext.WithTimeout. Quên cái này là leak guarantee.- Worker pool thay vì unbounded goroutine spawn — pick
workers = runtime.NumCPU()làm default cho CPU-bound, tune theo benchmark cho I/O-bound. %wthay vì%skhi wrap error.errors.Ischo sentinel,errors.Ascho custom type. Không mất type qua call chain, debug dễ hơn nhiều.