Skip to content
VLSI Mentor

C++ Part I · Module 14 · Smart Pointers and Ownership

std::shared_ptr and Reference Counting

Every ownership model so far has had exactly one owner, and that covers most designs. This chapter is the case it does not cover: several parts of a program each need an object to stay alive on their own schedule, and there is no way to say in advance which of them will finish last. A monitor, a scoreboard and a coverage collector all hold a transaction and release it when they are done; whichever is last must be the one that destroys it, and none of them knows whether it is last. That requires counting, which requires a second piece of state alongside the object, which is what a shared_ptr is. The mechanism is simple and the costs are specific, so this chapter measures all of them rather than describing them: the count rising and falling as copies come and go, a size that is exactly twice a unique_ptr's, a copy that compiles to 27 instructions including two atomic read-modify-writes where a unique_ptr's move compiles to 7 with none, and the two allocations that one spelling costs and another avoids — along with the hidden price the cheaper spelling charges, which is not an allocation at all but a deallocation that does not happen when you expect. And a word that does most of the damage in this area: shared_ptr is described as thread-safe, which is true of exactly one thing in it. Measured, eight threads sharing one object lost about forty per cent of their work while the count stayed perfect.

Advanced21 min readC++Part IFundamentalsOwnership

Part I · Module 14 · Chapter 14.5 · std::shared_ptr and Reference Counting

1. The Case Exclusive Ownership Cannot Express

Chapter 14.3 §10's callout named the one pattern unique_ptr rules out: you cannot keep it and give it away. Chapter 13.4 §9 gave the general classification, and its third row is this chapter:

  • one reaches it, one releases it — exclusive, Chapters 14.2–14.4;
  • several reach it, one releases it — borrowing, which a const T& or a raw pointer expresses;
  • several reach it, the last one releases it — shared.

The third row needs counting for a specific reason: no individual owner can tell whether it is the last one. A monitor finishing with a transaction cannot know whether the scoreboard is also finished. Something has to know how many remain, and that something cannot be any of the owners.

Shared ownership is not "several pointers to one object". It is several pointers to one object plus a count of them.

2. What a shared_ptr Holds

Two pointers:

  1. the object, and
  2. the control block — a separate piece of state holding the strong count (how many shared_ptrs), the weak count (Chapter 14.6), and the deleter.

The control block is why the type works. It is not in the object and not in any one pointer, so it survives any individual owner going away, and every owner can find it. When a shared_ptr is copied the strong count goes up; when one is destroyed it goes down; when it reaches zero, the object is destroyed.

(An object that needs a shared_ptr to itself cannot simply wrap this — it would create a second, independent control block. The standard provides std::enable_shared_from_this for that, and Chapter 14.8 is where it belongs, because the question it answers is an interface-design question.)

3. The Count, Measured

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
    auto a = std::make_shared<Widget>();
    {
        auto b = a;                        // copy
        {
            auto c = a;                    // another
        }
    }
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
use_count as copies come and go:
  after make_shared        : 1
  one copy in scope        : 2
  two copies in scope      : 3
  inner copy gone          : 2
  all copies gone          : 1

Up on copy, down on destruction, and the object survives as long as the count is above zero. Chapter 13.2 §3 measured that a copy constructor runs on every copy; this is what shared_ptr's does.

And one result that matters for Chapter 14.6:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
weak_ptr does NOT raise use_count:
  after weak_ptr w = a     : use_count=1  weak expired=0

A std::weak_ptr observes without owning — the count stayed at 1. That is the next chapter's subject; it appears here because the control block holds both counts and §7 needs the second one.

4. Size: Twice a unique_ptr

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
sizeof(int*)             = 8
sizeof(unique_ptr<int>)  = 8
sizeof(shared_ptr<int>)  = 16
sizeof(weak_ptr<int>)    = 16

16 bytes — the object pointer and the control-block pointer. Chapter 14.2 §5 measured unique_ptr at 8 and called it free; shared_ptr is not free, and the extra 8 bytes are the honest price of being able to answer how many of us are left.

This matters most where it multiplies. A vector of a million shared_ptrs is 16 MB of pointers against 8 MB — and Chapter 14.1 §9's element-size measurement is the context: the owning-pointer container was already paying 8 bytes per element plus a separate allocation per object.

5. What a Copy Costs

The count must be correct when several threads hold copies, so the increment and decrement are atomic. At -O2:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
void copyShared(const std::shared_ptr<W>& in, std::shared_ptr<W>& out) { out = in; }
void moveUnique(std::unique_ptr<W>&& in, std::unique_ptr<W>& out)      { out = std::move(in); }
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
copyShared : 27 instructions, containing
               ldadd   x9, x8, [x8]      (relaxed  — the increment)
               ldaddal x9, x8, [x8]      (acq_rel  — the decrement of the old target)
moveUnique :  7 instructions, no atomics

Two atomic read-modify-writes and roughly four times the instruction count. Chapter 14.2 §5 measured a unique_ptr's operations as byte-identical to a raw pointer's; this is the first smart-pointer operation in the module that is genuinely not free.

The asymmetry in the two instructions is worth a line. The increment is relaxed — making a new owner cannot cause anyone's destruction, so no ordering is needed. The decrement is acquire-release, because whichever thread takes the count to zero must see everything every other owner did before releasing, and must not run the destructor before those writes are visible. That is the ordering that makes the count safe, and §9 measures exactly how far it extends.

The practical consequence is a style rule: pass a shared_ptr by const& when a function only needs to look at it, and copy it only when the function is genuinely becoming an owner. A by-value shared_ptr parameter in a hot path pays two atomics per call for nothing.

6. make_shared: One Allocation Instead of Two

Chapter 14.4 §4 measured make_unique as 1 allocation against 1 — no saving. For shared_ptr the same comparison comes out differently:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
make_shared<Widget>()          : 1 allocation(s)
shared_ptr<Widget>(new Widget) : 2 allocation(s)  <-- TWO

Two allocations, because the two pieces of state are allocated separately: new Widget allocates the object, and then the shared_ptr constructor allocates a control block to track it. std::make_shared allocates one block holding both.

So the saving that is routinely and wrongly attributed to make_unique is real here, and it is the usual reason to prefer make_shared. The other reason is §3 of Chapter 14.4 — the unowned-pointer leak — which applies identically.

7. What make_shared Costs

One block for both pieces has a consequence that is rarely mentioned and is easy to measure. A 512-byte object, counting allocations and frees, with a weak_ptr deliberately outliving every shared_ptr:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
F1. make_shared<Big>, weak_ptr OUTLIVES every shared_ptr:
     after make_shared : allocs=1 frees=0
     ~Big ran
     shared_ptr gone   : allocs=1 frees=0  expired=1
     weak_ptr gone too : allocs=1 frees=1

F2. shared_ptr<Big>(new Big), same weak_ptr lifetime:
     after construction: allocs=2 frees=0
     ~Big ran
     shared_ptr gone   : allocs=2 frees=1  expired=1
     weak_ptr gone too : allocs=2 frees=2

Read the two shared_ptr gone lines against each other. In both, ~Big ran and the weak_ptr reports expired=1 — the destructor ran on time in both cases. But in F2 frees=1: the object's 512 bytes went back to the allocator immediately. In F1 frees=0 — the memory was still held, and was released only when the weak_ptr died.

The reason follows from §6. One block cannot be half-freed. The control block must outlive the object whenever a weak_ptr exists, because something has to remain to answer is it still there — so with make_shared the object's storage is trapped alongside it.

make_shared trades an allocation for a deallocation delay. The object is always destroyed on time; its storage is reclaimed only when the last weak reference is gone.

For a 512-byte object this is nothing. For a large object with long-lived weak references — a cached model observed by a registry — it is a real retention, and the fix is the two-allocation form. make_shared remains the right default; this is the one measured reason to depart from it.

8. Thread Safety: The Count Is Safe

Eight threads, each copying and destroying the same shared_ptr twenty thousand times:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
    auto p = std::make_shared<int>(0);
    for (int t = 0; t < 8; ++t)
        ts.emplace_back([p] { for (int i = 0; i < 20000; ++i) { auto copy = p; (void)copy; } });
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
D. after 8 threads x 20000 copy/destroy cycles:
     use_count = 1   (expected 1)

Exactly 1, on all three runs, with no crash and no premature destruction. 160,000 concurrent increments and 160,000 decrements, and the count came back precisely. That is §5's two atomic instructions doing their job: the reference count is thread-safe, and nothing you do to copies from different threads can corrupt it.

9. Thread Safety: The Object Is Not

The same eight threads, each incrementing the pointed-to int:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
    auto n = std::make_shared<int>(0);
    for (int t = 0; t < 8; ++t)
        ts.emplace_back([n] { for (int i = 0; i < 20000; ++i) ++(*n); });
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
E. after 8 threads x 20000 increments of *p:
     *p = 98514   (expected 160000)
     *p = 141358  (expected 160000)      run 2
     *p = 104645  (expected 160000)      run 3

About forty per cent of the work vanished, and the number was different every run. The shared_ptr machinery was flawless throughout — §8 ran concurrently with this very pattern and never lost a count. What was lost was the object's data, because ++(*n) is a read-modify-write on an ordinary int with no synchronisation at all.

This is the sentence worth taking away:

shared_ptr makes the ownership thread-safe. It makes the object no safer than any other shared variable.

Three things are commonly conflated, and only the first is guaranteed:

Safe?
copying / destroying different shared_ptr objects pointing to the same thingyes — measured in §8
reading or writing the pointed-to object from several threadsno — measured here
assigning to the same shared_ptr object from several threadsno — it is an ordinary object being mutated

Protecting the object is Module 26's subject — a mutex, an atomic type, or a design in which only one thread touches it. The threads here are an instrument, not a topic.

(A data race is undefined behaviour, not merely a lost update, so "40% was lost" describes what this run did rather than what is guaranteed. Chapter 14.4 §10's callout makes the general point: an observed outcome of undefined behaviour is one sample, not a specification.)

10. When It Is Right, and When It Is an Excuse

Chapter 10.9 §5 said shared ownership is "genuinely needed sometimes, and over-used because it appears to remove the need to decide." Both halves are worth being concrete about.

Genuinely needed when the last owner is not knowable in advance: several independent consumers with unrelated lifetimes, an object in a cache that outlives the request that created it, a node reachable from several graph roots, or an object handed to a thread whose completion order you do not control.

An excuse in three specific shapes, all of which this module has already answered:

  • "Three subsystems use this model." That is observation, not ownership. Chapter 14.1 §5 measured the signatures for it, and Chapter 14.7 argues for a raw pointer.
  • "I am not sure who should own it." Then the design question is open, and shared_ptr conceals it rather than answering it.
  • "It avoids the lifetime bugs." It converts them — Chapter 14.6 measures the one it introduces, and that one is invisible to the tools that catch the others.

11. What to Actually Do

Default to unique_ptr. Use shared_ptr when the last owner is genuinely unknowable.

Use make_shared — one allocation instead of two, plus Chapter 14.4 §3's leak.

Except for a large object with long-lived weak references — then take the two allocations so the storage is freed on time.

Pass const shared_ptr<T>& to observe, by value only to become an owner — two atomics per copy.

Better still, pass const T& or T* to observers so the signature does not mention ownership at all.

Do not read "thread-safe" as covering the object. Protect it yourself.

And never share a single shared_ptr object for concurrent assignment — only distinct instances are safe.

12. Mistakes Worth Making Once

Using shared_ptr because three subsystems use an object. That is observation.

Assuming the pointee is protected. Measured: 98,514 of 160,000.

Assuming concurrent assignment to one shared_ptr is safe. Only distinct instances are.

Taking shared_ptr<T> by value in a hot path. Two atomics per call for nothing.

Attributing make_shared's allocation saving to make_unique. 1 versus 1 there; 1 versus 2 here.

Expecting make_shared to free the object's storage when the count hits zero. Measured frees=0 with a live weak_ptr.

Thinking a live weak_ptr delays the destructor. It does not — ~Big ran on time in both forms.

Wrapping this in a shared_ptr. A second control block; Chapter 14.8.

Reaching for it to avoid deciding. Chapter 10.9 §5.

Treating one observed data-race outcome as the specification. It is undefined behaviour.

13. Interview Relevance

"How does shared_ptr know when to delete?" A control block holding a strong count, separate from the object so it survives any one owner. Zero means destroy.

"How big is it and why?" 16 bytes — two pointers, object and control block — against unique_ptr's 8.

"What does copying cost?" An atomic increment, and a decrement when the old target is released: 27 instructions against a unique_ptr move's 7. The strong answer notes the increment is relaxed and the decrement acquire-release, and says why.

"Is shared_ptr thread-safe?" The reference count is, under copying of distinct instances. The pointed-to object is not, and neither is concurrent assignment to one instance. This is a very common discriminator.

"Why make_shared?" One allocation instead of two, plus exception safety.

"Is there a reason not to?" Yes — the object's storage is retained until the last weak_ptr dies, because one block cannot be half-freed. Few candidates have this.

"When would you not use shared_ptr?" Whenever a single owner is identifiable — which is most of the time.

14. What You Should Be Able to Say Now

  1. Shared ownership exists because no individual owner can tell whether it is the last.
  2. A shared_ptr is two pointers — the object and a control block holding the strong count, weak count and deleter.
  3. Measured: use_count went 1 → 2 → 3 → 2 → 1 as copies came and went.
  4. Measured: a weak_ptr left the count at 1 — it observes without owning.
  5. Measured: sizeof(shared_ptr<int>) is 16, twice unique_ptr's 8.
  6. Measured: a copy is 27 instructions with ldadd and ldaddal; a unique_ptr move is 7 with no atomics.
  7. The increment is relaxed, the decrement acquire-release — because only the decrement can trigger destruction.
  8. Measured: make_shared is 1 allocation, shared_ptr<T>(new T) is 2 — the saving that does not apply to make_unique.
  9. Measured: with a live weak_ptr, make_shared gave frees=0 after the last shared_ptr died; the two-allocation form gave frees=1.
  10. The destructor ran on time in both — only the deallocation differs.
  11. Measured: 8 threads × 20,000 copy/destroy cycles left use_count at exactly 1, three runs out of three.
  12. Measured: the same threads incrementing the pointee produced 98,514 / 141,358 / 104,645 of 160,000.
  13. So "thread-safe" covers the count, not the object, and not concurrent assignment to one instance.
  14. Pass const& to observe and copy only to become an owner.
  15. shared_ptr answers who destroys the object, not who may touch it.
  16. Default to unique_ptr; shared ownership is sometimes necessary and often an excuse.

15. Exercises

Exercise 1 — why counting. Explain in one sentence why shared ownership requires state outside every owner.

Exercise 2 — predict the count. Trace use_count through: make_shared; copy to b; copy to c in an inner scope; c destroyed; b moved to d; d destroyed. Give six numbers.

Exercise 3 — the sizes. Give sizeof for T*, unique_ptr<T>, shared_ptr<T>, weak_ptr<T>, and account for each.

Exercise 4 — copy cost. Say what a copy does, which two atomic operations appear, and why their memory orderings differ.

Exercise 5 — the allocation table. Give the measured counts for all four creation forms and say which pair the saving belongs to and why.

Exercise 6 — the retention. A 4 MB model is created with make_shared and a registry holds a weak_ptr to it for the program's lifetime. Say what happens to the destructor and to the 4 MB when the last shared_ptr dies, and how to change it.

Exercise 7 — the three claims. For each of §9's three rows, say whether it is safe and cite the measurement or reason.

Exercise 8 — signatures. Choose the parameter type for: a function that logs a model's name; one that stores the model in a cache with an independent lifetime; one that runs the model on a worker thread outliving the caller.

Exercise 9 — right or excuse. For each, decide unique_ptr, shared_ptr, or a raw observing pointer: a model owned by the simulation and used by three subsystems; a transaction held by a monitor, a scoreboard and a coverage collector that finish in any order; an object whose owner you have not decided yet.

16. Summary

  • Shared ownership is several pointers plus a count, needed because no owner knows whether it is last.
  • A shared_ptr is two pointers — object and control block — measured at 16 bytes against unique_ptr's 8.
  • Measured: the count went 1 → 2 → 3 → 2 → 1; a weak_ptr left it at 1.
  • A copy costs two atomics and 27 instructions, against 7 for a unique_ptr move — so observe through const&.
  • make_shared is 1 allocation against 2 — the saving make_unique does not have.
  • But measured: with a live weak_ptr it left frees=0 — the destructor ran on time, the storage did not come back until the weak reference died.
  • Measured: the count is thread-safe — exactly 1 after 160,000 concurrent cycles, three runs of three.
  • Measured: the object is not — 98,514 of 160,000 increments survived, differently each run.
  • Default to unique_ptr. Shared ownership answers who destroys an object and nothing else.

One thing in this chapter was mentioned three times and explained once: the control block holds a second count, and a weak_ptr raised neither it nor the strong one while still being able to answer whether the object was there. That second count exists because the counting mechanism has a failure mode, and it is the only failure mode in this module that the count itself creates rather than prevents. Two objects that hold a shared_ptr to each other keep each other alive forever — measured next as both counts stuck at 2, neither destructor running, and two objects still live at exit. The part worth seeing is what the tools said: the leak detector that caught a raw new in the same program, verified working on a control case, reported 0 leaks.

Standards & specifications

Governing standard
ISO/IEC 14882 (the C++ language standard)(opens ISO/IEC (JTC 1/SC 22/WG 21) in a new tab)

Defines the C++ language and its standard library — the type system and templates, the object model and object lifetime, initialisation and overload resolution, the memory model and the containers, algorithms and utilities the library provides. Like C's standard it is a FAMILY of editions (C++98/03, C++11, C++14, C++17, C++20, C++23) and the editions are NOT interchangeable: a feature this curriculum relies on may be unavailable, differently specified or merely proposed in another edition, so each lesson names the edition its claim depends on. It does not define any foreign-function interface to a hardware description language, nor what a compiler actually emits, nor platform behaviour on real silicon — those are the implementation's and the device's contracts. Working drafts published by WG21 are freely available and are what most engineers read day to day; a draft is not the published standard.

This page also covers RTL structure, verification approach and debugging technique. Those are engineering practice built on the standard, not requirements the standard itself imposes.

Where this fits

Part of the C++ Programming curriculum.