Quant developer HFT course · Module 2: Safe C++
unique_ptr, shared_ptr and weak_ptr
The question
Our order manager stores every live order in a std::shared_ptr and hands copies to the risk checker and the gateway. What does that cost, and would you do it differently?
Three kinds of ownership
C++ gives you three standard ways to point at a heap object, and the choice says who is responsible for deleting it.
std::unique_ptr<T>: one owner. It holds a pointer and deletes the object in its destructor. It cannot be copied, only moved, so there is always exactly one owner. It is the size of a raw pointer (with the default deleter) and costs nothing at run time beyond the delete. This should be the default.
std::shared_ptr<T>: several owners. The object is deleted when the last shared_ptr to it is destroyed. To know when that is, it keeps a reference count in a separately allocated control block.
std::weak_ptr<T>: an observer. It refers to an object managed by shared_ptr without keeping it alive. To use the object you call lock(), which returns a shared_ptr if the object still exists and an empty one if it does not.
Raw pointers and references still have a role: a non-owning parameter that the caller guarantees is alive for the call. Passing const Order& to a function that only reads the order is better than passing any smart pointer.
What shared_ptr costs
- Size. A
shared_ptris two pointers: one to the object, one to the control block. - Allocation. The control block is allocated.
std::make_shared<T>()allocates the block and the object together in one allocation;std::shared_ptr<T>(new T)makes two. - Atomic counting. Every copy increments the strong count and every destruction decrements it. These are atomic read-modify-write operations, because copies may live on different threads. Uncontended, an atomic increment costs a few nanoseconds; contended, when threads on different cores copy the same pointer, the cache line holding the count moves between cores on every operation (Module 5).
- Unpredictable destruction. The object is destroyed on whichever thread drops the last reference, possibly in the middle of a hot path that happened to hold the last copy.
In the order manager above, each hand-off to the risk checker and the gateway copies the pointer: two atomic increments and two decrements per order, on counts that several threads touch. Each order also costs an allocation. Neither is necessary.
A better design
Ownership in an order manager is not shared. The manager owns the orders; the risk checker and the gateway look at an order while they process it. That is a single owner with borrowers:
class OrderManager {
std::vector<Order> orders_; // or a pool, see Module 3
public:
Order& create(const NewOrder& n); // owner keeps the object
void route(Order& o, RiskCheck& rc, Gateway& gw) {
if (rc.check(o)) gw.send(o); // borrowers take references, no counting
}
};
If an order must be referred to after the call returns, for example by an exchange acknowledgement that arrives later, use an id or a handle that the manager can look up and validate, rather than a pointer that keeps the object alive (lesson 7).
Where shared_ptr is the right answer
Genuinely shared, read-mostly data with an unpredictable last user is the classic case: a configuration or a reference-data snapshot that many threads read, replaced occasionally by a new version. Readers hold a shared_ptr to the version they started with; the old version is freed when the last reader finishes. Even then, the readers should take one copy per batch, not one per message.
Cycles and weak_ptr
Two objects that hold shared_ptrs to each other keep each other alive for ever: neither count can reach zero. A session that owns its strategies, where each strategy holds a shared_ptr back to the session, leaks both. The back-reference should be a weak_ptr (or a plain reference, if the owner provably outlives the strategy):
struct Session;
struct Strategy {
std::weak_ptr<Session> session; // does not keep the session alive
void on_fill() {
if (auto s = session.lock()) s->record_fill(); // nullptr if the session is gone
}
};
A detail worth knowing: with make_shared, the object and control block are one allocation, so the memory is not returned until the last weak_ptr is gone as well, even though the object's destructor ran earlier. For large objects observed by long-lived weak pointers, that can matter.
Check yourself
Question 1 of 3
What is sizeof(std::unique_ptr<Order>) with the default deleter on a 64-bit platform?
The default deleter has no state, so implementations store just the pointer. That is why unique_ptr is called zero-cost.
Question 2 of 3 · choose every correct option
Which operations on std::shared_ptr perform an atomic read-modify-write? Choose all that apply.
Copies and destructions change the count. Dereferencing reads the object pointer only, and a move transfers ownership without touching the count, which is why passing by move or by reference is cheaper than by copy.
Question 3 of 3
A parent object holds std::shared_ptr<Child> and each child holds std::shared_ptr<Parent>. What happens when the last outside reference to the parent is dropped?
Reference counting cannot see cycles. One direction has to be non-owning, usually the back-pointer, as a weak_ptr or a plain reference when lifetimes allow.
In the interview
Say your own answer out loud first, then open the one we would give.
When would you use shared_ptr versus unique_ptr?
unique_ptr by default: one owner, no overhead beyond the pointer. shared_ptr only when ownership is genuinely shared and I cannot say in advance who finishes last, such as a read-mostly snapshot handed to several threads. Functions that only use an object should take a reference, not a smart pointer.
What does shared_ptr cost?
Two pointers in size, a control block allocation, which make_shared merges with the object's, and an atomic increment or decrement on every copy and destruction. Under contention those atomics bounce a cache line between cores. And the object is destroyed on whatever thread drops the last reference, which can land in a hot path.
What is weak_ptr for?
Observing an object managed by shared_ptr without keeping it alive, typically to break ownership cycles or for caches. You call lock() to get a temporary shared_ptr, which is empty if the object has gone.
Why prefer make_shared?
One allocation for the object and the control block instead of two, better locality, and no window where a raw new has happened but ownership has not been taken. The trade-off is that the memory is not released until the weak references are gone too.