The Two static Are Not the Same Thing
A typical complete singleton class is usually written like this:
class Singleton {
public:
static Singleton& Instance() {
static Singleton instance;
return instance;
}
Singleton(const Singleton&) = delete;
Singleton& operator=(const Singleton&) = delete;
private:
Singleton() = default;
~Singleton() = default;
};
The outer static appears in the class member function declaration, indicating that Instance is a static member function. It has no this pointer and can be called directly via Singleton::Instance().
The static keyword inside the function modifies the local variable instance within the function body. It is still only visible within this function body, but its storage duration is not the automatic storage duration of “creating a new one each time the function is entered”; instead, it has static storage duration: the same object is retained throughout the program’s execution. Its initialization timing is when the control flow first passes through this declaration, rather than being reconstructed each time the function is called.
So the “singleton” here comes from the combination of several conditions:
- The name of
instanceis scoped within theInstancefunction, so the outside cannot directly obtain another object with the same name; staticmakes it retain the same object across multiple calls;- The constructor is private, so the outside cannot casually use
Singleton s; - The copy constructor and copy assignment are deleted to avoid creating a second object through copying;
- It returns
Singleton&, handing a reference to the same object to the caller, not returning a copy.
The last two items are C++11 syntax. C++98 does not have = delete and = default; you can only declare the copy constructor, assignment operator, and constructor in the private section without defining them.
What does T& actually do
T& can be read from right to left as “a reference to something of type T”. Therefore:
static T& Instance()
It means Instance returns a reference to a T object, not a T object.
return instance;
What is returned here is the static object pointed to by the local name instance. Because its lifetime spans the entire execution of the program, the reference remains valid after the function returns; if it were a regular local variable, returning a reference this way would result in a dangling reference.
Returning a reference has two direct effects: the caller receives the original object and can modify it; meanwhile, no additional object copy is made. If read-only access is the only intent, the interface can be written as const T&, but this will not automatically make other shared state inside T concurrency-safe.
What the compiler actually needs to resolve on the first call
Breaking down the invocation process into three scenarios makes it clearest.
On the first call, control flow reaches:
static T instance;
If the object has not finished initialization, call the default constructor of T. After construction is complete, execute return instance.
On subsequent calls, instance has already been initialized, so the constructor does not execute again, and the code directly returns the same object.
The problem arises when two threads simultaneously make their first call: they may both find the object “not yet initialized” at the same time. Without additional synchronization, the object may be constructed twice, one thread may read a half-constructed object, or the initialization flag may become visible to another thread before the object’s contents do.
Implementations typically hide a guard state for the local static. The following is just pseudocode to aid understanding, not the real ABI required by the standard:
if (!guard_is_complete()) {
lock_guard_for_this_static();
if (!guard_is_complete()) {
construct(instance);
mark_guard_complete();
}
unlock_guard_for_this_static();
}
return instance;
The key point is not whether there is a variable named guard, but rather that the entire sequence of “checking, constructing, and marking complete” must be protected by the implementation using a synchronization mechanism that conforms to the standard. Before initialization is complete, competing threads must wait; only after initialization is complete can they acquire the object.
Why C++98 cannot be considered thread-safe
There’s a common misconception here: C++98 is not incapable of writing this code. The local static syntax and the “initialize only once” sequencing semantics already existed in C++98.
The difference is that C++98 only describes the execution process in a single-threaded abstract machine; it lacks the standardized multi-threading execution, data race, and memory visibility rules introduced in C++11. It makes no guarantee that when two threads simultaneously pass the declaration point for the first time, the initialization must be performed by one thread while the others wait.
Therefore, the correct statement for this code under C++98 is: lazy-initialized singleton can be implemented in a single-threaded scenario; whether it is safe in a multi-threaded scenario depends on the compiler, runtime library, and platform implementation, and no guarantee can be derived from the C++98 standard itself.
This kind of C++98 version is especially not made safe by “checking the pointer before reallocating”:
static T* instance = 0;
if (instance == 0) {
instance = new T;
}
return *instance;
Two threads may both pass through instance == 0 at the same time, and create two T objects separately. Even if you add a double-checked locking pattern — “check first, acquire the lock, then check again inside the lock” — C++98 lacks a sufficient memory model to guarantee that when the pointer is published, another thread will necessarily see the fully constructed object in the correct order. volatile is not a thread synchronization tool and cannot fix this problem either.
If a C++98 project genuinely needs this guarantee, it can only offload the synchronization to platforms or libraries outside the standard, such as POSIX one-time initialization, Windows synchronization primitives, or thread libraries available at the time; alternatively, all accesses can be funneled through a mutex. That’s “patching in the guarantee with external synchronization,” not something this static line in C++98 provides on its own.
C++11 Changed the Semantics, Not This Line of Syntax
The key changes in C++11 are not inventions of:
static T instance;
This statement could have been written long ago. C++11 formally incorporated the concurrent initialization rules into the language standard: function-local variables with static storage duration are dynamically initialized when the control flow first passes through the declaration; if multiple threads enter concurrently while the variable is being initialized, other executions should wait for the initialization to complete.
This is precisely what “thread-safe initialization” means in the comment. What it guarantees is the initialization phase: T’s constructor only successfully executes once, and competing threads will not bypass the unfinished construction to directly use the object.
It does not guarantee that the business code below is inherently secure:
Singleton::Instance().append(data); // Multiple threads modifying the same object can still cause data races
If append modifies a shared container, you still need to use mutexes, atomic variables, or other proper concurrency design inside T. A singleton only addresses “when and how the object is constructed exactly once”; it does not address “how each operation inside the object is executed concurrently.”
A few corner cases in the C++11 approach
First, static T instance; requires T to be default-constructible, with the constructor and destructor accessible here. If the constructor throws, the initialization is considered incomplete; the next time control flow reaches the declaration, initialization will be retried, rather than leaving a half-constructed object behind.
Second, the initialization function must not recursively re-enter the initialization process of the same function-local static. For example, if the constructor of T calls Instance(), this enters a recursive initialization scenario that the standard explicitly prohibits, and cannot be treated as ordinary reentrance.
Third, if the instance is actually constructed, it will be destructed during the program’s termination phase. This characteristic is usually healthier than deliberately leaking memory after manually writing new, but it also means it may depend on the destruction order of other global objects. It’s best not to covertly rely on a bunch of cross-file global state inside the singleton’s constructor.
Finally, the “globally unique” nature of a singleton is mainly a single object at the language level. When crossing dynamic library, plugin, or different runtime boundaries, whether it is truly unique to the entire process also depends on the linking and loading boundaries; you cannot assume that all modules share the same instance based solely on the class name.
Conclusion: Remember This Distinction
Both C++98 and C++11 can write local static singletons; only C++11 codified the guarantee in the standard that, during concurrent initialization, the initialization happens only once while other threads wait.
So when you see code like this, it’s best to check it in three layers: static keeps the object alive for a long time, the function scope narrows the entry point, and the C++11 language rules are what give the first concurrent initialization a portable guarantee. As for whether the data inside the object can be modified simultaneously by multiple threads after construction completes—that’s a different question.
References
- C++11 Working Draft N3337: 6.7 Declaration statement
- Current C++ Working Draft: [stmt.dcl]
- WG21 N1875: C++ Threads
- Microsoft: /Zc:threadSafeInit (Thread-safe local static initialization)
写作附记
Original Prompt
$blog-writer C++ Singleton, please explain in detail the principle behind this. How is it supported at the language syntax level?
static T &Instance() { // C++11 guarantees thread-safe initialization of function-local static objects. static T instance; return instance; }Why couldn’t this be done in C++98? Why can it be done in C++11?
Comments will load when you scroll here.