WEEK 3 · 15-WEEK TRACK · SESSIONS 2 & 3 · 9-SESSION TRACK

Memory Management & Smart Pointers

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.

Lab: raw → 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
Part 1

1What is it? A destructor you cannot forget to call

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.

Execution

Scope depth
0
Destructors run
0
Live heap objects
0
Leaked
0

    

Four ways to point at an object, and what each one claims

TypeClaimSizeCopy costUse it when
T& / const T&I observe, I do not own, I do not outlive you8 Bfreealways, on the hot path
T* (non-owning)same, but may be null8 Bfreeoptional observer
unique_ptr<T>I am the one owner; it dies with me8 Bmove-onlythe default for ownership
shared_ptr<T>we jointly own it; last one out frees it16 Batomic ++ / −−genuinely shared lifetime
weak_ptr<T>I watch it; tell me if it is gone16 Batomic ++ / −−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.

Part 2

2How it works: build an ownership graph and watch the refcount

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.

Strong count
1
Weak count
0
Object
alive
Control block
—
Atomic ops
0
Spent so far
0 ns

Ownership model

What the machine did

Find the bug: a pointer into a container that reallocated

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.

Execution


      
Why a sanitizer is not optional. A use-after-free usually works — the freed bytes still hold the old value until the allocator reuses them, so your test passes and the failure arrives three weeks later under load. Build the same code with -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
Part 3

3Why it matters: moves, refcounts and the 64-byte line

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.

Copy
—
Move
—
Ratio
—
Per tick, copying
—

Move versus copy

—
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.

The memory-layout inspector

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.

Fields, in declaration order

sizeof(Order)
—
Payload
—
Padding
—
alignof
—
Lines for 8 orders
—
Straddling a line
—
Orders per line
—
4,096 orders
—

    
And the same 64 bytes, from the other direction. Padding you did not want costs you cache lines; padding you did want buys you isolation. Two counters written by two different threads must not share a line, or every write invalidates the other core's copy — 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.
Part 4

4Five ways this goes wrong

Every one of these has shipped in a submission. Open them.

An early return, or a throw, between new and delete

The 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 compiles

shared_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.

A pointer or reference into a container that can reallocate

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.

A shared_ptr cycle, and a leak with no new in sight

Two 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.

Reading a moved-from object

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.

Part 5

5Check yourself

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)?

Part 6

6Do the work

In the lab (~60 min)

  • Reproduce the leaking early return, prove it under -fsanitize=address, then fix it twice — once with unique_ptr, once with a hand-written RAII guard.
  • Write a ScopedTimer whose destructor prints the elapsed nanoseconds, and use it to time a scope. That is RAII for something that is not memory.
  • Benchmark a raw pointer dereference, unique_ptr::operator*, and a shared_ptr copy. Explain the ordering, and report ns/op with your machine.
  • Audit your on_book line by line and flag every allocation on the hot path.

After the session

  • Convert one owning raw-pointer member in your bot to unique_ptr and delete the destructor you no longer need.
  • Reorder one hot struct largest-field-first, record sizeof before and after, and say how many more of them now fit in a cache line.
  • Re-run the latency harness and show a tighter p99.9 than last week.
  • Sketch how you would replace one make_shared in a hot loop with a pre-allocated slot — this is the seed of Week 4's pool.
Get the code
15-week track starter
Header stubs, the autograder and the C++ arena client, one topic per session.
Get the code
9-session track starter
The same stubs and the same grader, packaged for the condensed nine-session schedule.
Back
← Week 2 · C++ Performance Foundations
What every operation costs, and how to measure it without lying to yourself.
Next
Week 4 · Custom Allocators, Memory Pools & Runtime Polymorphism →
This week said never allocate on the hot path. Next week builds the machinery that makes that livable.