There is no garbage collector coming to help you, and that is the point: in C++ you decide who owns each object and exactly when it dies. Get that right and cleanup is free, deterministic and invisible in your latency histogram. Get it wrong and you have a leak that grows all session, or a pointer into memory somebody else now owns.
unique_ptr, shared_ptr and weak_ptr, and price the choice in nanoseconds.on_book reads share a cache line — and so that two threads' counters do not.unique_ptr, an RAII guard, ASan, the refcount tax
Audit on_book: flag every allocation on the hot path
No shared_ptr on the tick-to-trade path
RAII is one sentence: acquire the resource in the constructor, release it in the
destructor. What makes it powerful is not tidiness — it is that the compiler emits the
destructor call on every path out of the scope, including the path an exception
takes while unwinding the stack. Step through the same function twice, once with a raw
new and once with unique_ptr, and throw the risk veto.
| Type | Claim | Size | Copy cost | Use it when |
|---|---|---|---|---|
| T& / const T& | I observe, I do not own, I do not outlive you | 8 B | free | always, on the hot path |
| T* (non-owning) | same, but may be null | 8 B | free | optional observer |
| unique_ptr<T> | I am the one owner; it dies with me | 8 B | move-only | the default for ownership |
| shared_ptr<T> | we jointly own it; last one out frees it | 16 B | atomic ++ / −− | genuinely shared lifetime |
| weak_ptr<T> | I watch it; tell me if it is gone | 16 B | atomic ++ / −− | breaking a cycle |
unique_ptr is not a compromise. It is exactly the size of a
raw pointer, its operator* compiles to the same load, and with the default deleter
there is nothing extra stored at all. The only thing you give up is the ability to
copy it — which was never a feature, since two owners of one object is the bug
the type exists to prevent.#include <memory>
struct Level { double px; int qty; };
// unique_ptr is a pointer, with a destructor attached
static_assert(sizeof(std::unique_ptr<Level>) == sizeof(Level*));
// shared_ptr is two pointers: the object and the control block
static_assert(sizeof(std::shared_ptr<Level>) == 2 * sizeof(Level*));
make_shared, new, and custom deleters// Two allocations: one for the Level, one for the
// control block — and they land far apart in memory,
// so touching the refcount misses the cache.
std::shared_ptr<Level> a(new Level{100.02, 500});
// One allocation: object and control block fused into
// a single block, adjacent in memory. Also
// exception-safe, which the line above is not.
auto b = std::make_shared<Level>(100.02, 500);
// unique_ptr's equivalent, for the same reasons
auto c = std::make_unique<Level>(100.02, 500);
Prefer the factory. The one case where make_shared loses: it keeps the
whole fused block alive as long as any weak_ptr exists, so a huge object watched by a
long-lived weak_ptr is better built with new.
// A custom deleter turns unique_ptr into RAII for
// anything with a paired acquire/release — not just
// memory. This is the pattern for every C API you
// will meet in weeks 11–13.
#include <cstdio>
#include <memory>
struct FileCloser {
void operator()(std::FILE* f) const noexcept {
if (f) std::fclose(f);
}
};
using FilePtr = std::unique_ptr<std::FILE, FileCloser>;
FilePtr open_tape(const char* path) {
return FilePtr{std::fopen(path, "rb")}; // closed on every path out
}
// A stateless deleter costs nothing: still one pointer.
static_assert(sizeof(FilePtr) == sizeof(std::FILE*));
A stateless deleter is stored in no bytes at all thanks to empty
base optimisation. A capturing lambda or a std::function deleter is not — it doubles
the pointer's size and can cost you an indirect call.
Add owners and observers to one Level object and watch what the machine
actually does. In unique_ptr mode a second owner is a compile error —
which is the whole point. In shared_ptr mode it is an atomic increment on a control block
that lives somewhere else in memory, and you pay for it on every copy and every destruction.
This one compiles, passes a unit test, and runs correctly for the first few hundred
ticks. Step through it and watch where p is pointing after the second
push_back.
-fsanitize=address and the read becomes a loud, immediate,
stack-traced error at the exact line.$ clang++ -std=c++20 -O1 -g -fsanitize=address book.cpp -o book
$ ./book
==1==ERROR: AddressSanitizer: heap-use-after-free
READ of size 8 at 0x50e000000040 thread T0
#0 0x… in hot() book.cpp:24
freed by thread T0 here:
#1 0x… in std::vector<Order>::push_back book.cpp:22
Ownership is not only about correctness. Every choice here shows up in the tick-to-trade
histogram: a copy is bytes plus an allocation, a shared_ptr copy is an atomic on a line
another core may own, and a badly ordered struct turns one cache line into two. Start with the cheapest
of the three to get right.
std::vector<Order> pending; Order o = decode(msg); pending.push_back(o); // copy: o survives, bytes duplicated pending.push_back(std::move(o)); // move: o's buffer is stolen // o is now valid but unspecified — do not read it, only reassign or destroy // std::move moves nothing. It is a cast to Order&& that says // "you may plunder this". The moving is done by the move constructor.
Here is the Order your book stores a few thousand of. Reorder the fields
and watch three numbers move: sizeof, the padding the compiler silently inserts, and how
many 64-byte cache lines a scan of eight orders has to touch. Same fields, same data — the only
variable is the order you declared them in.
alignas(64) on each is the fix, and it is why
sizeof going up is sometimes the optimisation. Week 9 explains the
coherency traffic; Week 10 depends on the fix.Every one of these has shipped in a submission. Open them.
throw, between new and deleteThe code is correct on the happy path and leaks on every other one. A risk check that returns
early, a break added six months later, an exception from a constructor two frames
down — each of them skips your delete, and in a process that runs all session the leak
is unbounded.
It survives review because the delete is visibly right there, a
few lines below the new.
Fix: never own through a raw pointer. std::make_unique puts the
delete in a destructor, and the compiler emits that destructor on every exit path
whether you thought of it or not.
shared_ptr everywhere, because it always compilesshared_ptr is the path of least resistance: it copies, so nothing ever fights you.
What you have bought is a second allocation, sixteen bytes per handle, an
atomic increment on every copy and an atomic decrement on every destruction —
and a control block that lives somewhere else in memory, so touching the refcount is frequently a
cache miss on top.
Worse, it makes the ownership question unanswerable. When everything is shared, nothing has a lifetime you can reason about, and “when is this object destroyed?” becomes a runtime mystery.
Fix: unique_ptr for ownership, raw references for observation, and
shared_ptr only when two owners genuinely both extend the lifetime. On the
tick-to-trade path, none.
std::vector::push_back may move the entire buffer, which invalidates every pointer,
reference and iterator into it. Caching &book.bids().front() across an insertion is
a use-after-free that reads the right value until the allocator reuses the bytes.
The same trap with std::string, and with a std::string_view that
outlives the string it viewed.
Fix: store an index, not an address, when the container can grow.
If you need stable addresses, reserve() the maximum up front — which you should be
doing anyway, since Week 2 said no allocation on the hot path — or use a container with stable
references such as std::deque, or Week 4's pool.
shared_ptr cycle, and a leak with no new in sightTwo objects holding shared_ptrs to each other keep each other's strong count at one
forever. Neither destructor ever runs, and no tool that looks for missing deletes will
say a word — the refcounts are perfectly consistent, just never zero.
This appears the moment your order objects point back at the book, or a subscriber list holds the subscribers that hold the list.
Fix: exactly one direction of a cycle owns. The other holds a
weak_ptr and calls lock() when it needs access — which also, usefully,
forces you to handle the case where the object is already gone.
After std::move(o), o is valid but
unspecified. For a std::vector that means empty in practice; for a user-defined
type it means whatever that type's move constructor chose to leave behind, which the standard does
not tell you. Code that reads o.size() afterwards is relying on a coincidence.
The subtle version: f(std::move(x), g(x)) — the argument evaluation order is
unspecified, so g may see x before or after it was plundered.
Fix: after moving from an object, only destroy it or assign a fresh value to it. Never move the same object twice, and never move from something you use later in the same expression.
Three questions, instant feedback, nothing recorded.
Q1A function holds a std::unique_ptr<Order> as a local. Halfway through, a risk check throws. What happens to the Order?
Q2What does copying a shared_ptr<Level> actually cost, and why does that matter inside on_book?
Q3Order declares, in this order: char side, double px, std::uint8_t flags, std::uint64_t id, std::uint16_t venue, std::uint32_t qty, std::uint32_t seq. The payload is 28 bytes. What is sizeof(Order)?
-fsanitize=address, then fix it twice — once with unique_ptr, once with a
hand-written RAII guard.ScopedTimer whose destructor prints the elapsed nanoseconds, and use it to
time a scope. That is RAII for something that is not memory.unique_ptr::operator*, and a
shared_ptr copy. Explain the ordering, and report ns/op with your machine.on_book line by line and flag every allocation on the hot path.unique_ptr and delete the
destructor you no longer need.sizeof before and after, and say
how many more of them now fit in a cache line.make_shared in a hot loop with a pre-allocated
slot — this is the seed of Week 4's pool.