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::atexitandstd::at_quick_exitin 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