0

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 Run sẽ 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ệu os.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ã code ban đầu truyền vào os.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:

  1. Bạn gọi os.Exit(0).

  2. Hàm exithook.Run() bắt đầu duyệt vòng lặp.

  3. 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).

  4. Hàm os.Exit(1) lại kích hoạt exithook.Run() từ đầu.

  5. 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

Viblo
Hãy đăng ký một tài khoản Viblo để nhận được nhiều bài viết thú vị hơn.
Đăng kí