Quant developer HFT course · Module 2: Safe C++
RAII: tying every resource to an object
The question
Explain RAII. Then tell me what is wrong with this function that takes a mutex, writes to a socket and releases the mutex.
The function
void send_order(Gateway& gw, const Order& o) {
gw.mutex.lock();
if (!gw.connected) return; // returns with the mutex held
gw.socket.write(encode(o)); // encode can throw: mutex held forever
gw.mutex.unlock();
}
Two exits skip the unlock. The early return leaves the mutex locked, and the next thread that calls send_order waits for ever. If encode throws, for example on an allocation failure or an invalid order, the exception leaves the function without reaching unlock. Every manual release has this problem: each new exit path is another chance to forget it.
The idea
RAII, resource acquisition is initialisation, ties a resource to the lifetime of an object. The constructor acquires it, the destructor releases it. C++ guarantees that when control leaves a scope, by falling off the end, by return, by break, or by an exception, the destructor of every fully constructed local object runs. So the release cannot be skipped.
void send_order(Gateway& gw, const Order& o) {
std::lock_guard<std::mutex> lock(gw.mutex); // locks here
if (!gw.connected) return; // unlocks here
gw.socket.write(encode(o)); // or here, if encode throws
} // or here
The name is awkward; the idea is that a resource is only ever held by an object whose destructor gives it back. The standard library is built on it: std::lock_guard and std::unique_lock for mutexes, std::unique_ptr and std::shared_ptr for heap memory, std::vector and std::string for buffers, std::fstream for files, std::jthread for a thread that joins itself.
Resources beyond memory
In trading systems the resources that matter are often not memory: a socket, a file descriptor for a shared-memory segment, a registration with an exchange session, a reserved slot in a ring buffer, a CPU core claimed for a thread. Each needs a release that must happen exactly once. Wrapping each in a small class with a destructor gives the same guarantee for all of them.
class Fd {
int fd_ = -1;
public:
explicit Fd(int fd) : fd_(fd) {}
~Fd() { if (fd_ >= 0) ::close(fd_); }
Fd(const Fd&) = delete; // two owners would close twice
Fd& operator=(const Fd&) = delete;
Fd(Fd&& o) noexcept : fd_(std::exchange(o.fd_, -1)) {}
Fd& operator=(Fd&& o) noexcept {
if (this != &o) { if (fd_ >= 0) ::close(fd_); fd_ = std::exchange(o.fd_, -1); }
return *this;
}
int get() const { return fd_; }
};Writing a wrapper correctly
The class above shows the decisions every RAII type has to make:
- Copying. If copying would create two owners of one resource, delete the copy operations. A file descriptor closed twice may close a descriptor that has since been reused for something else.
- Moving. Allow it, transferring ownership and leaving the source empty, so that the wrapper can be returned from functions and stored in containers. Mark the moves
noexceptso containers use them when they grow (lesson 5). - Destructors must not throw. A destructor that throws while another exception is propagating terminates the program. Destructors are implicitly
noexceptfor this reason. - The rule of zero and the rule of five. If a class only holds members that already manage themselves, write none of the five special functions and let the compiler generate them. If it manages a resource directly, write or delete all five: destructor, copy constructor, copy assignment, move constructor, move assignment.
RAII and latency
RAII has no run-time cost beyond the release itself: the destructor call is inlined at each exit. It does make one cost easy to miss. A destructor that frees a large structure, joins a thread, or flushes a file runs at the closing brace, which in a hot function is an invisible line of work. Look at what objects are destroyed at the end of a hot scope, not only what is written inside it.
Check yourself
Question 1 of 3
Why does std::lock_guard release the mutex when an exception is thrown inside its scope?
No try or catch is involved. Unwinding destroys locals on the way out, which is what RAII relies on.
Question 2 of 3
A class owns a raw file descriptor. Which set of special members is right?
Default copying would give two objects the same descriptor and close it twice. Moving transfers ownership; the moved-from object must hold an invalid value so its destructor does nothing.
Question 3 of 3
What happens if a destructor throws while the stack is being unwound by another exception?
Two exceptions cannot propagate at once. Destructors are noexcept by default, so a throw from one terminates the program.
In the interview
Say your own answer out loud first, then open the one we would give.
What is RAII?
Tying the lifetime of a resource to the lifetime of an object: acquire in the constructor, release in the destructor. Because C++ runs destructors of locals on every exit from a scope, including exceptions, the release cannot be forgotten. lock_guard, unique_ptr, vector and fstream all work this way.
Why is RAII important for exception safety?
Every function that can throw has hidden exit paths. With manual cleanup each one is a leak or a held lock. With RAII, unwinding releases everything automatically, so code is correct on the exception path without any try blocks.
What is the rule of five, and the rule of zero?
If a class manages a resource directly, it needs to define or delete all five special members: destructor, copy constructor, copy assignment, move constructor, move assignment. The rule of zero says to avoid that where possible by composing classes from members that already manage themselves, so the compiler-generated members are correct.
Is there a performance cost to RAII?
The mechanism itself is free; the destructor call is inlined where the scope ends. The release is whatever it was going to be. The thing to watch is expensive destructors at the end of hot scopes, such as freeing large structures, which RAII makes easy to overlook.