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