Bài 2: Cấu trúc Hook và thuật toán LIFO - Mổ xẻ mã nguồn
Để hiểu rõ cách đội ngũ Go Core "hack" vào chính luồng sinh tử của hệ điều hành, chúng ta sẽ mở trực tiếp mã nguồn của Go. File mà chúng ta đang phân tích nằm tại đường dẫn src/internal/runtime/exithook/hooks.go trong bộ cài đặt Go (từ phiên bản 1.21 trở lên).
Bạn sẽ bất ngờ khi thấy file mã nguồn cốt lõi này lại cực kỳ ngắn gọn và đơn giản. Nó xoay quanh một vài cấu trúc dữ liệu và hai hàm chính: Add và Run.
1. Định nghĩa Hook - Tấm thẻ bài miễn tử
Bên trong package exithook, Go định nghĩa một cấu trúc dữ liệu duy nhất mang tên Hook. Cấu trúc này đóng vai trò như một bản hợp đồng, quy định chính xác "ai" sẽ được chạy và chạy trong "hoàn cảnh" nào.
Go
package exithook
// Hook định nghĩa một hàm sẽ được thực thi khi chương trình gọi os.Exit.
type Hook struct {
// F là hàm callback sẽ được thực thi.
F func()
// RunOnFailure quyết định xem hàm F có được chạy hay không
// nếu os.Exit được gọi với mã lỗi (code != 0).
RunOnFailure bool
}
Tại sao lại có cờ RunOnFailure? Khi một chương trình thoát với mã 0 (thành công), mọi thứ đều bình thường. Nhưng khi thoát với mã 1 hoặc mã lỗi khác, nghĩa là chương trình đang trong trạng thái "bất ổn".
-
Một số công cụ (như hệ thống ghi báo cáo Coverage) chỉ muốn lưu dữ liệu nếu test pass (không lỗi). Chúng sẽ set
RunOnFailure = false. -
Một số công cụ khác (như Profiling - đo lường CPU) lại muốn bắt bằng được khoảnh khắc hệ thống chết để xem tại sao chết. Chúng sẽ set
RunOnFailure = trueđể ép runtime phải xả (flush) dữ liệu ra file trước khi sập hoàn toàn.
2. Cơ chế Đăng ký (Hàm Add)
Để quản lý danh sách các Hook, Go sử dụng một biến toàn cục dạng Slice và một Mutex để bảo vệ nó khỏi lỗi Race Condition (vì os.Exit hoặc quá trình khởi tạo có thể xảy ra ở bất kỳ Goroutine nào, chạy song song).
Go
import "sync"
var (
hooksMu sync.Mutex
hooks []Hook
)
// Add đăng ký một Hook mới.
// Hàm này thường được gọi trong các khối func init() của các package nội bộ.
func Add(h Hook) {
hooksMu.Lock()
defer hooksMu.Unlock()
hooks = append(hooks, h)
}
Nhờ cơ chế này, khi các package như runtime/pprof hay testing được import, các hàm init() của chúng sẽ ngầm gọi exithook.Add() để "xếp hàng" chờ sẵn, mà lập trình viên Application (như chúng ta) không hề hay biết.
3. Vòng lặp tử thần (Hàm Run) và thuật toán LIFO
Đây là nơi điều kỳ diệu xảy ra. Hàm exithook.Run(code int) được gọi trực tiếp từ bên trong hàm os.Exit (thuộc package os), ngay trước dòng lệnh gọi Syscall tắt tiến trình.
Hãy quan sát kỹ cách vòng lặp trong hàm Run hoạt động:
Go
// Run sẽ thực thi tất cả các hook đã đăng ký.
// Tham số 'code' chính là số nguyên truyền vào os.Exit(code).
func Run(code int) {
hooksMu.Lock()
// Lấy danh sách hook ra và nhả Lock ngay lập tức,
// tránh việc một hook đang chạy lại gọi thêm Add() gây Deadlock.
list := hooks
hooks = nil
hooksMu.Unlock()
// Thuật toán LIFO (Last-In, First-Out)
// Vòng lặp duyệt ngược từ phần tử cuối cùng về phần tử đầu tiên
for i := len(list) - 1; i >= 0; i-- {
h := list[i]
// Đánh giá cờ RunOnFailure
if code != 0 && !h.RunOnFailure {
continue // Nếu thoát do lỗi mà hook không muốn chạy lúc lỗi -> Bỏ qua
}
// Thực thi Hook!
h.F()
}
}
Tại sao phải duyệt ngược (LIFO)? Đây là một quy tắc thiết kế hệ thống cực kỳ chuẩn mực trong Khoa học máy tính (tương tự như cách Destructor của C++ hoạt động, hoặc cách từ khóa defer của Go hoạt động).
Nguyên lý là: "Kẻ sinh ra sau cùng, thường phụ thuộc vào kẻ sinh ra trước đó".
-
Giả sử Subsystem A (Ví dụ: Logger) khởi tạo trước -> Đăng ký Hook 1.
-
Subsystem B (Ví dụ: Profiler) khởi tạo sau, và nó cần dùng Logger để ghi lại trạng thái của nó -> Đăng ký Hook 2.
-
Khi chương trình sập, nếu ta tắt theo thứ tự xuôi (FIFO), Hook 1 chạy trước (tắt Logger). Đến lượt Hook 2 chạy (Profiler), nó cố gắng gọi Logger nhưng Logger đã chết -> Crash lần 2!
-
Vì vậy, hệ thống phải tắt ngược (LIFO): Hook 2 (Profiler) chạy trước để dọn dẹp, xả dữ liệu xuống Logger. Xong xuôi, Hook 1 (Logger) mới chạy để đóng file log lại. Mọi thứ an toàn tuyệt đối.
4. Mối liên kết ngầm với os.Exit
Nếu bạn mở package os trong mã nguồn Go (file proc.go), bạn sẽ thấy hàm os.Exit đã được viết lại một cách tinh tế như sau:
Go
package os
import "internal/runtime/exithook"
func Exit(code int) {
// 1. Kích hoạt toàn bộ "hệ thống hỗ trợ sinh tồn"
exithook.Run(code)
// 2. Chết thực sự (Gọi System Call xuống HĐH)
syscall.Exit(code)
}
Đến đây, bạn đã nắm được lõi kỹ thuật của exithook. Nó là một mảng Slice, chạy vòng lặp ngược, và gọi hàm callback. Nhưng thiết kế hệ thống không bao giờ trải đầy hoa hồng. Điều gì sẽ xảy ra nếu chính cái hàm h.F() của một Hook lại bị hoảng loạn (Panic) trong lúc hệ thống đang hấp hối? Hệ thống sẽ treo vĩnh viễn (Deadlock) hay vỡ vụn?
All rights reserved