Bài 3: Xử lý ngoại lệ trong lúc hấp hối của exithook
Trong Bài 2, chúng ta đã thấy vòng lặp LIFO tĩnh lặng của hàm exithook.Run(code). Nó duyệt qua từng Hook và thực thi h.F(). Tuy nhiên, lập trình hệ thống (Systems Programming) không bao giờ cho phép bạn giả định rằng hàm callback (do người khác viết) sẽ luôn chạy an toàn.
Khoảnh khắc os.Exit được gọi, chương trình đang ở trong trạng thái "hấp hối" (teardown phase). Nếu một lỗi xảy ra ngay trong giai đoạn này, hậu quả có thể vô cùng thảm khốc: vòng lặp bị gián đoạn (các hook phía sau không được chạy), tiến trình bị treo cứng (zombie process), hoặc rơi vào đệ quy vô hạn.
Để hiểu cách các kỹ sư Go giải quyết bài toán này, chúng ta cần đào sâu hơn vào các biến trạng thái (state flags) ẩn bên trong exithook và cơ chế bảo vệ của Go Runtime.
1. Hiểm họa 1: Khi Hook bị Panic
Giả sử Hook số 1 có nhiệm vụ ghi dữ liệu vào một tệp (file). Nhưng tệp đó vô tình bị khóa bởi một tiến trình khác, dẫn đến việc hàm callback h.F() tung ra một panic.
Trong luồng chạy bình thường (Normal Return), panic sẽ kích hoạt quá trình thu hồi ngăn xếp (stack unwinding) và làm sập chương trình, in ra Stack Trace. Tuy nhiên, lúc này chúng ta ĐANG Ở TRONG hàm os.Exit. Tiến trình vốn dĩ đã chuẩn bị tắt rồi. Nếu panic làm gián đoạn vòng lặp Run, các Hook quan trọng khác (như Profiler, Coverage) sẽ bị bỏ qua.
Cách Go Core xử lý: Đáng ngạc nhiên là trong file mã nguồn hooks.go, đội ngũ Go... không làm gì cả (Không có khối defer func() { recover() } nào bọc quanh h.F()).
Tại sao? Vì triết lý của Go là: Lỗi hệ thống lúc tắt máy cần phải được báo cáo ồn ào nhất có thể.
-
Nếu một Hook bị Panic, vòng lặp
Runsẽ thực sự bị gián đoạn. -
Go Runtime sẽ ngay lập tức in ra toàn bộ Stack Trace của Panic đó ra
Stderr, đè lên tín hiệuos.Exit. -
Chương trình kết thúc với mã lỗi là mã của Panic (thường là 2), thay vì mã
codeban đầu truyền vàoos.Exit.
Đây là sự đánh đổi có chủ ý: Khép lại tiến trình trong im lặng (giấu panic đi) tồi tệ hơn việc bỏ qua vài cái Hook khác nhưng báo cho Lập trình viên biết rằng có một đoạn code đang bị hỏng nặng lúc shutdown.
2. Hiểm họa 2: Kẻ ngốc gọi lại os.Exit (Nested Exit)
Đây là ác mộng của đệ quy. Hãy tưởng tượng tình huống sau:
-
Bạn gọi
os.Exit(0). -
Hàm
exithook.Run()bắt đầu duyệt vòng lặp. -
Hook của một thư viện bên thứ ba chạy. Bên trong Hook đó, vì một lỗi ngớ ngẩn nào đó, lập trình viên lại gọi
os.Exit(1). -
Hàm
os.Exit(1)lại kích hoạtexithook.Run()từ đầu. -
Vòng lặp đệ quy vô hạn xảy ra, chương trình bị treo vĩnh viễn (Deadlock).
Để ngăn chặn tuyệt đối tình huống này, exithook sử dụng một State Machine (Máy trạng thái) vô hình thông qua hai cờ boolean.
Hãy xem đoạn mã nguồn thực tế (đã được lược giản cho dễ hiểu):
Go
var (
hooksMu sync.Mutex
hooks []Hook
// Cờ đánh dấu: Đang trong quá trình chạy Hook
running bool
// Cờ đánh dấu: Có một Panic đã xảy ra trong lúc chạy Hook
panicking bool
)
func Run(code int) {
hooksMu.Lock()
// KIỂM TRA ĐỆ QUY (NESTED EXIT)
if running {
hooksMu.Unlock()
// Nếu một Hook lại gọi tiếp os.Exit() -> Cấm chạy vòng lặp lại từ đầu
// Hàm Run sẽ return ngay lập tức, nhường quyền cho os.Exit gọi syscall kết liễu
return
}
running = true // Bật cờ khóa
list := hooks
hooks = nil
hooksMu.Unlock()
// Vòng lặp LIFO...
for i := len(list) - 1; i >= 0; i-- {
// ... (logic kiểm tra h.RunOnFailure)
list[i].F()
}
}
Bằng cách thiết lập cờ running = true, bất kỳ lệnh os.Exit() nào được gọi lồng bên trong một Hook sẽ bị phát hiện ngay lập tức. Lệnh os.Exit thứ hai đó sẽ bỏ qua exithook.Run và tiến thẳng tới án tử syscall.Exit.
3. Hiểm họa 3: Deadlock do Đa luồng (Race Condition)
Vì Go là ngôn ngữ xử lý đa luồng (Concurrency), hoàn toàn có khả năng:
-
Goroutine 1: Gặp lỗi, gọi
os.Exit(1). -
Goroutine 2 (cùng lúc đó): Chạy xong tác vụ, gọi
os.Exit(0).
Hàm exithook.Run() có một chiếc khóa hooksMu.Lock(). Goroutine 1 sẽ lấy được khóa trước, mảng hooks được lấy ra (copy sang biến list) và quan trọng nhất: mảng gốc hooks bị gán bằng nil.
Go
list := hooks
hooks = nil // Xóa sạch danh sách gốc
Khi Goroutine 2 chui vào được hàm Run (sau khi Goroutine 1 nhả khóa), nó sẽ thấy mảng hooks đã trống trơn. Nó không có Hook nào để chạy, nên nó sẽ return ngay lập tức và đi tới án tử.
Cơ chế "Copy và Nil" này đảm bảo rằng các hàm dọn dẹp hệ thống chỉ được thực thi đúng một lần duy nhất, bất kể có bao nhiêu Goroutine đồng thời cố gắng kéo sập chương trình.
Qua 3 bài học, bạn đã hiểu ngọn ngành cơ chế bảo vệ sự sống/chết của Go Runtime. Ở Bài 4 tiếp theo, chúng ta sẽ xem xét các "khách hàng" lớn nhất đang sử dụng bí thuật này trong thư viện chuẩn: testing/coverage và runtime/pprof
All rights reserved