runtime.SetFinalizer cho phép đăng ký một hàm sẽ chạy khi một object trở thành rác. Nghe rất tiện: "đặt finalizer để tự động đóng file/kết nối khi object bị thu hồi". Nhiều lập trình viên đến từ ngôn ngữ có destructor bị cám dỗ dùng nó như vậy. Đây là một sai lầm. Bài này đo trực tiếp ba cạm bẫy khiến finalizer trở thành công cụ nguy hiểm, và giải thích khi nào (hiếm khi) nên dùng nó.
Finalizer là gì
runtime.SetFinalizer(obj, fn) bảo runtime: "khi obj không còn ai tham chiếu, hãy chạy fn(obj)". Hàm fn chạy trên một goroutine riêng, ở một thời điểm nào đó sau khi GC phát hiện obj là rác.
t := &Tai{id: i}
runtime.SetFinalizer(t, func(x *Tai) { atomic.AddInt64(&chay, 1) })
t = nil // bỏ tham chiếu -> object đủ điều kiện thu hồi
Nghe đơn giản, nhưng ba từ khoá — "một thời điểm nào đó", "sau khi GC", "goroutine riêng" — chứa toàn bộ vấn đề.

Hình 1: SetFinalizer đăng ký hàm chạy khi object thành rác. Ba cạm bẫy và lý do đừng dùng nó để giải phóng tài nguyên.
Đo thật: ba cạm bẫy
Cạm bẫy 1 — chỉ chạy sau GC, bất đồng bộ. Tạo 10.000 object có finalizer rồi bỏ tham chiếu, đếm số finalizer chạy:
Cạm bẫy 2 — giữ object sống thêm một chu kỳ GC. So sánh thu hồi bộ nhớ có và không finalizer.
Cạm bẫy 3 — có thể không chạy lúc thoát. Chương trình đăng ký finalizer rồi thoát ngay không GC.

Hình 2: (1) Finalizer chạy = 0 trước GC, 9998 ngay sau GC (bất đồng bộ), 9999 sau GC 2. (2) Có finalizer: 19 MB sau GC1, 1 MB sau GC2 — cần 2 chu kỳ; không finalizer: 0 MB ngay. (3) Chương trình thoát không GC → finalizer không in ra.
Kết quả rõ ba vấn đề:
- Bất đồng bộ và chỉ sau GC: trước GC, 0 finalizer chạy. Ngay sau GC lần 1, 9998/10000 chạy — không phải tất cả, vì goroutine finalizer còn đang xử lý. Bạn không kiểm soát được khi nào và có chạy hết không trong một khoảnh khắc.
- Giữ object sống thêm một chu kỳ: object có finalizer chiếm 19 MB sau GC1 (vẫn sống, chờ chạy finalizer), chỉ về 1 MB sau GC2. Object không finalizer về 0 MB ngay GC1. Finalizer buộc object sống sót một chu kỳ GC thêm — chi phí bộ nhớ và độ trễ thật.
- Không chạy lúc thoát: chương trình đăng ký finalizer rồi thoát ngay không GC — dòng "FINALIZER DA CHAY!" không bao giờ in ra. Finalizer bị bỏ hoàn toàn.
Vì sao đừng dùng để giải phóng tài nguyên
Ghép ba cạm bẫy lại là thấy tại sao dùng finalizer để đóng file, kết nối, hay flush buffer là sai lầm nguy hiểm:
- Nếu bạn dựa vào finalizer để đóng file, file có thể bị mở rất lâu (tới khi GC chạy), hoặc không bao giờ đóng (chương trình thoát) — rò file descriptor.
- Nếu dựa vào finalizer để flush buffer ghi dữ liệu, dữ liệu có thể mất khi chương trình thoát trước GC.
- Vì finalizer chạy bất đồng bộ trên goroutine khác, thứ tự dọn dẹp không đảm bảo — tài nguyên có thể được giải phóng theo thứ tự sai.
Cách đúng luôn là Close() tường minh + defer. Nó chạy ngay lập tức, đồng bộ, và đảm bảo chạy (kể cả khi panic). Đây là lý do mọi API tài nguyên trong Go (file, kết nối, response body) đều có Close() và mẫu defer x.Close().
Ứng dụng thực tế
Finalizer chỉ nên là lưới an toàn cuối cùng. Một số thư viện đặt finalizer bổ sung để cảnh báo (hoặc dọn) khi người dùng quên gọi Close() — nhưng đó là phòng thủ, không phải cơ chế chính. os.File của thư viện chuẩn có finalizer đóng fd nếu bạn quên, nhưng bạn vẫn phải gọi Close() — đừng dựa vào lưới an toàn đó.
Đừng đặt finalizer trên object có vòng tham chiếu. Nếu hai object có finalizer trỏ vòng vào nhau, runtime không biết chạy finalizer nào trước, nên nó không chạy cái nào và cả hai không bao giờ được thu hồi — một dạng rò rỉ. Đây là lý do nữa để tránh finalizer.
Nếu buộc phải dùng, tách object khỏi tài nguyên. Mẫu an toàn hơn: đặt finalizer trên một object nhỏ bọc tài nguyên, không phải object lớn — để giảm chi phí "sống thêm một chu kỳ". Nhưng ngay cả vậy, ưu tiên Close() tường minh.
Đánh đổi cần cân nhắc
Finalizer có công dụng hợp lệ hẹp. Nó phù hợp cho việc dọn tài nguyên không quan trọng về thời điểm và không thể dùng Close — ví dụ giải phóng bộ nhớ do C cấp phát (qua cgo) khi Go object bao quanh nó bị thu hồi, mà không có điểm gọi Close rõ ràng. Nhưng đây là trường hợp hiếm và chuyên biệt.
runtime.AddCleanup (Go 1.24+) tốt hơn finalizer. Go 1.24 thêm runtime.AddCleanup — an toàn hơn SetFinalizer (không giữ object sống thêm chu kỳ theo cách gây rò với vòng tham chiếu, cho phép nhiều cleanup). Nếu ở Go 1.24+, ưu tiên nó. Container này là Go 1.23 nên minh hoạ SetFinalizer, nhưng biết AddCleanup là hướng đi mới.
Đừng nhầm finalizer với destructor. Lập trình viên C++/Rust quen destructor chạy tất định khi object ra khỏi scope. Finalizer của Go không như vậy — nó là "có thể chạy, lúc nào đó, sau GC". Mang tư duy destructor vào finalizer là nguồn gốc của mọi bug.
Ba ý mang về
- Finalizer chỉ chạy sau GC, bất đồng bộ, không đảm bảo thời điểm: đo thật, 0 finalizer chạy trước GC, 9998/10000 ngay sau GC (goroutine finalizer còn xử lý) — bạn không kiểm soát được khi nào và có chạy hết không.
- Finalizer giữ object sống thêm một chu kỳ GC và có thể không chạy lúc thoát: đo thật, object có finalizer chiếm 19 MB sau GC1 (vs 0 MB không finalizer), và finalizer không in gì khi chương trình thoát trước GC — chi phí bộ nhớ thật và rủi ro không dọn.
- Đừng dùng finalizer để giải phóng tài nguyên: file/kết nối có thể rò vì finalizer chạy muộn hoặc không chạy — luôn dùng
Close()tường minh +defer; finalizer chỉ là lưới an toàn cuối, và Go 1.24+ córuntime.AddCleanuptốt hơn.
Bài tập làm thử
Bài 1 (đọc hiểu). Đoạn code sau đăng ký một finalizer. Giải thích chính xác finalizer sẽ chạy khi nào, theo ba từ khoá mà bài nhấn mạnh.
t := &Tai{id: i}
runtime.SetFinalizer(t, func(x *Tai) { atomic.AddInt64(&chay, 1) })
t = nil
Đáp án
Ba từ khoá: "một thời điểm nào đó" (không tất định), "sau khi GC" (chỉ chạy sau khi GC phát hiện t là rác, không chạy ngay khi t = nil), và "goroutine riêng" (hàm finalizer chạy bất đồng bộ trên một goroutine khác, không đồng bộ với luồng chính). Tức là không có gì đảm bảo finalizer chạy ngay, chạy đúng lúc, hay chạy trên cùng goroutine.
Bài 2 (đọc hiểu số liệu). Bài đo 10.000 object có finalizer: trước GC finalizer chạy = 0, ngay sau GC lần 1 chạy = 9998/10000, sau GC lần 2 mới lên 9999. Giải thích tại sao con số không nhảy thẳng lên 10000 ngay sau GC lần 1.
Đáp án
Vì finalizer chạy bất đồng bộ trên goroutine riêng của runtime — GC chỉ phát hiện object là rác và xếp hàng chạy finalizer, chứ không chạy đồng bộ ngay lúc đó. Goroutine finalizer cần thời gian để xử lý hết hàng đợi, nên ngay sau GC lần 1 chỉ mới 9998/10000 đã chạy xong, còn lại xử lý tiếp và lên 9999 ở lần đo sau — thể hiện đúng tính chất "không kiểm soát được khi nào và có chạy hết không tại một khoảnh khắc".
Bài 3 (sửa lỗi). Đoạn code dưới đây dùng finalizer để "tự động đóng file" — đúng kiểu sai lầm mà bài cảnh báo. Sửa lại theo cách đúng mà bài đề xuất.
func moFile(path string) *os.File {
f, _ := os.Open(path)
runtime.SetFinalizer(f, func(x *os.File) { x.Close() })
return f
}
func doc(path string) {
f := moFile(path)
// đọc dữ liệu từ f...
}
Đáp án
Không dựa vào finalizer để đóng file — nó có thể chạy rất muộn (tới khi GC chạy) hoặc không bao giờ chạy (chương trình thoát trước GC), gây rò file descriptor. Cách đúng là Close() tường minh kèm defer:
func doc(path string) {
f, _ := os.Open(path)
defer f.Close()
// đọc dữ liệu từ f...
}
defer f.Close() chạy ngay lập tức, đồng bộ, và đảm bảo chạy kể cả khi panic.
Bài 4 (vận dụng thực tế). Bài nói "Finalizer chỉ nên là lưới an toàn cuối cùng", lấy ví dụ os.File của thư viện chuẩn. Giải thích ý nghĩa của việc này — nó có nghĩa là bạn không cần gọi Close() nữa không?
Đáp án
Không. os.File có đặt finalizer để đóng fd phòng khi người dùng quên gọi Close(), nhưng đó là cơ chế phòng thủ bổ sung, không phải cơ chế chính — bạn vẫn phải gọi Close() tường minh (thường qua defer). Dựa vào lưới an toàn đó thay vì gọi Close() sẽ gặp đúng ba cạm bẫy đã đo: đóng muộn, có thể không đóng khi thoát, và tốn thêm bộ nhớ vì object sống thêm một chu kỳ GC.
Bài 5 (bẫy/đánh đổi). Bài cảnh báo "đừng đặt finalizer trên object có vòng tham chiếu". Tại sao điều này lại gây rò rỉ, và vì sao đây là một lý do nữa để tránh dùng finalizer?
Đáp án
Nếu hai object có finalizer trỏ vòng vào nhau, runtime không thể xác định nên chạy finalizer của object nào trước (chạy cái này có thể cần object kia vẫn còn sống), nên nó không chạy finalizer nào cả — kết quả là cả hai object không bao giờ được thu hồi, tạo ra một dạng rò rỉ bộ nhớ dù bản thân chúng đã trở thành rác logic. Đây cùng với việc chạy bất đồng bộ/không tất định và giữ object sống thêm một chu kỳ GC là lý do khiến finalizer là công cụ nguy hiểm, chỉ nên dùng cho các trường hợp hẹp như giải phóng bộ nhớ C cấp phát qua cgo mà không có điểm Close() rõ ràng.
Phần sau ta mở chặng mới về cấu trúc dữ liệu nội tại: Phần sau mổ xẻ slice header — ba thành phần con trỏ, độ dài, dung lượng, cách chúng nằm trong bộ nhớ, và vì sao hiểu chúng là chìa khoá tránh bug slice kinh điển.