Hình dung hai kiểu "cho mượn đồ". Kiểu thứ nhất: bạn đưa hẳn cuốn sách cho một người, dặn "dùng xong thì vứt đi giúp" — chỉ một chủ, không sổ sách gì. Kiểu thứ hai: cuốn sách nằm trong thư viện chung, ai cầm lên phải ghi một vạch vào sổ mượn, trả thì gạch một vạch, và chỉ khi sổ về 0 thủ thư mới đem sách đi hủy. Cái sổ đó phải khóa lại mỗi lần ghi để hai người không ghi đè lên nhau. Kiểu thứ nhất là unique_ptr; kiểu thứ hai là shared_ptr — và cái sổ khóa-mỗi-lần-ghi chính là chi phí ẩn của nó.

Con trỏ thông minh (unique_ptr, shared_ptr) giải phóng bạn khỏi việc quản lý bộ nhớ thủ công — chúng tự xóa đối tượng khi không còn ai dùng. Vì tiện, nhiều người quơ luôn shared_ptr cho mọi thứ. Nhưng hai loại này có chi phí runtime khác hẳn nhau, và shared_ptr trả một cái giá mà unique_ptr không phải trả: đếm tham chiếu atomic. Tôi đo cả hai trong container gcc:13, và con số cho thấy "dùng shared_ptr cho tiện" là một tối ưu ngược — trong khi unique_ptr gần như miễn phí.

Sở hữu độc quyền so với đếm tham chiếu atomic

unique_ptr biểu diễn sở hữu độc quyền — chỉ một chủ tại một thời điểm. Nó không đếm gì cả. Bạn không thể copy nó (chỉ move — chuyển sở hữu). sizeof bằng đúng con trỏ thô (8 byte), và runtime gần như 0 overhead: move chỉ là chuyển con trỏ.

shared_ptr biểu diễn sở hữu chung — nhiều chủ. Nó giữ một bộ đếm tham chiếu: mỗi lần copy tăng bộ đếm, mỗi lần hủy giảm; khi về 0 mới xóa đối tượng. Vì nhiều luồng có thể cùng copy/hủy, bộ đếm phải atomic (nguyên tử, an toàn luồng) — nên mỗi copy là một phép atomic increment, mỗi hủy một atomic decrement. Đúng cái sổ mượn phải khóa mỗi lần ghi. Và sizeof là 16 byte (hai con trỏ: tới đối tượng và tới control block chứa bộ đếm).

Chưa hết: shared_ptr cần hai khối bộ nhớ — đối tượng và control block. Viết shared_ptr<T>(new T) cấp hai lần (new cho đối tượng, rồi control block riêng); make_shared<T>() gộp cả hai vào một lần cấp phát.

Đo: shared_ptr copy 2,3 lần chậm, unique_ptr ~miễn phí

g++ -O2 -pthread:

sizeof: raw = 8, unique_ptr = 8, shared_ptr = 16 byte

A. COPY/GÁN con trỏ (100 triệu lần):
   raw pointer (gán 8B)                  : 0,28 ns/copy
   shared_ptr copy (atomic refcount++/--): 0,63 ns/copy   (~2,3x raw)
   unique_ptr move (chuyển con trỏ)      : 0,23 ns/move   (~raw, KHÔNG atomic)

B. CẤP PHÁT (2 triệu đối tượng):
   shared_ptr(new) : 28,5 ns/tạo   (2 malloc: đối tượng + control block)
   make_shared     :  7,6 ns/tạo   (1 malloc gộp -> nhanh ~3,75x)
   make_unique     : 11,1 ns/tạo   (1 malloc, KHÔNG control block)

Nhìn A: copy một shared_ptr mất 0,63 ns so với gán một con trỏ thô 0,28 ns — chậm hơn ~2,3 lần, và toàn bộ khác biệt là phép atomic tăng/giảm bộ đếm. Còn unique_ptr move chỉ 0,23 ns — bằng con trỏ thô, vì nó không đếm gì, chỉ chuyển con trỏ. Nói cách khác: unique_ptr gần như miễn phí, shared_ptr trả giá atomic mỗi lần copy.

Cần nói thẳng một chỗ cho công bằng: 2,3 lần là chi phí đơn luồng, không tranh chấp — một phép atomic không tranh trên CPU hiện đại chỉ vài chu kỳ, nên khác biệt vừa phải. Nhưng khi nhiều luồng cùng copy/hủy cùng một shared_ptr, cache line của bộ đếm bị ping-pong qua lại giữa các lõi — đúng cái false sharing của phần 13 — và lúc đó shared_ptr chậm hẳn, mất khả năng mở rộng. Cái sổ mượn chung hóa ra là điểm nghẽn khi cả phòng chen nhau ghi.

B cho thấy chi phí cấp phát: shared_ptr<T>(new T) mất 28,5 ns vì hai lần malloc (đối tượng và control block riêng), còn make_shared chỉ 7,6 ns vì gộp thành một — nhanh ~3,75 lần. Đây là lý do luôn ưu tiên make_shared. make_unique (11,1 ns) chỉ một malloc và không control block.

(Môi trường đo: container gcc:13 trên host ARM AArch64, g++ -O2 -pthread, mỗi số lấy MIN sau warmup chia số thao tác. Con số tuyệt đối đổi theo máy; cái cần nhìn là tỉ lệ 2,3x copy, unique ≈ raw, và make_shared nhanh 3,75x.)

Hai niềm tin đối nghịch, cùng sai

Tôi vào đo với thói quen: "con trỏ thông minh tiện và an toàn, cứ shared_ptr cho mọi thứ". Con số cho thấy shared_ptr không miễn phí: mỗi copy là một atomic refcount (2,3 lần gán thô đơn luồng, và tệ hơn nhiều khi nhiều luồng tranh cùng bộ đếm), cộng control block, cộng cấp phát hai khối. Dùng shared_ptr cho một đối tượng chỉ có một chủ là trả giá đồng bộ hóa mà chẳng để làm gì.

Nhưng niềm tin ngược lại — "smart pointer nào cũng chậm nên né chúng, dùng con trỏ thô cho nhẹ" — cũng sai. unique_ptr có sizeof bằng con trỏ thô (8 byte), move nhanh bằng con trỏ thô (0,23 ns), không atomic — gần như không có overhead runtime. Chỉ shared_ptr mới trả giá refcount. Né unique_ptr vì gộp nó chung với shared_ptr là bỏ đi một công cụ vừa an toàn vừa miễn phí.

Về đến code hằng ngày

Điều đầu tiên: dùng unique_ptr làm mặc định; chỉ dùng shared_ptr khi THẬT SỰ cần chia sẻ sở hữu. Phần lớn đối tượng có một chủ rõ ràng — unique_ptr diễn đạt điều đó, an toàn, và không tốn gì runtime. Chỉ khi nhiều nơi thật sự phải cùng giữ một đối tượng sống (và không biết cái nào chết sau) mới cần shared_ptr. "Chia sẻ sở hữu" là một quyết định thiết kế, không phải mặc định vì tiện.

Điều thứ hai: luôn dùng make_shared/make_unique thay vì new. make_shared gộp hai lần cấp phát thành một (nhanh 3,75 lần trong đo) và an toàn ngoại lệ hơn. make_unique cũng rõ ràng và an toàn hơn new. Và tránh copy shared_ptr không cần thiết — truyền const shared_ptr& hay T*/tham chiếu khi hàm chỉ dùng đối tượng chứ không giữ sở hữu, để không trả phí atomic vô ích.

Chỗ này người viết Java, Python hay Go nên để ý, vì bài toán quản lý vòng đời không biến mất ở ngôn ngữ có GC — nó chỉ đổi hình. Python dùng đúng đếm tham chiếu như shared_ptr cho mọi đối tượng (sys.getrefcount), và chính vì bộ đếm ấy phải an toàn luồng mà CPython phải giữ khóa toàn cục GIL — cái giá "sổ mượn khóa mỗi lần ghi" nâng lên tầm cả trình thông dịch. Swift cũng dùng ARC (automatic reference counting) atomic y hệt, và lập trình viên iOS phải học weak/unowned để phá vòng tham chiếu — đúng cái weak_ptr của C++. Java và Go thì đi hướng khác hẳn: không đếm tham chiếu mà dùng tracing GC quét cả đồ thị đối tượng, nên copy một tham chiếu là miễn phí (như unique_ptr/con trỏ thô), đổi lại trả giá bằng những đợt GC. Không có bữa trưa miễn phí — chỉ là mỗi runtime chọn trả tiền ở chỗ khác.

Muốn tự thấy thì in sizeof(std::unique_ptr<T>) và sizeof(std::shared_ptr<T>) — bạn sẽ thấy unique bằng con trỏ thô (8 byte), shared gấp đôi (16 byte, vì có thêm con trỏ tới control block). Rồi bấm giờ: copy một shared_ptr vài trăm triệu lần so với gán một con trỏ thô — shared chậm hơn vì mỗi copy là một atomic tăng bộ đếm. So với unique_ptr move: nó bằng con trỏ thô, không atomic. Cuối cùng, đo tạo đối tượng bằng shared_ptr<T>(new T) so với make_shared<T>() — bản gộp nhanh hơn vài lần vì một lần cấp phát thay vì hai. Vài phút đó cho bạn thấy điều mà "cứ dùng shared_ptr cho tiện" giấu đi: quản lý bộ nhớ tự động không miễn phí — đếm tham chiếu atomic là một chi phí thật, và chọn đúng loại con trỏ thông minh (unique khi độc quyền, shared khi thật sự chia sẻ) là khác biệt giữa an toàn-miễn-phí và an toàn-nhưng-phải-trả-giá.