C++ / MEMORY OWNERSHIP AND SMART POINTERS
shared_ptr, reference counts, and their cost
Explain what a shared_ptr copy actually costs, track the strong count to predict destruction, and cut needless atomic traffic with const& and std::move.
What you will learn
- Predict exactly when a shared object is destroyed by following its strong count
- Use make_shared to get one heap block instead of two for object plus control block
- Take shared_ptr by const& when borrowing; by value only when the callee stores it
- Move instead of copy to transfer ownership without touching the atomic counter
Understanding shared_ptr, reference counts, and their cost
A shared_ptr is two pointers wide: one to the object and one to a separately allocated control block. The control block, not the pointer, holds the strong count, the weak count and the deleter, which is why copying a shared_ptr copies neither the object nor the block; it only increments the strong count both copies now point at. Destroying or resetting an owner decrements that same count, and whichever owner takes it to zero runs the destructor and releases the memory. No owner is special and nothing is scheduled in advance: the last one out does the deleting.
The count has to be atomic because the standard guarantees you can copy and destroy shared_ptrs to the same object from several threads without adding a lock of your own. An atomic increment is a locked read-modify-write, and when several cores hold copies of one object the control block's cache line bounces between them, which usually costs far more than the instruction itself. Be precise about where that cost lives: *p and p->field load the object pointer stored inside the shared_ptr, so dereferencing is as cheap as a raw pointer, and only creating or destroying an owner touches the counter.
The working rule is therefore to count owners, not uses. A function that merely reads the pointee needs no ownership, so give it const T& or const shared_ptr<T>& and leave the counter alone; take shared_ptr<T> by value only when the function will store it, and std::move it into place so the copy is not paid for twice. make_shared additionally folds the two allocations of shared_ptr<T>(new T) into one, at the price of keeping the object's bytes (not the object) alive for as long as the control block itself is needed.
<iostream>
<memory>
struct Sensor {
int id;
explicit Sensor(int i) : id(i) { std::cout << "Sensor " << id << " built\n"; }
~Sensor() { std::cout << "Sensor " << id << " destroyed\n"; }
};
int main() {
std::shared_ptr<Sensor> a = std::make_shared<Sensor>(7);
std::cout << "owners now: " << a.use_count() << '\n';
{
std::shared_ptr<Sensor> b = a; // atomic ++ on the control block
std::cout << "after copy: " << a.use_count() << '\n';
std::shared_ptr<Sensor> c = std::move(b); // ownership moves, count untouched
std::cout << "after move: " << a.use_count() << '\n';
std::cout << "b is empty: " << (b ? "no" : "yes") << '\n';
} // c dies here: atomic --
std::cout << "block left: " << a.use_count() << '\n';
std::cout << "sizeof ptr: " << sizeof(a) << " (raw pointer: " << sizeof(Sensor*) << ")\n";
a.reset(); // last owner takes the count to 0
std::cout << "after reset: " << (a ? "still owns" : "empty") << '\n';
}
Copying a shared_ptr does not copy a pointer, it registers another owner by atomically bumping a shared count, and the object dies the instant that count reaches zero.
Worked examples
Two allocations or one
Counts real heap blocks to show that shared_ptr<T>(new T) allocates twice while make_shared allocates once.
<cstddef>
<cstdlib>
<iostream>
<memory>
static int allocations = 0;
void* operator new(std::size_t n) {
++allocations;
return std::malloc(n);
}
void operator delete(void* p) noexcept { std::free(p); }
void operator delete(void* p, std::size_t) noexcept { std::free(p); }
struct Big { double x, y, z; };
int main() {
std::cout << "counting heap blocks\n"; // let the stream allocate before we measure
allocations = 0;
{ std::shared_ptr<Big> p(new Big{}); }
int separate = allocations;
allocations = 0;
{ std::shared_ptr<Big> q = std::make_shared<Big>(); }
int fused = allocations;
std::cout << "shared_ptr<Big>(new Big) -> " << separate << '\n';
std::cout << "make_shared<Big>() -> " << fused << '\n';
}
Example explained
Line 1Replacing global operator new counts every heap block the program asks for, so the numbers are measured rather than claimed.
Line 2The counter is copied into separate and fused before any result is printed, so std::cout's own allocations cannot pollute the figures.
Line 3shared_ptr<Big>(new Big{}) allocates the Big first and then the control block, giving two blocks, two frees, and a count that sits on a different cache line from the object.
Line 4make_shared requests one block large enough for both and constructs the object inside it, which is why the count is 1.
Which parameter form touches the counter
Shows that a by-value shared_ptr parameter increments and decrements the count for nothing, while const& leaves it alone.
<iostream>
<memory>
<string>
void logByValue(std::shared_ptr<std::string> p) {
std::cout << "inside by-value: " << p.use_count() << '\n';
}
void logByRef(const std::shared_ptr<std::string>& p) {
std::cout << "inside by-ref: " << p.use_count() << '\n';
}
struct Cache {
std::shared_ptr<std::string> held;
void keep(std::shared_ptr<std::string> p) { held = std::move(p); } // by value: it stores
};
int main() {
auto text = std::make_shared<std::string>("payload");
std::cout << "start: " << text.use_count() << '\n';
logByValue(text); // +1 on entry, -1 on return
logByRef(text); // counter never touched
Cache c;
c.keep(text);
std::cout << "after keep: " << text.use_count() << '\n';
c.held.reset();
std::cout << "after cache release: " << text.use_count() << '\n';
}
Example explained
Line 1logByValue copies into its parameter, so the count is 2 inside and back to 1 the moment it returns: two atomic operations that bought nothing.
Line 2logByRef binds a reference to the caller's shared_ptr, so the count stays 1 while it reads the string just as safely.
Line 3Cache::keep is the case that legitimately takes by value, and std::move hands the existing copy to held instead of making a second one.
Line 4The count is 2 after keep because two separate shared_ptr objects, text and c.held, now name the same control block.
Where the atomic cost comes from
Four threads copy from one shared_ptr, hammering a single atomic counter, and the count is still exactly right afterwards.
<iostream>
<memory>
<thread>
<vector>
int main() {
auto data = std::make_shared<int>(42);
std::vector<std::thread> pool;
for (int t = 0; t < 4; ++t) {
pool.emplace_back([&data] {
for (int i = 0; i < 200000; ++i) {
std::shared_ptr<int> mine = data; // atomic ++ here, atomic -- at loop end
}
});
}
for (auto& t : pool) t.join();
std::cout << "value: " << *data << '\n';
std::cout << "use_count: " << data.use_count() << '\n';
}
Example explained
Line 1All four threads read the same data object and write the same counter, so 800000 locked read-modify-writes land on one cache line that keeps migrating between cores.
Line 2Copying from a shared_ptr nobody modifies is race-free by design, and that guarantee is exactly why the count cannot be a plain int.
Line 3After the joins every thread-local copy is gone, so the count is back to 1 and the int is still alive.
Line 4Build with -pthread on GCC or Clang; the atomicity covers the count only, so two threads writing *data would still be a data race.
Important notes
use_count() is a diagnostic, not a control mechanism: with other threads running it can be stale before you read the result, so never write if (p.use_count() == 1) to decide that mutation is safe.
sizeof(std::shared_ptr<T>) is two pointers on the usual implementations (16 in the 64-bit run above, and implementation-defined in general), so a container of shared_ptr costs twice the memory of the same container of raw pointers.
Common mistakes
Building two shared_ptrs from the same raw pointer (shared_ptr<T> a(p); shared_ptr<T> b(p);): each creates its own control block with count 1, so the object is destroyed twice and the heap is corrupted.
Assuming shared_ptr makes the pointee thread-safe; only the reference count is atomic, so two threads writing *p is still a data race and still needs a mutex.
Passing shared_ptr by value through every layer of a call chain, which adds an atomic increment and decrement per layer and hides which layer is the real owner.
Try it yourself
Change, predict, then run
Take the first example and push two copies of a into a std::vector<std::shared_ptr<Sensor>>, printing use_count() after the pushes and again after vector.clear(). Predict both numbers before running, then change one push to std::move(a) and explain why the count and the position of the destructor message both change.
Open the C++ workspaceCheck your understanding
A function takes std::shared_ptr<Widget> p by value and only calls p->draw(). It is called millions of times from four threads. Why does switching the parameter to const std::shared_ptr<Widget>& speed it up?
- By-value copying also copies the Widget, so the reference avoids duplicating the object's data
- Each by-value copy allocates a fresh control block, so the reference saves millions of allocations
- Each call performs an atomic increment on entry and a matching decrement on return, and four cores contend for that one control block's cache line
- A copied shared_ptr adds an extra pointer hop to every p->draw() call, which the reference removes
Show answer
A copy only touches the counter in the control block, so the cost is one atomic increment plus one atomic decrement per call, multiplied by four cores writing the same cache line. Option 2 is tempting because constructing the first shared_ptr does allocate, but a copy never allocates anything; it points at the control block that already exists. Copying also never touches the Widget, and because the object pointer is stored inside the shared_ptr, dereferencing costs the same through a copy or a reference.