C++ shared_ptr and weak_ptr: Complete Guide
이 글의 핵심
Master shared ownership with std::shared_ptr: control blocks, make_shared, weak_ptr, breaking cycles, enable_shared_from_this, thread-safety limits, and real patterns for observers and caches.
Introduction: shared ownership
Who deletes this object, and when? In C++ that is ownership. std::unique_ptr is the straight story: one owner, period. std::shared_ptr is for when more than one part of the system gets to keep something alive. The last remaining shared_ptr runs the destructor. The bookkeeping lives in a control block (reference count, weak count, deleter, and friends)—not inside the object you are sharing.
A quick war story, because this stuff only feels abstract until it isn’t. We shipped a refactor that added a back-pointer between two long-lived objects—both stored as shared_ptr. It looked clean on paper. In prod, RSS just climbed; no single smoking stack trace, no crash, just a slow leak until the process had to be restarted. The graph never reached refcount zero. Circular references brought down production in the most boring, expensive way. The patch was textbook: one of those edges became a weak_ptr, and the leak stopped.
That is the emotional pitch for the rest of this post. If several subsystems really do need the same thing to stay alive independently—say, a network session held by a timer, a message queue, and a UI bit—shared_ptr is a legitimate tool. If you are using it because ownership is vague, you will pay in bugs and in atomics. When only one component should own the resource, unique_ptr or a plain member is usually faster and easier to read.
We will walk through how shared_ptr and weak_ptr work, where they cost you, and how to stay out of cycles, double deletes, and lifetime issues that only show up at scale. The examples assume C++17 or later unless I say otherwise, and I will call out C++20 pieces where they matter.
shared_ptr basics: creation and usage
A std::shared_ptr<T> is really two ideas glued together: a pointer to the managed object (type T, or a base type if you are doing polymorphic stuff), and a pointer to a control block that stores the strong and weak counts, the deleter, and any allocator data.
You can build one with std::make_shared (usually the right default), a constructor with new, or whatever factory you already use. Copying a shared_ptr bumps the strong count; assigning or destruction drops it. When the strong count hits zero, the managed object goes away. The control block can outlive the object for a while if something still has a weak_ptr to it.
#include <memory>
#include <iostream>
struct Node {
int v;
explicit Node(int x) : v(x) { std::cout << "Node " << v << " ctor\n"; }
~Node() { std::cout << "Node " << v << " dtor\n"; }
};
int main() {
std::shared_ptr<Node> a = std::make_shared<Node>(1);
{
std::shared_ptr<Node> b = a; // same control block, strong count 2
std::cout << "use_count=" << a.use_count() << "\n";
} // b gone, count 1
std::cout << "still alive, v=" << a->v << "\n";
} // last strong ref: Node destroyed
use_count() is fine for printf debugging. I would not use it to prove correctness in concurrent code: the number you read and the very next line can disagree with reality.
std::make_shared: efficient creation
std::make_shared<T>(args...) usually does one heap allocation that holds both the T and the control block. That is nicer for cache locality and you get strong exception safety when you construct T in a gnarly expression.
auto p = std::make_shared<std::string>(10, 'x');
// Prefer make_shared to:
// std::shared_ptr<std::string>(new std::string(10, 'x'));
Caveat: with make_shared, the object and the block are one chunk. When the last shared_ptr goes away, T’s destructor runs, but the control block memory can stick around until the last weak_ptr tied to that block disappears—because the weak side still references the same block. In tight heap budgets or on embedded, a huge T plus a bunch of long-lived weak_ptr observers can keep a fat allocation reserved longer than a split new + block would. That is not what static analyzers call a “leak,” but it can still hurt.
make_shared also gets awkward if you need a non-default allocator for T only, or a custom deleter that you cannot express the way you need—then you fall back to a constructor with new or allocate_shared.
The control block: what lives where
Think of the control block as the little ledger behind every shared_ptr graph. It stores the strong count (how many shared_ptr owners), the weak count (for weak_ptr plus a bit of implementation bookkeeping; when both sides are done, the block itself can be freed), the deleter you registered, and the allocator if you used one.
Remember: the count is not inside T. That is why aliasing and enable_shared_from_this can work: several shared_ptr instances can share one block but still point at different addresses (for example, a subobject of the same allocation).
Reference counting: how it works
Each copy of a shared_ptr that shares the same ownership does an atomic bump on the strong count (the standard only mandates thread-safe ref-count operations; the typical implementation is atomics). Destruction or reset decrements, and at zero the deleter runs.
std::weak_ptr does not participate in the strong count. It keeps the control block from going away so you can still ask “is anything left?”
std::shared_ptr<int> s = std::make_shared<int>(7);
std::weak_ptr<int> w = s;
s.reset(); // int destroyed, strong count 0
// w is expired, but the control block was kept until w is destroyed
Practical note: every copy, assign, and destroy of a shared_ptr touches that atomic. unique_ptr is often free at runtime in comparison.
Custom deleters with shared_ptr
shared_ptr can own things that are not a plain new—C APIs, mmapd regions, whatever—if you hand it a deleter. The deleter is type-erased in the control block, unlike unique_ptr, which bakes the deleter into the type.
#include <cstdio>
#include <memory>
struct FileDeleter {
void operator()(std::FILE* f) const {
if (f) std::fclose(f);
}
};
std::shared_ptr<std::FILE> open_file(const char* path) {
std::FILE* f = std::fopen(path, "r");
if (!f) return nullptr;
return std::shared_ptr<std::FILE>(f, FileDeleter{});
// or: return std::shared_ptr<std::FILE>(f, [](std::FILE* x) { if (x) std::fclose(x); });
}
C++17 arrays with shared_ptr often go through a deleter: default_delete<T[]>() or a lambda that does delete[] when you are not on the shared_ptr<T[]> specialization (more on that in a bit).
If you get the deleter wrong—delete instead of delete[], or closing the wrong handle—the compiler will happily ship it. Undefined behavior on teardown.
Aliasing constructor: shared-ownership tricks
The aliasing constructor (conceptually) looks like this:
shared_ptr<T>( const shared_ptr<U>& r, T* p );
Ownership and the refcount still come from r’s control block, but this shared_ptr dereferences to p, which has to stay valid for the lifetime of whatever r is keeping alive (usually a subobject of the same allocation).
#include <memory>
struct Inner { int x = 2; };
struct Outer {
Inner inner;
explicit Outer() : inner() {}
};
int main() {
auto po = std::make_shared<Outer>();
// Share lifetime with Outer, but "point to" inner as Inner*
std::shared_ptr<Inner> pInner(po, &po->inner);
po.reset(); // destroys Outer when pInner is last? No: pInner still holds strong ref to same count
} // pInner destroyed, then Outer destroyed
I use this when I need a shared_ptr to a member but I want the parent object to stay alive. The footguns: if p is not a subobject of r’s managed object, you are holding nonsense; and you still must not build two unrelated shared_ptr graphs over the same memory without a common block.
weak_ptr: the non-owning observer
std::weak_ptr<T> does not keep T alive. It watches the same control block. You upgrade with lock() to get a temporary shared_ptr when you need a real guarantee. That is the usual way to break retention cycles and to model “maybe it is already gone.”
std::weak_ptr<int> w;
{
std::shared_ptr<int> s = std::make_shared<int>(5);
w = s;
} // s gone, int destroyed; w is expired
I reach for this when A should not keep B alive, but A sometimes still wants to call into B if B exists—parent/child, pub/sub, cache entries, that sort of thing.
Cyclic references: the problem and the fix
A cycle of strong shared_ptr edges means no node in the loop ever hits refcount zero, even if the rest of the program has dropped all its outside references. The heap still thinks the objects are owned.
struct B;
struct A {
std::shared_ptr<B> b;
~A() = default;
};
struct B {
std::shared_ptr<A> a; // CYCLE: if both set, neither side hits refcount 0
~B() = default;
};
void bad() {
auto pa = std::make_shared<A>();
auto pb = std::make_shared<B>();
pa->b = pb;
pb->a = pa; // cycle
}
// After bad() returns, memory may still be unreachable in the logical sense
What do you do? Make one direction weak_ptr, or pick one canonical owner (a document owns nodes; nodes only weak_ptr back to the parent/peer), or go arena-style and free in bulk. For the back edge, it often looks like this:
struct B2 {
std::weak_ptr<class A2> a; // no strong back-edge
};
If this feels like architecture, good—it is. Refcounting will not design your graph for you.
lock(): safe upgrade from weak to shared
lock() checks, atomically, whether the object is still there. If the strong count is still positive, you get a shared_ptr that shares ownership. Otherwise you get an empty shared_ptr.
void maybe_use(std::weak_ptr<int> w) {
if (std::shared_ptr<int> s = w.lock()) {
std::cout << *s << "\n";
} else {
std::cout << "gone\n";
}
}
Pattern I like: put the lock() result in a local shared_ptr and work off that for the whole operation. Do not lock() twice and assume the pointee in between; another thread can reset in the real world.
This still does not give you a free pass on data races: lock() extends lifetime, it is not a mutex for mutable state inside *s.
expired(): check without locking
w.expired() is basically “is the strong count zero from this weak_ptr’s point of view?” The gotcha is the A-B-A race: you see !expired(), and before you lock() something else drops the last shared_ptr. The boring safe move is: use lock() and branch on the returned shared_ptr, or do check and use in one place with lock().
expired() is fine for logging or for skipping work when a false “still alive” is harmless. I would not use it as a stand-in for lock() right before you dereference.
std::enable_shared_from_this: a safe this pointer
If the object is already owned by a shared_ptr and you need to hand this out as shared ownership to an API (async, registration, etc.), you do not write std::shared_ptr<T>(this)—that can double-delete.
#include <memory>
struct Good : std::enable_shared_from_this<Good> {
std::shared_ptr<Good> self() {
return shared_from_this();
}
};
int main() {
std::shared_ptr<Good> p = std::make_shared<Good>();
std::shared_ptr<Good> q = p->self(); // same control block
}
Two rules that have burned people: the object has to already live inside a shared_ptr when you call shared_from_this(); stack-allocated Good will throw bad_weak_ptr. And do not call it from the constructor—the shared_ptr is not wiring up the control block until after construction. Use a two-phase init() if you must.
If you need a non-owning handle from inside the object, weak_from_this() is often the right companion.
Thread safety: atomic refcount vs data races
The standard gives you: concurrent copy, assign, and destroy of shared_ptr objects that share ownership, with refcount updates that behave correctly for the count, so the last destructor runs once. None of that makes *p thread-safe. If two threads write through the same T with no sync, you have a data race on T.
Another sneaky one: one thread resets while another keeps using a stale raw pointer or reference from get(). Synchronize T, or keep things immutable after you share them.
C++20 adds std::atomic<std::shared_ptr<T>> for the case where the variable holding the shared_ptr itself is the thing multiple threads read and write. That is not the same as “I do not have to think about T.”
Net: refcounts are thread-safe for refcounting. Your invariants on T are still on you.
Performance: the cost of shared_ptr
You pay for atomics on every owner copy and destroy, extra indirection (object pointer + block pointer) versus unique_ptr’s single pointer, and sometimes two allocations if you do new T in a way that does not coalesce with the block (where make_shared would have helped but you could not use it).
It really stings in hot loops that copy shared_ptr for no good reason. Pass const&, or pass a string_view / span to the data, or move. If you do not need shared ownership, unique_ptr is cheaper and clearer.
weak_ptr::lock() is not a raw pointer deref. You are paying for the right lifetime story.
Comparison operators: ==, !=, <, and owner_before
operator== compares the managed address the shared_ptr stores (after any aliasing). Two empty shared_ptrs compare equal; empty vs non-empty do not. With aliasing, the stored pointer matters, so you can have two shared_ptrs that feel “about the same object” in English but still compare not equal if their stored addresses differ while sharing a block.
!= is the usual negation. Ordering (std::less, operator<) is defined in an owner-based way so you can use shared_ptr in ordered containers. owner_before is there for trickier cases where you need a strict ordering that lines up with the ownership group, sometimes mixing shared_ptr and weak_ptr.
std::shared_ptr<int> a, b;
if (!a.owner_before(b) && !b.owner_before(a)) {
// same owner identity (e.g. both empty or pertain to same block—implementation phrasing)
}
For “are these the same value?” you usually want to compare *p to *q, not whether the shared_ptr handles match—unless identity of the shared_ptr is literally what you mean.
Type erasure: shared_ptr<void>
A common handle type is std::shared_ptr<void> with a deleter that knows the real type:
#include <memory>
struct Base {};
struct Impl : Base {};
std::shared_ptr<void> make_erased() {
// `shared_ptr<Impl>` converts to `shared_ptr<void>`; deleter and lifetime stay correct
return std::make_shared<Impl>();
}
In real code I still prefer a templated factory that returns shared_ptr<Interface> to shared_ptr<Concrete> with a virtual destructor on the base, over sprinkling void* everywhere. The point stands: shared_ptr type-erases the deleter, which is part of why it has a bit more runtime overhead than a unique_ptr with a fixed deleter type.
Arrays: shared_ptr<T[]> and shared_ptr<T[N]> (C++17)
C++17 let you do:
#include <memory>
// shared_ptr to dynamic array, uses delete[] automatically
std::shared_ptr<int[]> p1(new int[4]{1,2,3,4});
// Or with make_shared: available for arrays in the standard since C++20
// (check your stdlib): std::make_shared<int[4]>(1,2,3,4) — compiler-dependent
// C++20: make_shared for arrays is in the standard; example:
// auto p2 = std::make_shared_for_overwrite<int[1024]>();
Before C++17, you would pair new T[n] with a custom delete[] deleter, or you would use std::vector and sleep better.
std::make_shared_for_overwrite (C++20)
std::make_shared_for_overwrite<T>() (and the array forms) give you storage you can treat as “fill before read” in performance code—you are on the hook for write-before-read rules for the type.
#include <memory>
void demo() {
// Single object, storage suitable for overwrites after placement init work
auto p = std::make_shared_for_overwrite<int>();
*p = 42; // must initialize before use for int
}
Use it when you are about to blast in real data in one go and you want the make_shared allocation story. Read the standard and your team’s safety rules: reading uninitialized int is still your bug.
Observer pattern: weak_ptr in subjects
Anti-pattern: a subject holds std::vector<std::shared_ptr<Observer>> when the subject should not keep the observer alive—observers end up “owned” by the thing they are observing, which is often backwards.
Better: std::vector<std::weak_ptr<Observer>> (or callbacks with a weak capture). On notify, walk the list, lock() where you can, and prune the expired weak entries so the vector does not grow forever with zombies.
#include <memory>
#include <vector>
class Session;
class Notifier {
std::vector<std::weak_ptr<Session>> clients_;
public:
void add(std::weak_ptr<Session> w) { clients_.push_back(w); }
void ping() {
for (auto it = clients_.begin(); it != clients_.end(); ) {
if (auto s = it->lock()) {
s->on_ping();
++it;
} else {
it = clients_.erase(it); // drop dead weak refs
}
}
}
};
class Session {
public:
void on_ping() {}
};
You still have to handle the “nobody home” case without crashing. That is the trade for not immortalizing every observer.
Cache implementation: weak_ptr as a value
Pattern: a map from a key to a weak_ptr. If something else still holds a shared_ptr to the value, lock() and return it. If it is expired, build a new strong object and register again. If only the cache has weak entries and no one has shared_ptr anywhere, everything decays and you re-create on demand—sometimes exactly what a soft cache wants.
#include <memory>
#include <string>
#include <unordered_map>
#include <mutex>
class Data {};
class WeakCache {
mutable std::mutex mu_;
std::unordered_map<std::string, std::weak_ptr<Data>> map_;
public:
std::shared_ptr<Data> get(const std::string& key) {
std::lock_guard lock(mu_);
if (auto it = map_.find(key); it != map_.end()) {
if (std::shared_ptr<Data> s = it->second.lock()) return s;
}
// Miss or expired: caller might insert strong ref elsewhere, then re-register
return nullptr;
}
void put(const std::string& key, const std::shared_ptr<Data>& s) {
std::lock_guard lock(mu_);
map_[key] = s; // weak from shared
}
};
The annoying bit is coordination: on a miss you often need a policy (mutex + once flag, or a single factory) so you do not stampede ten threads into creating the same object.
Common mistakes: pitfalls
std::shared_ptr<T>(this) from inside T: you can build a second control block for the same object, so the destructor can run twice. That is what enable_shared_from_this is for.
shared_ptr to a stack T: you are going to get double-destruction or other UB when the stack unwinds and the shared_ptr also tries to delete. Do not do it.
A loop of shared_ptr edges: strong counts in the loop never hit zero, so you get a logical leak like the production story at the top. Fix the graph.
Holding a raw pointer from get() after the last shared_ptr is gone: classic use-after-free. Use weak_ptr to observe and lock() to extend life.
Confusing atomic refcount with a thread-safe T: it is not. You still need real synchronization for shared mutable data.
Trusting use_count() == 1 in multithreaded code as a lock: the count can flip between your read and the next line. Use a real mutex (or a design with one owner) instead.
make_shared of a very large T plus long-lived weak_ptr: the single combined allocation for object + block can keep a big chunk reserved longer than split allocations. Know your memory budget.
Wrong deleter for FILE*, delete[], etc.: teardown is UB and you will not get a nice compiler error.
Hard-learned lessons
Default to unique_ptr or value semantics unless you can name the multiple owners. Single ownership is cheaper in atomics and in head space.
Use make_shared when a single block works for you—fewer allocs, better locality, and many constructor expressions get cleaner exception safety.
Put weak_ptr on back-edges, caches, and observers when you do not want to extend lifetime. That is how you avoid accidentally immortal subgraphs.
Pass shared_ptr by const& when the callee is only reading or borrowing for the call; pass by value when you are storing a new owner and you want the refcount bump to happen at the boundary. In hot paths, unnecessary copies hurt.
Call lock() once and keep the result in a local shared_ptr for notify loops and async work. That is the straightforward way to avoid UAFs and to make the “strong for this scope” story obvious.
If you delete through a base pointer, give the base a virtual destructor when you use polymorphic shared_ptr<Base> to a Derived from a single allocation path.
Be stingy with get() and with constructing shared_ptr from random raw pointers unless the ownership contract is loud and clear. Otherwise you will ship ownership ambiguities.
Gotchas
RSS never drops after you “let go” of a big graph, but the process still looks busy holding memory: look for a shared_ptr cycle or a static / singleton that still holds a strong ref. weak_ptr on one of the back-edges, or a whiteboard session on who actually owns what, usually fixes it.
bad_weak_ptr in shared_from_this: the object was not in a shared_ptr yet, or you called from the constructor before the shared_ptr fully adopted this. Factory + make_shared + a post-init() hook is the boring fix.
Double free or ASan on shutdown after someone got clever with shared_ptr(this) or two independent shared_ptr to the same new without shared counts: you want one control block per allocation story—enable_shared_from_this, or a single factory that hands out the handles.
Use-after-free in a callback from a raw this or a get() that outlives the object: switch to weak_ptr + lock(), or cancel the callback in the destructor, or pass ownership explicitly—pick one real strategy.
Refcount is hot on a profile: you are copying shared_ptr too much on a tight loop. const&, span over the data, move where it makes sense, or revisit whether it had to be shared in the first place.
The cache “always” misses right after you thought you hit it: if nothing else holds a shared_ptr and the cache only stored weak, of course the entry expired. Either keep a strong ref somewhere you actually mean to, or accept recreate-on-miss as the design.
Real examples: end-to-end code
Example A — graph with a safe parent link
#include <memory>
#include <string>
#include <vector>
class Node;
struct Node : std::enable_shared_from_this<Node> {
std::string name;
std::vector<std::shared_ptr<Node>> children;
std::weak_ptr<Node> parent; // no strong back-edge
explicit Node(std::string n) : name(std::move(n)) {}
};
int main() {
auto root = std::make_shared<Node>("root");
auto child = std::make_shared<Node>("child");
child->parent = root;
root->children.push_back(child);
// Dropping all external sp's to child still allows root to own child;
// but parent is weak, so no cycle with root<->child
return 0;
}
Example B — async work with shared_from_this
#include <memory>
#include <thread>
#include <chrono>
struct Job : std::enable_shared_from_this<Job> {
void start() {
std::weak_ptr<Job> wk = weak_from_this();
std::thread([wk]() {
std::this_thread::sleep_for(std::chrono::milliseconds(10));
if (std::shared_ptr<Job> j = wk.lock()) {
j->done();
}
}).detach();
}
void done() {}
};
int main() {
auto j = std::make_shared<Job>();
j->start();
// Keep the last strong ref until the detached thread can lock()
std::this_thread::sleep_for(std::chrono::milliseconds(50));
return 0;
}
Example C — safe cache hit
#include <memory>
#include <string>
#include <unordered_map>
struct Blob { int bytes{}; };
std::shared_ptr<Blob> get_blob(const std::string& key,
std::unordered_map<std::string, std::weak_ptr<Blob>>& cache) {
if (auto it = cache.find(key); it != cache.end()) {
if (std::shared_ptr<Blob> s = it->second.lock()) return s;
}
auto fresh = std::make_shared<Blob>();
fresh->bytes = 1024;
cache[key] = fresh;
return fresh;
}
Here the returned shared_ptr<Blob> is strong, so the cache entry and the caller both participate in the refcount until the last owner bails.
Conclusion
std::shared_ptr and std::weak_ptr give you shared and observing lifetimes on top of refcounting and a control block. Reach for make_shared when it fits, use weak_ptr to break cycles and to mark edges that should not keep things alive, and use enable_shared_from_this when you must hand out this as real shared ownership without inventing a second control block. Remember: an atomic refcount is not a data-race lock for the pointee. When things get weird, profile refcount churn in hot paths and draw the ownership on paper before you trust the symptoms.
Further reading on this project: shared_ptr vs unique_ptr and circular references in depth.
FAQ (quick)
Is shared_ptr ever null? Yes: default-constructed, reset to empty, or moved from. If your API can return empty, check it.
Can I mix make_shared and a raw new? You can adopt a pointer with the constructors, but you only get one control block per allocation story—never two independent shared_ptr to the same raw pointer from two separate new calls that were not set up to share.
What about atomic_shared_ptr? In C++20, std::atomic<std::shared_ptr<T>> helps when the shared_ptr variable itself is the shared state between threads. It does not replace synchronization for the data inside *p.
If you are sketching new C++ in 2026, treat shared_ptr as a deliberate design choice, not the default, and keep weak_ptr discipline anywhere you have back pointers or callbacks that could otherwise make objects live forever by accident.