sync.Pool là công cụ chuẩn của Go để tái dùng object giữa nhiều goroutine, cắt allocation. Nhưng nó là một trong những API bị dùng sai nhiều nhất — nhiều người dán sync.Pool vào mọi chỗ tưởng sẽ nhanh hơn, rồi benchmark cho thấy chậm hơn. Bài này đo cả hai mặt: khi nào Pool là bẫy (chậm hơn), khi nào nó thật sự thắng, và cơ chế quan trọng nhất mà ai cũng phải hiểu — GC dọn sạch pool.

Cơ chế: mượn, trả, và lưu theo mỗi P

sync.Pool là một bể object có thể mượn/trả an toàn giữa nhiều goroutine. Get() trả về một object có sẵn trong pool, hoặc gọi hàm New nếu pool rỗng. Put() trả object về pool để lần sau tái dùng.

Điểm hay về hiệu năng: pool lưu theo mỗi P (giống mcache của allocator). Mỗi P có một khe riêng, nên Get/Put chủ yếu không cần khoá — nhiều goroutine mượn/trả song song không giẫm chân nhau. Đây là lý do sync.Pool scale tốt dưới tải cao.

Ảnh chụp đoạn mã Go nền tối minh hoạ sync.Pool bể chứa object tái dùng an toàn đa luồng, pool cho phép nhiều goroutine mượn trả object mà không giành khoá lưu theo mỗi P Get lấy object có sẵn hoặc gọi New Put trả lại nhưng GC dọn sạch pool, var bufPool sync.Pool New func any return new bytes.Buffer, hàm xayChuoi b con trỏ bytes.Buffer b.Reset dùng lại buffer đã lớn sẵn for i 200 lần b.WriteString, mẫu chuẩn Get dùng Put nên defer Put cho chắc buf bằng bufPool.Get ép kiểu con trỏ bytes.Buffer xayChuoi buf bufPool.Put buf trả lại để lần sau tái dùng, bẫy lưu byte trực tiếp boxing slice header 24 byte mỗi lần pool.Put buf là byte đóng hộp header lên heap nên lưu con trỏ không lưu slice trần, hai đặc điểm phải nhớ lưu theo mỗi P như mcache Get Put chủ yếu không khoá scale tốt song song, GC dọn sạch pool object trong pool bị bỏ sau tối đa hai chu kỳ GC nên sync.Pool chỉ để giảm churn cấp phát ngắn hạn không phải bể kết nối hay cache dài hạn

Hình 1: Mẫu chuẩn Get → dùng → Put. Lưu con trỏ (*bytes.Buffer), không lưu slice trần (gây boxing). Hai đặc điểm phải nhớ: lưu theo mỗi P, và GC dọn sạch pool.

Bẫy: khi Pool chậm hơn không Pool

Đây là điều nhiều người không ngờ. Xét một buffer 4KB xử lý trong vòng lặp song song, so pool với cấp mới:

Ảnh chụp bảng kết quả đo thật nền tối sync.Pool Go 1.23 arm64 10 luồng song song, phần 1 bẫy pool byte khi buffer không thoát heap BenchmarkKhongPool 147.8 ns/op 0 B/op 0 allocs/op BenchmarkCoPool 279.0 ns/op 24 B/op 1 allocs/op không pool nhanh hơn buffer 4KB không thoát compiler để stack 0 alloc pool lại tốn boxing slice header pool không phải lúc nào cũng thắng, phần 2 pool con trỏ bytes.Buffer khi object thật sự thoát heap BenchmarkKhongPool2 1499 ns/op 6144 B/op 107 allocs/op BenchmarkCoPool2 1086 ns/op 2113 B/op 101 allocs/op pool tái dùng buffer đã lớn sẵn bớt 6 lần cấp phát buffer tự lớn 4KB ít hơn nhanh khoảng 28 phần trăm modest nhưng thật, phần 3 GC dọn sạch pool Get đầu tiên New gọi 1 lần Get lại ngay chưa GC New gọi 1 lần tái dùng không tăng Get sau 2 lần GC New gọi 2 lần pool đã bị dọn New lại

Hình 2: (1) Pool []byte CHẬM hơn (279 vs 147 ns) vì buffer không thoát heap (compiler để stack, 0 alloc) còn pool tốn boxing. (2) Pool *bytes.Buffer khi object thật sự thoát heap: nhanh 28% (1086 vs 1499 ns). (3) GC dọn pool: New gọi lại sau 2 lần GC.

Kết quả gây ngạc nhiên: không pool nhanh hơn — 147 ns với 0 cấp phát, so với pool 279 ns và 1 cấp phát. Hai lý do:

  1. Buffer 4KB không thoát heap. Vì hàm xử lý chỉ đọc/ghi buffer rồi thôi, trình biên dịch chứng minh được nó không thoát và đặt trên stack — miễn phí. Không có allocation nào để "tiết kiệm".
  2. Pool tốn boxing. Lưu một []byte (slice) vào pool (kiểu any) buộc đóng hộp slice header lên heap — chính là 24 byte, 1 cấp phát bạn thấy. Pool tạo ra allocation thay vì cắt nó.

Bài học: sync.Pool không phải lúc nào cũng thắng. Nếu object vốn không cấp phát trên heap, pool chỉ thêm overhead. Và nếu bạn lưu slice trần, boxing header còn phản tác dụng.

Khi Pool thật sự thắng

Pool giúp khi object thật sự cấp phát trên heap và bị tạo lại liên tục (churn). Đo với pool *bytes.Buffer xây chuỗi (buffer nội bộ thật sự cấp phát trên heap):

  • Không pool: 1499 ns, 6144 B/op, 107 allocs/op — mỗi new(bytes.Buffer) bắt đầu rỗng và phải tự lớn (6 lần nhân đôi buffer nội bộ).
  • Có pool: 1086 ns, 2113 B/op, 101 allocs/op — buffer lấy từ pool đã lớn sẵn từ lần dùng trước, nên Reset() + ghi tái dùng luôn, bớt 6 lần cấp phát tăng trưởng. Nhanh ~28%.

Chú ý tôi lưu *bytes.Buffer (con trỏ), không phải slice trần — con trỏ vào interface không gây boxing thêm. Đây là mẫu đúng.

Đo thật cơ chế then chốt: GC dọn sạch pool

Đây là điều bắt buộc phải hiểu, nếu không bạn sẽ dùng sai. Đặt một bộ đếm trong New, quan sát khi nào nó được gọi:

b := pool.Get()  // New gọi lần 1
pool.Put(b)
b = pool.Get()   // tái dùng, New KHÔNG gọi (đếm vẫn 1)
pool.Put(b)
runtime.GC(); runtime.GC()
b = pool.Get()   // New gọi lần 2 — pool đã bị dọn!

Kết quả: Get đầu → New (1 lần); Get lại ngay → tái dùng (vẫn 1); sau 2 lần GC → New gọi lại (2 lần). Object trong pool bị bỏ đi khi GC chạy (thực ra Go giữ một "victim cache" nên object sống sót tối đa qua một chu kỳ GC, rồi bị dọn ở chu kỳ sau). Điều này nghĩa là: sync.Pool chỉ dùng để giảm churn cấp phát ngắn hạn, KHÔNG phải để cache lâu dài hay làm bể kết nối — vì bất cứ lúc nào GC chạy, pool có thể trống rỗng.

Ứng dụng thực tế

Dùng cho object tạm ngắn hạn bị churn nhiều. Ví dụ điển hình: buffer trong xử lý request HTTP, object trung gian trong parser/encoder, buffer cho từng dòng log. Chúng sống ngắn, tạo/hủy liên tục ở tần suất cao — đúng đối tượng của Pool.

Đừng dùng làm bể kết nối hay cache. Vì GC dọn pool bất kỳ lúc nào, đừng để *sql.DB connection hay dữ liệu cache quan trọng trong sync.Pool. Dùng thư viện pool chuyên dụng (như của database/sql) cho kết nối.

Luôn lưu con trỏ, không lưu slice/struct trần. Lưu []byte hay struct lớn trực tiếp gây boxing/copy. Lưu *bytes.Buffer, *[]byte, hoặc con trỏ struct. Và luôn Reset()/làm sạch object sau khi Get để không lẫn dữ liệu cũ.

Đánh đổi cần cân nhắc

Luôn đo trước khi thêm Pool. Như phép đo cho thấy, Pool có thể làm chậm nếu object không thoát heap. Chạy benchmark với -benchmem (bài trước) trên cả hai phiên bản; chỉ giữ Pool nếu nó thật sự giảm allocs/op và ns/op.

Pool thêm phức tạp và rủi ro. Quên Put thì mất tác dụng (object không quay lại pool). Quên Reset thì rò dữ liệu cũ. Trả về pool một object mà nơi khác còn giữ tham chiếu thì hai chỗ dùng chung một buffer — bug khó tìm. Chỉ dùng khi lợi ích đo được đủ lớn để đáng rủi ro.

Hiệu quả phụ thuộc tần suất GC. Vì GC dọn pool, một chương trình GC thường xuyên (heap nhỏ, cấp phát nhiều) sẽ thấy pool bị dọn liên tục, giảm hiệu quả. Ngược lại, chương trình GC thưa (heap lớn, GOGC cao) giữ pool lâu hơn. Đây là lý do lợi ích thực tế của Pool rất phụ thuộc workload.

Ba ý mang về

  1. sync.Pool lưu theo mỗi P (không khoá, scale tốt) nhưng KHÔNG phải lúc nào cũng nhanh hơn: đo thật, pool []byte khi buffer không thoát heap còn chậm hơn không pool (279 vs 147 ns) vì compiler đã để buffer trên stack và pool lại tốn boxing slice header.
  2. Pool thắng khi object thật sự cấp phát heap và bị churn nhiều: đo thật pool *bytes.Buffer nhanh ~28% (1086 vs 1499 ns) nhờ tái dùng buffer đã lớn sẵn — nhưng phải lưu con trỏ, không lưu slice trần, và luôn Reset sau Get.
  3. GC dọn sạch pool sau tối đa hai chu kỳ: đo thật, sau 2 lần GC thì New được gọi lại — nên sync.Pool chỉ dùng cho object ngắn hạn giảm churn, tuyệt đối không dùng làm bể kết nối hay cache lâu dài.

Phần sau ta quay lại bố cục bộ nhớ ở cấp thấp nhất: Phần sau mổ xẻ struct alignment và padding — vì sao thứ tự field đổi kích thước struct, cách trình biên dịch chèn byte đệm, và sắp field để struct gọn nhất.