Skip to main content

Welcome to a Grasp lesson

Create a free account to keep studying and create your own study path.

Lesson 45

RAII with `std::unique_ptr` vs. `std::shared_ptr` Performance

Recommended resources

Hello! Welcome back to our bootcamp.

In the previous lesson, we explored how to write safer and more efficient C++ code by moving computations to compile time with constexpr and eliminating pointer ambiguity with nullptr. We established that modern C++ provides tools to build robust systems by leveraging the compiler to enforce correctness.

Today, we will build directly on that theme by tackling one of the most critical concepts in modern C++: resource management. We will explore the Resource Acquisition Is Initialization (RAII) pattern, which is the cornerstone of safe and predictable C++ programming. You will learn to implement this pattern using std::unique_ptr, the workhorse of embedded C++, and to critically evaluate std::shared_ptr, understanding why its use is often an anti-pattern in time-critical systems. This is a frequent and deep topic in interviews for senior roles at companies like AMD, Nvidia, and Qualcomm.

Your learning outcome for this lesson is to: Implement RAII patterns using std::unique_ptr and justify why std::shared_ptr is often unsuitable for time-critical contexts.


1. The RAII Principle: A System-Level Safety Model

Before we look at any code, it's essential to understand the principle that governs modern C++ resource management. In C, you manually manage resources (e.g., malloc/free, fopen/fclose). This is error-prone, especially with multiple return paths or exceptions.

RAII (Resource Acquisition Is Initialization) is a C++ language idiom that binds the life cycle of a resource to the lifetime of an object.

  • Acquisition: The resource is acquired in the object's constructor.
  • Release: The resource is released in the object's destructor.

Because C++ guarantees that destructors are called when an object goes out of scope (including during stack unwinding from an exception), RAII ensures resources are never leaked.

For a professional standard, it's best to think of RAII not as a simple memory trick, but as a core contract for system correctness.

[PDF] Clean Code in C++

This brief guide, 'Clean Code in C++', frames RAII as a mandatory principle for professional development. Let's read why.

Please read two parts of this document: Rule 3 — RAII Is Mandatory, Not Optional (page 15, section 1.3). This sets the stage. Chapter 6 — RAII as a System-Level Safety Model (starts on page 46). Skim this chapter to grasp the breadth of what RAII guarantees: not just memory safety, but also exception safety, composable lifetimes, and transactional integrity, even in kernel and embedded systems. Focus on the 'What RAII Actually Guarantees' section.

Smart pointers are the C++ standard library's primary tools for implementing the RAII pattern for dynamically allocated memory.


2. std::unique_ptr: The Embedded Standard for RAII

std::unique_ptr is the embodiment of RAII for exclusive ownership. It ensures that one, and only one, unique_ptr owns a resource at any time.

Key characteristics that make it ideal for embedded systems:

  • Exclusive Ownership: The ownership model is simple and clear. There is no ambiguity about who is responsible for cleanup.
  • Move Semantics: Ownership can be explicitly transferred using std::move. It cannot be copied.
  • Zero Overhead: A std::unique_ptr is the same size as a raw pointer. It has no performance overhead compared to using a raw pointer, but provides enormous safety benefits.
Life Cycle of a unique_ptr
This diagram illustrates the core concept of `std::unique_ptr`. An 'Entity' is created and its ownership is passed from function to function via `std::move`. At any point, there is only one owner. When the final owner (`fun4`) goes out of scope, the entity is automatically destroyed.

Practical Implementation: Beyond Memory

While unique_ptr is perfect for managing heap memory with new/delete, its real power in embedded systems comes from managing any resource via a custom deleter. This allows you to apply the RAII pattern to hardware peripherals, file handles, locks, or any resource that has an acquire/release protocol.

Let's consider a practical automotive example: managing a CAN bus peripheral. Suppose we have a C-style API to work with it:

// C-style API for a CAN controller
typedef struct can_handle_t can_handle_t;

can_handle_t* can_open(int bus_id); // Acquires the handle
void can_start(can_handle_t* handle);
void can_stop(can_handle_t* handle);
void can_close(can_handle_t* handle); // Releases the handle

A naive C++ wrapper might still leak the handle if an exception occurs or a developer forgets to call can_close. A robust RAII implementation using unique_ptr looks like this:




#include <iostream>
#include <memory>

// C-style API from above...

// 1. Define the custom deleter
struct CanHandleDeleter {
    void operator()(can_handle_t* handle) const {
        if (handle) {
            std::cout << "Stopping CAN bus...\n";
            can_stop(handle);
            std::cout << "Closing CAN handle...\n";
            can_close(handle);
        }
    }
};

// 2. Create a type alias for convenience
using CanHandlePtr = std::unique_ptr<can_handle_t, CanHandleDeleter>;

void process_can_messages(const CanHandlePtr& can_ctrl) {
    // Process messages...
    std::cout << "Processing messages on the CAN bus.\n";
    // Let's pretend an error occurs
    throw std::runtime_error("CAN bus fault!");
}

int main() {
    try {
        std::cout << "Opening CAN handle for bus 0...\n";
        CanHandlePtr can_ctrl(can_open(0)); // Resource acquired

        if (!can_ctrl) {
            std::cerr << "Failed to open CAN handle.\n";
            return 1;
        }

        std::cout << "Starting CAN bus...\n";
        can_start(can_ctrl.get());
        
        process_can_messages(can_ctrl);

    } catch (const std::exception& e) {
        std::cerr << "Caught exception: " << e.what() << "\n";
    }
    // `can_ctrl` goes out of scope here.
    // Its destructor is called automatically, which invokes our custom deleter.
    // The CAN bus is guaranteed to be stopped and closed, even on the error path.
    
    return 0;
}

In this example, the CanHandleDeleter ensures that no matter how the scope is exited—normally or by an exception—the can_stop and can_close functions are called. This is deterministic, exception-safe, and has no runtime overhead. This pattern is extremely common and powerful in professional embedded C++ code.



3. std::shared_ptr: Shared Ownership and its Hidden Costs

std::shared_ptr implements RAII for shared ownership. It allows multiple pointers to co-own a resource. The resource is destroyed only when the last shared_ptr pointing to it is destroyed.

This is achieved through reference counting.

  • A shared_ptr internally points to both the object and a control block.
  • This control block is a separate heap allocation that stores the reference count (and other data).
  • Copying a shared_ptr atomically increments the reference count.
  • Destroying a shared_ptr atomically decrements the reference count. When the count reaches zero, the object is deleted.
Internal Structure of std::shared_ptr
The memory layout of a `std::shared_ptr`. It consists of two pointers: one to the managed object and another to the control block, which contains the reference count. This immediately reveals two sources of overhead: double the memory footprint of a raw pointer and a second heap allocation for the control block.

This mechanism, while flexible, comes with significant costs that are often unacceptable in resource-constrained and time-critical systems.

Feature std::unique_ptr std::shared_ptr
Ownership Exclusive (1 owner) Shared (N owners)
Size sizeof(void*) 2 * sizeof(void*) (Pointer + Control Block pointer)
Allocations 1 (for the object) 2 (object + control block), unless using make_shared
Performance Fast (no overhead) Slower due to atomic reference counting
Embedded Use Recommended (default choice) Avoid unless absolutely necessary and proven safe
(Table adapted from EWskills C++ Cheat Sheet)


4. Justifying the Unsuitability of shared_ptr

This brings us to a critical interview question: "Why is std::shared_ptr often a poor choice for embedded or real-time systems?"

There are two main arguments: non-determinism and performance overhead.

Argument 1: Non-Deterministic Destruction

In a real-time system, predictability is paramount. You need to know when operations will happen and roughly how long they will take.

The destruction of an object managed by a shared_ptr is non-deterministic. It doesn't happen when it goes out of scope; it happens when the last reference is released. In a complex system with many threads and components passing shared_ptrs around, it becomes very difficult to predict when the object's destructor will run. This can introduce unacceptable jitter into your system, as a potentially long-running destructor (e.g., freeing a large buffer, closing a hardware device) could execute at an unpredictable time.

std::unique_ptr provides deterministic destruction: the resource is released exactly when the unique_ptr object goes out of scope. This is predictable and easy to reason about.

Argument 2: Performance Overhead and Contention

The performance cost of shared_ptr is its most critical flaw in time-sensitive contexts. The reference count must be updated atomically to be thread-safe.

  • Atomic Operations are Expensive: On many architectures, atomic instructions are significantly slower than normal memory accesses. They may involve memory barriers or bus locking, stalling the CPU core and impacting other cores trying to access the same memory bus.
  • Cache Coherency Hell: The biggest issue arises in multi-core systems. The control block becomes a point of high contention. When multiple cores hold a shared_ptr to the same object, every time one is copied or destroyed, its core must gain exclusive access to the cache line containing the control block to update the reference count. This causes that cache line to be continuously invalidated and fetched across all other cores—a phenomenon known as cache thrashing or false sharing. The cores spend more time fighting over the reference count than doing useful work.

To understand just how severe this performance degradation can be, let's watch a clear explanation.

Smarter Cpp Atomic Smart Pointers - Efficient Concurrent Memory Management - Daniel Anderson CppCon

This presentation, 'Smarter Cpp Atomic Smart Pointers', provides a powerful visualization and benchmark of the performance problem with shared ownership. Although it focuses on std::atomic<std::shared_ptr>, the fundamental issue of contending on the atomic reference count is the same for using plain std::shared_ptr across threads.

Watch the segment from 05:05 to 14:40. Pay close attention to: The explanation of how the reference count is managed atomically (06:00). The critical performance issue of copying a shared_ptr across threads (07:50). The performance benchmark graph (11:50). Notice how the performance of the shared_ptr-based solution completely collapses as more threads are added. The explanation of why it's so slow: cache thrashing and serialization of atomic increments (13:00).

As the video demonstrates, the overhead is not trivial. It can completely serialize your parallel operations and destroy scalability. For this reason, shared_ptr is generally forbidden in Interrupt Service Routines (ISRs) and highly discouraged in any real-time or performance-critical code path.



Conclusion

You have now explored the modern C++ approach to resource management, a topic that separates professional C++ engineers from amateurs. Understanding the trade-offs between different smart pointers is essential for building robust and performant embedded systems.

Key Takeaways:

  • RAII is Mandatory: It is the fundamental C++ pattern for ensuring resources are managed correctly and safely, especially in the presence of errors and exceptions.
  • std::unique_ptr is the Default: It provides deterministic, zero-overhead RAII for exclusive ownership. Its ability to use custom deleters makes it a versatile tool for managing all kinds of system resources, not just heap memory.
  • std::shared_ptr has Severe Drawbacks for Embedded Systems:
    • Non-deterministic lifetime: The destructor runs at an unpredictable time, causing potential jitter in real-time systems.
    • Memory Overhead: It uses at least twice the memory of a raw pointer and requires a separate control block allocation.
    • Performance Overhead: Atomic reference counting is slow and becomes a major contention bottleneck in multi-threaded/multi-core environments, leading to cache thrashing and serialization.

In an interview, you should be able to articulate these points clearly. Start with unique_ptr as your default, and only consider shared_ptr if you can prove that its overhead and non-determinism are acceptable for a specific, non-time-critical part of your design.

Preview of the Next Lesson:

We've focused heavily on C++ features. However, a huge part of embedded development involves interfacing with existing C libraries, drivers, or legacy code. In our next lesson, we will tackle this challenge head-on by exploring how C and C++ code can coexist. We will dive into name mangling and how to use extern "C" to create clean, reliable interfaces between the two languages.