The right implementation

 1class Config {
 2public:
 3    static Config& instance() {
 4        static Config inst;   // C++11 §6.7: initialisation is thread-safe
 5        return inst;
 6    }
 7
 8    void set(std::string_view key, std::string value) { /* ... */ }
 9    std::string get(std::string_view key) const { /* ... */ }
10
11private:
12    Config() = default;                          // prevent construction
13    Config(const Config&) = delete;
14    Config& operator=(const Config&) = delete;
15};
16
17Config::instance().set("baud", "115200");

That’s the entire implementation. No mutex, no std::call_once, no atomic flags.

Why it’s correct: the C++11 standard guarantees that initialisation of a block-scope static is performed exactly once, even if multiple threads reach the declaration simultaneously. The compiler inserts a thread-safe guard around the first-time initialisation.


Why the old approaches were wrong

Double-checked locking — broken before C++11

 1// BROKEN (pre-C++11) — data race on ptr_
 2class Singleton {
 3    static Singleton* ptr_;
 4    static std::mutex mtx_;
 5public:
 6    static Singleton* instance() {
 7        if (!ptr_) {                     // check without lock — data race!
 8            std::lock_guard<std::mutex> lk(mtx_);
 9            if (!ptr_) {
10                ptr_ = new Singleton;    // write not visible to other threads without fence
11            }
12        }
13        return ptr_;
14    }
15};

Without memory ordering guarantees, the CPU or compiler can reorder the write to ptr_ before the constructor finishes — another thread sees a non-null ptr_ and uses an incompletely constructed object.

C++11 fixes this: the memory model defines happens-before relationships that make double-checked locking safe if you use std::atomic with the right ordering. But the function-static approach is simpler and correct by default — use that.

Heap-allocated singleton with a lock

1// Unnecessary complexity
2static Singleton* instance() {
3    static std::once_flag flag;
4    static Singleton* ptr = nullptr;
5    std::call_once(flag, [] { ptr = new Singleton; });
6    return ptr;
7}

std::call_once is correct but heavier than a static local. Use it when you need to call a factory function rather than direct construction, or when the object must be destroyed before program exit in a controlled way.


Thread-safe access to state

The singleton’s existence is thread-safe. Access to its state is not — you still need synchronisation on mutations:

 1class Config {
 2public:
 3    static Config& instance() {
 4        static Config inst;
 5        return inst;
 6    }
 7
 8    void set(std::string_view key, std::string value) {
 9        std::lock_guard<std::mutex> lk(mtx_);
10        data_[std::string(key)] = std::move(value);
11    }
12
13    std::string get(std::string_view key) const {
14        std::lock_guard<std::mutex> lk(mtx_);
15        auto it = data_.find(std::string(key));
16        return it != data_.end() ? it->second : "";
17    }
18
19private:
20    Config() = default;
21    Config(const Config&) = delete;
22    Config& operator=(const Config&) = delete;
23
24    mutable std::mutex mtx_;
25    std::map<std::string, std::string> data_;
26};

For read-heavy access, use std::shared_mutex (C++17):

 1mutable std::shared_mutex mtx_;
 2
 3// Multiple readers simultaneously
 4std::string get(std::string_view key) const {
 5    std::shared_lock<std::shared_mutex> lk(mtx_);
 6    // ...
 7}
 8
 9// Exclusive write
10void set(std::string_view key, std::string value) {
11    std::unique_lock<std::shared_mutex> lk(mtx_);
12    // ...
13}

Destruction order and the static lifetime problem

Function-static singletons are destroyed in reverse order of construction. If singleton A holds a reference to singleton B, and A is destroyed first, B’s destructor may access a destroyed A — undefined behaviour.

1// Dangerous: Logger uses Config during destruction
2class Logger {
3    ~Logger() {
4        Config::instance().set("last_log", lastMsg_);  // Config may be gone!
5    }
6};

Mitigations:

  • Avoid singletons depending on other singletons
  • Use explicit lifetime management (pass dependencies as constructor arguments)
  • Register a destructor with std::atexit and std::at_quick_exit in the correct order

This is the main argument for not using singletons at all.


When to avoid the singleton entirely

Singletons are global mutable state with a constructor. They make testing hard (can’t swap the instance for a mock), create hidden dependencies, and introduce the destruction-order problem.

Prefer: pass the object as a reference or std::shared_ptr through the constructor chain. This is dependency injection — explicit, testable, and lifetime-safe.

 1// Instead of:
 2void SensorTask::run() {
 3    Logger::instance().log("started");
 4}
 5
 6// Prefer:
 7class SensorTask {
 8    ILogger& logger_;
 9public:
10    SensorTask(ILogger& logger) : logger_(logger) {}
11    void run() { logger_.log("started"); }
12};

Use a singleton only for objects that are genuinely unique in the system (hardware peripheral wrappers, OS handles) and that don’t need to be swapped in tests.


Embedded note

On Cortex-M and most bare-metal MCUs, there is no threading — no POSIX threads, no std::thread. The singleton initialisation guard from C++11 still generates correct code (it compiles to a simple flag check), but the thread safety guarantee is irrelevant. On bare metal, static local variables are effectively always safe for single-core use.

If the linker output shows __cxa_guard_acquire / __cxa_guard_release symbols and you want to strip them (no RTOS, no concurrency at all), pass -fno-threadsafe-statics to GCC/Clang. This removes the guard overhead while keeping the once-only initialisation.


Quick reference

 1// Correct thread-safe singleton — C++11 and later
 2class MySingleton {
 3public:
 4    static MySingleton& instance() {
 5        static MySingleton inst;
 6        return inst;
 7    }
 8    // ... public interface ...
 9private:
10    MySingleton() = default;
11    MySingleton(const MySingleton&) = delete;
12    MySingleton& operator=(const MySingleton&) = delete;
13};
  • No new, no pointer, no mutex needed for the instance itself
  • Add a mutex inside for thread-safe state access
  • Prefer constructor injection over singletons for anything testable