defer, panic và recover trong Go: dùng khi nào

1. Tổng quan

Go không có try/catch. Thay vào đó, ngôn ngữ cung cấp ba công cụ: defer để dọn dẹp tài nguyên, panic để báo lỗi không thể phục hồi, và recover để chặn panic tại một ranh giới nhất định.

Bài này giải thích cơ chế của từng công cụ, các bẫy hay gặp, và quan trọng nhất là khi nào nên dùng, khi nào không. Đọc xong, bạn sẽ biết cách dùng defer đúng cách, biết lúc nào panic là hợp lý, và biết cách dùng recover để giữ cho server không sập vì một request lỗi.

2. defer

defer hoãn một lời gọi hàm cho tới khi hàm bao quanh kết thúc. Hàm có thể kết thúc bằng return bình thường hoặc do panic. Trong cả hai trường hợp, các lời gọi đã defer đều chạy.

2.1. Thứ tự thực thi LIFO

package main

import "fmt"

func main() {
	for i := 1; i <= 3; i++ {
		defer fmt.Println("defer", i)
	}
	fmt.Println("done")
}

Mỗi lần gặp defer, Go đẩy lời gọi vào một stack riêng của hàm. Khi main kết thúc, các lời gọi được lấy ra theo thứ tự ngược lại:

done
defer 3
defer 2
defer 1

Thứ tự LIFO này có lý do. Tài nguyên mở sau thường phụ thuộc vào tài nguyên mở trước. Ví dụ, bạn mở connection rồi mới mở transaction, nên transaction phải đóng trước connection.

2.2. Đối số được đánh giá ngay lập tức

Đây là bẫy đầu tiên. Đối số của hàm được defer được tính tại thời điểm gặp defer, chứ không phải lúc lời gọi thực sự chạy.

package main

import "fmt"

func main() {
	total := 10
	defer fmt.Println("deferred total:", total)

	defer func() {
		fmt.Println("closure total:", total)
	}()

	total = 20
	fmt.Println("current total:", total)
}
current total: 20
closure total: 20
deferred total: 10

Ở lời gọi thứ nhất, giá trị 10 được chụp lại ngay khi gặp defer. Closure ở lời gọi thứ hai thì khác. Nó tham chiếu tới biến total, nên khi chạy sẽ đọc giá trị mới nhất là 20.

Khi bạn cần giá trị cuối cùng, chẳng hạn để đo thời gian hay ghi log trạng thái, hãy dùng closure.

2.3. Sửa giá trị trả về bằng named result

Một deferred closure có thể đọc và ghi named result của hàm. Kỹ thuật này rất hữu ích khi Close() có thể trả về lỗi, ví dụ khi ghi file mà dữ liệu chỉ thực sự được flush lúc đóng.

package main

import (
	"errors"
	"fmt"
	"os"
)

func writeReport(path string, data []byte) (err error) {
	f, err := os.Create(path)
	if err != nil {
		return err
	}
	defer func() {
		err = errors.Join(err, f.Close())
	}()

	_, err = f.Write(data)
	return err
}

func main() {
	err := writeReport("/tmp/report.txt", []byte("Order #42: 3 items\n"))
	fmt.Println("error:", err)
}
error: <nil>

Câu lệnh return err gán giá trị vào biến err, sau đó deferred closure mới chạy. Closure gộp lỗi của f.Close() vào err bằng errors.Join, có từ Go 1.20. errors.Join trả về nil nếu cả hai lỗi đều nil.

Nếu bạn chỉ viết defer f.Close(), lỗi khi đóng file sẽ bị nuốt mất mà không ai biết.

Lưu ý: Với file chỉ đọc, defer f.Close() là đủ. Lỗi khi đóng một file read-only gần như không ảnh hưởng tới dữ liệu.

2.4. Bẫy: defer trong vòng lặp

func processAll(paths []string) error {
	for _, p := range paths {
		f, err := os.Open(p)
		if err != nil {
			return err
		}
		defer f.Close() // SAI: chỉ đóng khi processAll kết thúc
		// ... đọc f ...
	}
	return nil
}

defer gắn với hàm, không gắn với block hay vòng lặp. Với 10.000 file, đoạn code trên giữ 10.000 file descriptor mở cùng lúc và rất dễ gặp lỗi too many open files.

Cách sửa là tách thân vòng lặp ra một hàm riêng:

func processAll(paths []string) error {
	for _, p := range paths {
		if err := processFile(p); err != nil {
			return err
		}
	}
	return nil
}

func processFile(path string) error {
	f, err := os.Open(path)
	if err != nil {
		return err
	}
	defer f.Close() // đóng ngay khi processFile return
	// ... đọc f ...
	return nil
}

3. panic

panic dừng luồng thực thi bình thường của goroutine hiện tại. Go bắt đầu unwind stack: từng hàm return ngược lên, và các defer của mỗi hàm vẫn được chạy. Khi lên tới đỉnh stack của goroutine mà không có ai recover, chương trình in stack trace và thoát với mã 2.

3.1. Quan sát quá trình unwind

package main

import "fmt"

func main() {
	defer fmt.Println("main: deferred")
	processOrder()
	fmt.Println("never printed")
}

func processOrder() {
	defer fmt.Println("processOrder: deferred")
	panic("inventory service returned invalid state")
}
processOrder: deferred
main: deferred
panic: inventory service returned invalid state

goroutine 1 [running]:
main.processOrder()
	/tmp/main.go:13 +0x...
exit status 2

Bạn thấy cả hai defer đều chạy trước khi chương trình chết. Đây chính là lý do defer mu.Unlock() hay defer tx.Rollback() vẫn an toàn khi có panic.

Panic cũng có thể đến từ runtime chứ không chỉ từ lời gọi panic() tường minh. Các trường hợp thường gặp là truy cập index ngoài phạm vi, dereference con trỏ nil, ghi vào map nil, hoặc chia số nguyên cho 0.

3.2. Khi nào nên panic

Quy tắc chung của Go: lỗi có thể dự đoán thì trả về error. Ví dụ như file không tồn tại, input sai định dạng hay network timeout. Chỉ dùng panic trong vài trường hợp sau.

1. Lỗi lập trình, tức invariant bị vi phạm. Đây là trạng thái “không thể xảy ra” nếu code đúng, và tiếp tục chạy chỉ làm hỏng dữ liệu.

func (s OrderStatus) String() string {
	switch s {
	case Pending:
		return "PENDING"
	case Paid:
		return "PAID"
	case Shipped:
		return "SHIPPED"
	}
	panic(fmt.Sprintf("unknown OrderStatus: %d", s))
}

2. Hàm MustXxx dùng lúc khởi tạo. Thư viện chuẩn có sẵn regexp.MustCompile và template.Must. Bạn có thể viết hàm tương tự cho cấu hình:

package config

import (
	"fmt"
	"strconv"
)

func MustParsePort(s string) int {
	port, err := strconv.Atoi(s)
	if err != nil || port < 1 || port > 65535 {
		panic(fmt.Sprintf("invalid port %q", s))
	}
	return port
}

var emailPattern = regexp.MustCompile(`^[^@\s]+@[^@\s]+$`)

Nếu cấu hình sai thì ứng dụng không nên khởi động. Panic ngay lúc start-up giúp bạn phát hiện lỗi sớm, thay vì để server chạy với trạng thái hỏng.

Lưu ý: Không dùng MustXxx với dữ liệu từ người dùng hay request. Một input xấu không được phép làm sập cả process.

4. recover

recover() dừng quá trình unwind và trả về giá trị đã được truyền cho panic. Hàm này chỉ có tác dụng khi được gọi trực tiếp bên trong một deferred function. Gọi ở bất kỳ chỗ nào khác, nó trả về nil và không làm gì.

4.1. Chuyển panic thành error

package main

import "fmt"

func safeDivide(a, b int) (result int, err error) {
	defer func() {
		if r := recover(); r != nil {
			err = fmt.Errorf("safeDivide(%d, %d): %v", a, b, r)
		}
	}()
	return a / b, nil
}

func main() {
	fmt.Println(safeDivide(10, 2))
	fmt.Println(safeDivide(10, 0))
}
5 <nil>
0 safeDivide(10, 0): runtime error: integer divide by zero

Khi b == 0, runtime panic. Deferred closure bắt panic bằng recover() rồi gán vào named result err. Hàm return bình thường với result giữ giá trị zero là 0.

Ví dụ này chỉ để minh họa. Trong thực tế, bạn nên kiểm tra b == 0 và trả về error trực tiếp.

4.2. Bẫy: recover phải được gọi trực tiếp

func logPanic() {
	if r := recover(); r != nil {
		log.Println("recovered:", r)
	}
}

func handlerA() {
	defer logPanic() // OK: logPanic chính là deferred function
	panic("boom A")
}

func handlerB() {
	defer func() {
		logPanic() // KHÔNG hoạt động: recover nằm sâu thêm một tầng
	}()
	panic("boom B")
}

handlerA recover thành công. handlerB vẫn làm sập chương trình, vì recover được gọi bên trong logPanic, mà logPanic lại được gọi bởi deferred function chứ bản thân nó không phải deferred function.

4.3. Bẫy: recover không bắt panic của goroutine khác

Mỗi goroutine có stack riêng. Một panic trong goroutine con làm sập toàn bộ process, bất kể main có recover hay không.

func main() {
	defer func() { recover() }() // vô dụng với goroutine bên dưới

	go func() {
		panic("worker crashed") // process vẫn chết
	}()
	time.Sleep(time.Second)
}

Nếu bạn chạy job nền mà không kiểm soát được hết code bên trong, hãy bọc nó lại:

func SafeGo(name string, fn func()) {
	go func() {
		defer func() {
			if r := recover(); r != nil {
				log.Printf("goroutine %s panicked: %v\n%s", name, r, debug.Stack())
			}
		}()
		fn()
	}()
}

debug.Stack() nằm trong package runtime/debug. Nó giúp log đủ stack trace để bạn debug sau này.

4.4. Recover ở ranh giới HTTP

Một chỗ hợp lý để đặt recover là ranh giới request. Một request lỗi không nên kéo theo các request khác.

func RecoverMiddleware(next http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		defer func() {
			if rec := recover(); rec != nil {
				if rec == http.ErrAbortHandler {
					panic(rec) // để net/http xử lý abort như thiết kế
				}
				log.Printf("panic on %s %s: %v\n%s", r.Method, r.URL.Path, rec, debug.Stack())
				http.Error(w, `{"error":"internal server error"}`, http.StatusInternalServerError)
			}
		}()
		next.ServeHTTP(w, r)
	})
}

net/http vốn đã recover panic cho mỗi connection, nhưng nó chỉ log rồi đóng connection, nên client không nhận được response tử tế. Middleware ở trên trả về mã 500 rõ ràng, đồng thời ghi log kèm method và path để dễ tra cứu.

5. Viết test cho panic và recover

Go dùng package testing chuẩn, không cần dependency ngoài.

package config

import (
	"strings"
	"testing"
)

func TestSafeDivide_ByZero_ReturnsError(t *testing.T) {
	result, err := safeDivide(10, 0)

	if err == nil {
		t.Fatal("expected error, got nil")
	}
	if result != 0 {
		t.Errorf("expected result 0, got %d", result)
	}
	if !strings.Contains(err.Error(), "divide by zero") {
		t.Errorf("unexpected error message: %v", err)
	}
}

func TestMustParsePort_InvalidInput_Panics(t *testing.T) {
	defer func() {
		r := recover()
		if r == nil {
			t.Fatal("expected panic, got none")
		}
		if msg, _ := r.(string); !strings.Contains(msg, `"abc"`) {
			t.Errorf("unexpected panic value: %v", r)
		}
	}()

	MustParsePort("abc")
}

func TestMustParsePort_ValidInput(t *testing.T) {
	if got := MustParsePort("8080"); got != 8080 {
		t.Errorf("expected 8080, got %d", got)
	}
}
go test ./... -v
=== RUN   TestSafeDivide_ByZero_ReturnsError
--- PASS: TestSafeDivide_ByZero_ReturnsError (0.00s)
=== RUN   TestMustParsePort_InvalidInput_Panics
--- PASS: TestMustParsePort_InvalidInput_Panics (0.00s)
=== RUN   TestMustParsePort_ValidInput
--- PASS: TestMustParsePort_ValidInput (0.00s)
PASS

Test thứ hai dùng chính defer và recover để khẳng định hàm có panic, và panic với đúng thông điệp. Nếu bạn dùng testify, assert.PanicsWithValue cho kết quả tương tự với ít code hơn.

6. Khi nào KHÔNG nên dùng

  • Không dùng panic/recover như exception. Viết panic(ErrNotFound) rồi recover ở tầng trên sẽ làm luồng điều khiển khó đọc, và đi ngược quy ước của Go. Hãy trả về error.
  • Không panic trong thư viện public khi lỗi xuất phát từ input của caller. Người dùng thư viện của bạn không mong process của họ chết.
  • Không recover rồi im lặng bỏ qua. Ít nhất hãy log giá trị panic và stack trace. Nếu trạng thái đã hỏng, ví dụ dữ liệu dùng chung bị ghi dở, hãy để process chết rồi cho orchestrator restart.
  • Không defer trong vòng lặp dài (xem mục 2.4).
  • Đừng lo quá về hiệu năng của defer. Từ Go 1.14, open-coded defer gần như không tốn chi phí trong đa số trường hợp. Chỉ tối ưu khi profiler chỉ ra đó là điểm nóng.

Lưu ý: Từ Go 1.21, panic(nil) không còn làm recover() trả về nil nữa. Thay vào đó, recover() trả về *runtime.PanicNilError. Code cũ dùng recover() == nil để kiểm tra “không có panic” vì thế giờ chính xác hơn.

7. Kết luận

Ba công cụ này có vai trò rất khác nhau:

  • defer là công cụ hằng ngày để dọn dẹp tài nguyên. Hãy nhớ hai điều: đối số được đánh giá ngay khi gặp defer, và defer gắn với hàm chứ không gắn với vòng lặp. Kết hợp với named result, defer giúp bạn không bỏ sót lỗi từ Close().
  • panic dành cho lỗi lập trình và lỗi khởi tạo không thể phục hồi. Với các lỗi khác, hãy trả về error.
  • recover chỉ nên xuất hiện ở ranh giới: middleware HTTP, worker goroutine, hoặc chỗ gọi vào code bạn không kiểm soát. Nó phải được gọi trực tiếp trong deferred function, và chỉ bắt được panic của chính goroutine đó.

Nắm vững các ranh giới này, bạn sẽ viết được code Go vừa an toàn vừa đúng tinh thần của ngôn ngữ: lỗi là giá trị, còn panic là ngoại lệ thật sự.

golangdeferpanicrecovererror-handling