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. 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.
Lưu ý trung thực: 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.
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ột lần tôi đo hớ: "shared_ptr tiện, dùng khắp nơi" và "smart pointer nào cũng chậm"
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ứ". Đo 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à không cần. Còn niềm tin ngược "smart pointer nào cũng chậm nên né chúng, dùng con trỏ thô" 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.
Bài học đo lường: unique_ptr (sở hữu độc quyền) gần như MIỄN PHÍ — sizeof 8 byte (= con trỏ thô), move 0,23 ns (~raw), không đếm; còn shared_ptr (sở hữu chung) trả giá ATOMIC refcount mỗi copy (0,63 vs raw 0,28 ns = 2,3x đơn luồng, tệ hơn nhiều khi nhiều luồng tranh bộ đếm — cache bounce như phần 13), sizeof 16 byte + control block; make_shared (1 malloc gộp) nhanh 3,75x so với shared_ptr(new) (2 malloc). Nếu tôi tin "shared_ptr tiện, dùng khắp nơi" tôi trả giá atomic + control block khắp nơi không cần; nếu tôi tin "smart pointer nào cũng chậm" tôi né unique_ptr — một công cụ an toàn và miễn phí.
Vì sao điều này quan trọng khi lập trình
Hệ quả đầ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.
Hệ quả 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.
Hệ quả thứ ba là tinh thần đo lường: "an toàn" và "miễn phí" là hai chuyện — biết cái trừu tượng nào có chi phí ẩn. Con số mang theo: unique_ptr = con trỏ thô về hiệu năng (sizeof 8, move 0,23 ns, 0 overhead, không copy được); shared_ptr trả giá đếm tham chiếu ATOMIC mỗi copy (0,63 vs raw 0,28 ns = 2,3x đơn luồng, tệ hơn khi nhiều luồng tranh bộ đếm) + control block 16 byte + cấp phát 2 khối (dùng make_shared để gộp 1, nhanh 3,75x). unique_ptr mặc định; shared_ptr chỉ khi cần chia sẻ sở hữu. Không phải mọi con trỏ thông minh đều đắt — chỉ cái đếm tham chiếu mới thế.
Thử ba mươi giây
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. Ba mươi giây đó 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á.