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 — cancel khi client disconnect, và deadline explicit thì propagate xuống mọi thứ downstream.
Chỗ hay bị hiểu nhầm: timeout của nginx/load balancer không tự động tạo ra deadline trên context.Context phía Go. r.Context() chỉ tự cancel khi client đóng connection hoặc handler return — nó không “biết” con số 3s cấu hình bên nginx. Muốn có deadline thật trong Go, phải khai báo explicit:
func (h *Handler) GetUser(w http.ResponseWriter, r *http.Request) {
// Không dựa vào timeout của proxy — set deadline explicit ở đây
ctx, cancel := context.WithTimeout(r.Context(), 3*time.Second)
defer cancel()
user, err := h.userRepo.FindByID(ctx, userID)
if err != nil {
// Nếu client disconnect, hoặc quá 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)
// ...
}
r.Context() tự cancel khi client disconnect — cái đó đúng và miễn phí. Nhưng nếu anh em muốn một deadline cứng bất kể client có kiên nhẫn chờ hay không, phải tự WithTimeout. Đừng giả định proxy timeout và Go context deadline là một.
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. Chú ý validate workers > 0 trước — quên bước này thì loop spawn không chạy, nhưng job feed vẫn ghi vào channel như bình thường nên deadlock (buffered channel còn dễ tha, unbuffered thì treo chắc chắn):
func processAll(ctx context.Context, items []Item, workers int) error {
if workers <= 0 {
return fmt.Errorf("workers must be > 0, got %d", workers)
}
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 — khi ctx bị cancel giữa chừng, kết quả trả về nên phản ánh đúng lý do (ctx.Err()), không chỉ im lặng trả về các error đã thu thập được:
func processAll(ctx context.Context, items []Item, workers int) error {
if workers <= 0 {
return fmt.Errorf("workers must be > 0, got %d", workers)
}
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)
}
// ctx.Err() != nil nghĩa là mình dừng giữa chừng vì cancel/timeout,
// không phải vì xử lý xong hết — caller cần biết sự khác biệt này
if ctx.Err() != nil {
errsOut = append(errsOut, ctx.Err())
}
return errors.Join(errsOut...)
}
Pattern spawn + sync.WaitGroup + error channel này chính là thứ package golang.org/x/sync/errgroup đóng gói sẵn. Nếu không cần tuning per-worker channel như trên, errgroup.WithContext thường gọn hơn — nó tự cancel context khi một goroutine lỗi, và tự gom lỗi đầu tiên mà không cần error channel tay.
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.
(Các snippet trên là excerpt minh họa cho pattern — Item, process() là placeholder, không phải code chạy được nguyên khối.)
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(ctx context.Context) (*sql.DB, error) {
p.once.Do(func() {
// sql.Open tạo *sql.DB (pool handle) và thường chưa mở connection
// thật; việc validate DSN/arguments phụ thuộc driver. Muốn biết DB
// có connect được không, phải Ping/PingContext.
p.db, p.err = sql.Open("postgres", os.Getenv("DATABASE_URL"))
if p.err != nil {
return
}
if p.err = p.db.PingContext(ctx); p.err != nil {
_ = p.db.Close()
p.db = 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 — nhưng để ý: nếu lần gọi đầu tiên fail (DB down lúc startup chẳng hạn), sync.Once vẫn đánh dấu là “đã chạy”. Mọi lần gọi Get() sau đó nhận lại đúng cái lỗi cũ, kể cả khi DB đã sống lại. sync.Once không tự retry — nếu cần retry thì phải tự quản lý state đó, không nhờ được Once.
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 thì context (và goroutine đang chờ nó) có thể sống tới khi parent context bị hủy hoặc process dừng — với timeout ngắn hạn, resource cuối cùng vẫn được reclaim, nhưng đó là kiểu leak âm thầm mà anh em không muốn debug lúc 2am.- Worker pool thay vì unbounded goroutine spawn — validate
workers > 0trước khi spawn. Pickworkers = runtime.NumCPU()làm default cho CPU-bound, tune theo benchmark cho I/O-bound. Nếu logic đơn giản,errgroup.WithContextthường sạch hơn tự viết channel + WaitGroup. %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.