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

Table of Contents

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.

Đó là lần đầu mình hiểu context.Context không phải mấy dòng code cho có. Dùng sai là chết thật. Dùng đúng thì request dừng gọn, goroutine sạch, hệ thống nhẹ nhàng.

Dưới đây là những gì mình học được sau vài lần “chết” vì context - tất cả có code Go chạy được.

![Context trong Go(https://blog-bucket.luandnh.com/images/covers/go-context.png)

Context làm đúng 3 việc - đừng biến nó thành cái túi rác

Trước khi đi vào code, phải rõ context để làm gì:

  1. Cancellation - báo cho goroutine dừng khi request bị huỷ
  2. Deadline/Timeout - giới hạn thời gian cho tác vụ
  3. Truyền metadata nhẹ - request ID, user ID, trace ID

Không phải: kho chứa object nặng, không phải dependency injection container. Mình từng thấy codebase nhét cả DB connection vào context. Khóc.

Một câu thần chú để tự kiểm tra: “Giá trị này có thực sự là ngữ cảnh thực thi, hay chỉ là tham số nghiệp vụ?” Nếu là tham số nghiệp vụ → truyền qua function parameter. Đừng nhét vào context.

Truyền dữ liệu đúng cách với context.Value

package ctxkeys

type key string

const (
    RequestID key = "request_id"
    UserID    key = "user_id"
    Locale    key = "locale"
)

Dùng typed key để tránh collision - string rỗng "" là recipe cho bug khó debug nhất mình từng gặp.

ctx = context.WithValue(ctx, ctxkeys.RequestID, reqID)

// Extract với type assertion - luôn check ok
if id, ok := ctx.Value(ctxkeys.UserID).(string); ok {
    log.Printf("[%s Processing request", id)
}

3 nguyên tắc mình tuân thủ tuyệt đối:

  • ✅ Metadata nhỏ (< 1KB): request_id, user_id, trace_id, locale
  • ❌ Object nặng: DB pool, Redis client, config struct → truyền qua constructor
  • ❌ Business data: order ID, product filter, query params → truyền qua function params
  • ❌ Đọc ctx.Value trong hot loop → extract ra biến local một lần, xong mới loop

Mình từng thấy code lấy ctx.Value trong vòng lặp 10K items. CPU profile cho thấy 15% thời gian nằm ở type assertion của ctx.Value. Extract ra ngoài loop, 15% biến mất.

Deadline và Timeout

// Timeout cho DB query
ctx, cancel := context.WithTimeout(ctx, 3*time.Second)
defer cancel()

rows, err := db.QueryContext(ctx, "SELECT * FROM orders WHERE user_id = $1", userID)

Cái defer cancel() quan trọng hơn bạn nghĩ. Không gọi cancel → context leak → timer resource không được giải phóng cho đến khi timeout hết. Trong service nhận 1K req/s, mỗi cái leak 3 giây timer… tự tính đi.

Deadline từ upstream nên truyền xuống, không override:

func (s *Service) GetOrder(ctx context.Context, orderID string) (*Order, error) {
    // KHÔNG làm thế này:
    // ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)

    // LÀM thế này - respect deadline từ caller:
    ctx, cancel := context.WithTimeout(ctx, 5*time.Second)
    defer cancel()

    return s.repo.GetOrder(ctx, orderID)
}

Nếu client đã set deadline 2s, gói thêm WithTimeout(ctx, 5s) thì deadline vẫn là 2s (lấy cái ngắn hơn). Nhưng nếu bạn dùng context.Background() làm parent - bạn đã cắt đứt chain và vô hiệu hóa deadline của client. Lỗi này mình mất 2 tiếng debug.

Cancel khi không cần kết quả nữa

ctx, cancel := context.WithCancel(ctx)
defer cancel()

go func() {
    select {
    case <-ctx.Done():
        return // cleanup, không leak
    case result := <-ch:
        process(result)
    }
}()

Truyền context qua các layer

// gRPC handler
func (s *Server) GetMenu(ctx context.Context, req *pb.MenuRequest) (*pb.MenuResponse, error) {
    // ctx được truyền xuyên suốt
    merchant, err := s.merchantRepo.GetByID(ctx, req.MerchantId)
    if err != nil {
        return nil, err
    }

    items, err := s.menuService.ListItems(ctx, merchant.ID, req.Filters)
    if err != nil {
        return nil, err
    }

    return &pb.MenuResponse{Items: items}, nil
}

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

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
}

Một goroutine lỗi → ctx cancel → 2 goroutine còn lại dừng sớm. Sạch.

Worker pool + context

func worker(ctx context.Context, id int, jobs <-chan Job, results chan<- Result) {
    for {
        select {
        case <-ctx.Done():
            log.Printf("worker %d: shutting down", id)
            return
        case job, ok := <-jobs:
            if !ok {
                return
            }
            results <- process(job)
        }
    }
}

Trace: luôn extract từ context

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

    // Truyền ctx xuống - trace ID tự động propagate
    return s.service.Process(ctx, req)
}

Không cần extract trace ID thủ công rồi bỏ vào log - context làm việc đó tự động nếu bạn setup instrumentation đúng.

Test với context

func TestGetOrder_WithTimeout(t *testing.T) {
    ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
    defer cancel()

    _, err := svc.GetOrder(ctx, "slow-order-123")
    assert.ErrorIs(t, err, context.DeadlineExceeded)
}

Cheatsheet

ViệcDùngKhông dùng
Request-scoped metadatactx.Value❌ Business data
Deadline/Timeoutcontext.WithTimeoutcontext.Background override
Cancel goroutinectx.Done() + select❌ Quên defer cancel()
Truyền qua layer✅ Function param❌ Global variable

Bottom line

Context trong Go không khó. Cái khó là kỷ luật: luôn truyền context xuống, luôn gọi defer cancel(), không nhét rác vào context.Value, không override deadline từ upstream. Mình mất vài bug production mới nhớ mấy cái này. Hy vọng bạn đọc xong không phải trả giá như mình.

Bạn đã từng debug cái bug “goroutine leak do quên cancel context” bao giờ chưa? Hay team bạn có convention gì về context.Value không? Mình tò mò cách team khác handle cái này lắm - kể mình nghe với. 🦞

Share :