Hình dung panic như cái nút DỪNG KHẨN màu đỏ trên một cỗ máy. Nó không dành cho "chi tiết này không vừa" — đó là việc của một error trả về; nó dành cho "máy hỏng về mặt cấu trúc, không chạy tiếp được". Nhấn nút là cả dây chuyền chạy trình tắt (mọi defer) rồi ngừng hẳn. Go có panic và recover, nhìn qua thì giống throw và catch — nhưng chúng không phải vậy, và dùng nhầm là cách nhanh nhất để viết mã Go trông như Java.
Panic dừng cả chương trình
panic("nổ")
Panic bắt đầu tháo ngăn xếp: mọi defer trên đường đi được chạy, rồi chương trình in dấu vết và thoát với mã 2.
Ba nguồn panic bạn sẽ gặp:
Lỗi lập trình lúc chạy — truy cập con trỏ nil, chỉ số ngoài mảng, chia số nguyên cho 0, ghi vào map nil (bài 10), type assertion sai không có comma-ok (bài 14).
Bạn tự gọi panic().
log.Fatal — thực ra không panic mà gọi os.Exit(1) ngay, nên defer không chạy. Đây là cắt thẳng cầu dao tổng, không có trình tắt. Chi tiết này quan trọng: log.Fatal trong hàm có defer f.Close() sẽ bỏ qua việc đóng tệp.
recover chỉ chạy trong defer
func phucHoi() (err error) {
defer func() {
if r := recover(); r != nil {
err = fmt.Errorf("đã phục hồi từ panic: %v", r)
}
}()
panic("nổ")
}
đã phục hồi từ panic: nổ
recover() gọi ở đâu khác đều trả về nil và không làm gì — người giám sát chỉ làm việc được từ chòi điều khiển (defer), không đứng rải rác khắp xưởng. Đây là ràng buộc cố ý: nó khiến bạn không thể rắc recover khắp nơi như try/catch.
Chú ý mẫu ở trên dùng named return để đổi giá trị trả về thành lỗi. Đây là một trong số ít trường hợp named return thật sự cần, như bài 7 đã nói.
Khi nào được phép panic
Danh sách rất ngắn:
Lỗi lập trình không thể tiếp tục — bất biến bị vi phạm, trạng thái không thể xảy ra. Ví dụ trong default của một switch mà mọi trường hợp đều đã liệt kê.
Khởi tạo hằng hỏng, lúc khởi động. Đây là quy ước Must...:
var re = regexp.MustCompile(`^[a-z]+$`)
Regex hằng sai là lỗi lập trình, và chết ngay lúc khởi động tốt hơn nhiều so với chết lúc phục vụ request — cái nút dừng khẩn nối sẵn để bật ngay lúc khởi động nếu một chi tiết lắp sai.
Trong hàm khởi tạo của package khi thiếu thứ bắt buộc.
Ngoài ba trường hợp đó: trả về error. Panic trong thư viện là hành vi bị coi là thô lỗ — bạn quyết định thay cho người dùng rằng chương trình của họ phải chết.
Khi nào được phép recover
Cũng rất ngắn:
Ở ranh giới ngoài cùng của một tác vụ độc lập — một handler HTTP, một job trong hàng đợi. Một request hỏng không nên giết cả máy chủ.
net/http đã làm sẵn việc này: panic trong handler chỉ giết request đó, không giết server. Nhưng nó chỉ in dấu vết ra stderr, nên bạn thường vẫn muốn middleware riêng để log cho tử tế:
func PhucHoi(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
defer func() {
if rec := recover(); rec != nil {
log.Printf("panic: %v\n%s", rec, debug.Stack())
http.Error(w, "lỗi máy chủ", 500)
}
}()
next.ServeHTTP(w, r)
})
}
debug.Stack() là phần quan trọng — không có nó bạn chỉ biết "có panic" mà không biết ở đâu.
Chuyển panic của thư viện bên thứ ba thành lỗi. Khi bạn buộc phải dùng một thư viện hay panic.
Ngoài ra: đừng recover. Đặc biệt đừng recover rồi tiếp tục như không có gì — trạng thái chương trình lúc đó không còn tin được.
Cái bẫy: panic trong goroutine
Đây là chỗ nguy hiểm nhất, và tôi sẽ nói kỹ hơn ở chặng đồng thời:
go func() {
panic("nổ trong goroutine")
}()
Panic trong một goroutine giết cả chương trình, và recover ở goroutine cha không bắt được — một ô máy không có người giám sát mà bị nhấn dừng khẩn thì cả nhà máy ngừng theo. Mỗi goroutine phải tự lo recover của mình:
go func() {
defer func() {
if r := recover(); r != nil { log.Printf("panic: %v", r) }
}()
lam()
}()
Đây là lý do các thư viện worker pool trong Go đều có sẵn cơ chế này.
Panic mang giá trị bất kỳ
panic(fmt.Errorf("lỗi: %w", err))
recover() trả về any, nên bạn kiểm kiểu như bài 14:
if r := recover(); r != nil {
if err, ok := r.(error); ok { ... }
}
Quy ước: panic bằng error hoặc chuỗi, không panic bằng kiểu tự chế — người recover sẽ không biết kiểm gì.
Nếu chỉ nhớ một chi tiết, để nó là chuyện log.Fatal bỏ qua defer — thử trong ba mươi giây:
func f() {
defer fmt.Println("defer CHẠY")
log.Fatal("chết")
}
Dòng defer không in ra. log.Fatal gọi os.Exit trực tiếp, cắt cầu dao tổng, bỏ qua toàn bộ cơ chế tháo ngăn xếp. Đó là lý do log.Fatal chỉ nên xuất hiện trong main(), không bao giờ trong thư viện.
Mẫu số chung
Đằng sau panic/recover là một lựa chọn triết lý mà ngôn ngữ trưởng thành nào cũng phải làm: tách "một thất bại mà người gọi được kỳ vọng xử lý" (lỗi trả về) khỏi "giả định của chương trình đã vỡ" (panic). Và Go cố ý làm recover vướng víu để bạn không dùng panic như luồng điều khiển. Rust chia y hệt: Result cho cái khôi phục được, panic! cho cái không; còn Java/Python/C# dồn cả hai vào exception và thế là người ta lạm dụng nó làm luồng điều khiển — đúng cái Go và Rust né. Dùng kênh-sự-cố cho luồng thường khiến đường thất bại trở nên vô hình (đúng điều các bài "lỗi là giá trị" nói) và làm ca thật-sự-vỡ lẫn với ca thường ngày.
Điều thứ hai đáng khắc sâu: bán kính sát thương của một cú sập là một quyết định thiết kế bạn phải biết. Panic của Go cuốn cả tiến trình (goroutine panic giết tất cả, recover không băng qua goroutine); Rust cô lập vào một luồng; Erlang cô lập vào một tiến trình được giám sát ("cứ để nó sập", có supervisor khởi động lại). Nên bạn phải bọc ranh giới của mỗi tác vụ độc lập — handler HTTP, worker, goroutine — bằng recover của riêng nó; đó là mẫu vách ngăn (bulkhead) giữ một request hỏng khỏi nhấn chìm cả con tàu. Sợi chỉ chung: panic chỉ cho "lẽ ra không thể xảy ra", trả error cho mọi thứ người gọi có thể gặp, và đặt một recover ở mỗi ranh giới tác vụ độc lập — vì bán kính sát thương mặc định, nhất là của một goroutine, rộng hơn bạn muốn.
Ngày mai: defer — thứ tự LIFO và cái bẫy đối số tính ngay.
Bài tập làm thử
Bài 1 (đọc hiểu). Đoạn mã sau in ra gì, và vì sao?
func f() {
defer fmt.Println("defer CHẠY")
log.Fatal("chết")
}
Đáp án
Chương trình in thông báo lỗi của log.Fatal ra và thoát ngay với mã khác 0 — dòng defer fmt.Println("defer CHẠY") không bao giờ in ra. log.Fatal không panic; nó gọi os.Exit(1) trực tiếp, cắt thẳng "cầu dao tổng", bỏ qua toàn bộ cơ chế tháo ngăn xếp (không chạy defer). Đây là lý do log.Fatal chỉ nên dùng trong main(), không bao giờ trong thư viện hay hàm có tài nguyên cần dọn bằng defer.
Bài 2 (sửa lỗi). Hàm sau muốn phục hồi panic và trả về lỗi thay vì để chương trình sập, nhưng nó không hoạt động. Tìm và sửa lỗi:
func antoan() (err error) {
if r := recover(); r != nil {
err = fmt.Errorf("đã phục hồi: %v", r)
}
panic("nổ")
}
Đáp án
recover() chỉ có tác dụng khi được gọi trực tiếp bên trong một hàm defer — gọi ở bất kỳ chỗ nào khác (kể cả trước dòng panic) luôn trả về nil và không làm gì. Ở đây recover() được gọi trước khi panic("nổ") xảy ra nên vô tác dụng, và panic vẫn tiếp tục làm sập chương trình. Sửa:
func antoan() (err error) {
defer func() {
if r := recover(); r != nil {
err = fmt.Errorf("đã phục hồi: %v", r)
}
}()
panic("nổ")
}
Bài 3 (vận dụng). Bạn có một goroutine chạy nền xử lý job. Nếu job đó panic, chương trình chính bị giết luôn (theo bài viết, panic trong goroutine giết cả chương trình và không thể recover từ goroutine cha). Viết lại đoạn mã khởi động goroutine để một job hỏng chỉ log lỗi mà không giết cả tiến trình.
go func() {
xuLyJob(job)
}()
Đáp án
go func() {
defer func() {
if r := recover(); r != nil {
log.Printf("panic: %v", r)
}
}()
xuLyJob(job)
}()
Mỗi goroutine phải tự lo recover của chính mình — không có cách nào bắt panic từ một goroutine khác ở goroutine cha.
Bài 4 (bẫy/đánh đổi). Bài viết liệt kê ba trường hợp được phép panic và nói rằng panic trong thư viện dùng chung là "hành vi bị coi là thô lỗ". Giải thích tại sao một hàm thư viện công khai như sau lại là thiết kế tồi, và nên sửa thế nào:
func PhanTich(input string) Ket {
if input == "" {
panic("input rỗng")
}
...
}
Đáp án
input rỗng là một lỗi mà người gọi hoàn toàn có thể lường trước và xử lý (không phải "lỗi cấu trúc không chạy tiếp được") — đây thuộc nhóm nên trả về error, không phải panic. Panic trong thư viện tước mất quyền quyết định của người dùng: nó buộc chương trình của họ phải sập (hoặc buộc họ phải tự bọc recover quanh mọi lời gọi tới hàm của bạn) thay vì được tự xử lý lỗi theo ý mình. Sửa:
func PhanTich(input string) (Ket, error) {
if input == "" {
return Ket{}, errors.New("input rỗng")
}
...
}
Bài 5 (đọc hiểu/vận dụng). Dòng mã sau dùng quy ước Must... — giải thích khi nào panic ở đây là hợp lý, và vì sao nó KHÁC với ví dụ ở Bài 4 dù cả hai đều là panic khi gặp input không hợp lệ:
var re = regexp.MustCompile(`^[a-z]+$`)
Đáp án
Đây thuộc nhóm "khởi tạo hằng hỏng lúc khởi động" — một trong ba trường hợp được phép panic. Biểu thức chính quy ở đây là một hằng số do lập trình viên viết, không phải input từ người dùng lúc chạy; nếu nó sai cú pháp, đó là lỗi lập trình (bug) chứ không phải tình huống người dùng cuối có thể gặp phải khi hệ thống đang phục vụ request. Panic ngay lúc khởi động (khi package được nạp) khiến lỗi lộ ra ngay lập tức trong môi trường phát triển/CI, tốt hơn nhiều so với việc nó âm thầm tồn tại rồi làm sập server giữa lúc đang phục vụ request thật. Khác với Bài 4, nơi input đến từ người dùng lúc runtime và là tình huống hoàn toàn bình thường cần xử lý bằng error.