Circular references in singletons are most likely to occur in logging modules and configuration management modules. If a circular reference is triggered during the construction phase, it can be guaranteed by the uniqueness of static variable initialization in C++11. The danger lies in triggering a circular reference during the destruction phase, which can lead to a core dump.
The singleton pattern is a common method for managing global resources in C++ development—core components such as logging modules, configuration management, and connection pools almost all rely on it.
However, the “globally unique” characteristic of singletons also hides a trap that is easy to overlook: circular references. When two or more singletons hold references to each other, it can not only prevent resources from being released but also cause crashes during program exit, memory leaks, and even strange state inconsistency issues at runtime.

Let’s understand this double-edged sword of singletons through a practical case.
1. A Circular Reference Case That “Seems Fine”
Suppose we are developing a server program that includes two core singletons: Logger (log manager) and Config (configuration manager).
Logically, Logger needs to obtain the log output path from Config, while Config needs Logger to record information during the configuration loading process—forming a circular reference.
The code implementation (problematic version) is as follows:
#include <iostream>
#include <memory>
#include <string>
// Singleton base class (lazy, thread-safe simplified version)
template <typename T>
class Singleton {
protected:
Singleton() = default;
Singleton(const Singleton&) = delete;
Singleton& operator=(const Singleton&) = delete;
public:
static std::shared_ptr<T> getInstance() {
static std::shared_ptr<T> instance = std::make_shared<T>();
return instance;
}
virtual ~Singleton() = default;
};
// Logger singleton
class Logger : public Singleton<Logger> {
friend class Singleton<Logger>;
private:
// Strong reference to Config
std::shared_ptr<class Config> config_;
Logger() {
// Get Config instance during initialization
config_ = Config::getInstance();
std::cout << "Logger initialized" << std::endl;
}
public:
void log(const std::string& msg) {
// Get log path from Config (simplified)
std::string path = config_->getLogPath();
std::cout << "[" << path << "] " << msg << std::endl;
}
};
// Config singleton
class Config : public Singleton<Config> {
friend class Singleton<Config>;
private:
// Strong reference to Logger
std::shared_ptr<Logger> logger_;
std::string log_path_ = "/var/log/";
Config() {
// Get Logger instance during initialization
logger_ = Logger::getInstance();
logger_->log("Config loaded"); // Log configuration loading
std::cout << "Config initialized" << std::endl;
}
public:
std::string getLogPath() const {
return log_path_;
}
};
int main() {
// Get Logger instance and print log
auto logger = Logger::getInstance();
logger->log("Program started");
return 0;
}
When the program runs, the output seems normal:
# Logger initialized
# Config initialized
# [ /var/log/ ] Config loaded
# [ /var/log/ ] Program started
However, upon exiting the program, we find a serious issue: the destructors of Logger and Config are not called (attempting to add print statements in the destructors will confirm this). This means that the resources of the two singletons (if any) are not released, causing memory leaks.
2. How Circular References “Lock” the Lifecycle of Singletons?
To understand the root of the problem, we need to clarify the cooperation mechanism between singletons and smart pointers.
The above code uses std::shared_ptr to manage singletons, and the destruction of shared_ptr depends on the reference count reaching zero. However, circular references break this mechanism, forming a closed loop in the reference chain.
Logger’s config_ holds a strong reference to Config, and Config’s logger_ holds a strong reference to Logger, forming a closed loop between Logger and Config.Reference counts cannot reach zero.
During program execution, the reference count of both singletons is at least 1 (mutually held). Even if the logger is released in the main function, the reference counts of the two singletons remain at 1, preventing shared_ptr from calling the destructors, and resources can never be released.
Initialization phase:
Logger instance reference count = 2 (logger in main + Config's logger_)
Config instance reference count = 2 (Logger's config_ + itself being held)
After main function ends:
Logger instance reference count = 1 (Config's logger_ still holds)
Config instance reference count = 1 (Logger's config_ still holds)
→ Destructors are not executed, leading to memory leaks
3. More Dangerous Scenario: Crashes Caused by Destruction Order
If a singleton holds resources that need to be manually released (such as database connections or locks), circular references may also lead to crashes during program exit.
Suppose Logger needs to close the log file during destruction, and Config needs to release the configuration lock during destruction, while circular references cause the destruction order to become chaotic: since the reference count cannot reach zero, the destructors of the singletons may be forcibly called at the “last moment” of program exit (depending on the cleanup mechanism of the operating system or CRT).
At this point, if Logger is destructed first, and Config calls Logger’s method (such as logging the destruction) during its destruction, it will access a destroyed object, leading to a crash.
// Logger destructor
~Logger() {
std::cout << "Logger closing log file" << std::endl;
// Close log file (actual development may involve file handle operations)
}
// Config destructor
~Config() {
logger_->log("Config released lock"); // Dangerous: Logger may have been destructed
std::cout << "Config released lock" << std::endl;
}
The program exit may output:
Logger closing log file
Segmentation fault (core dumped)
Note: Different compilers may have differences, and runtime behavior is unpredictable.
4. Solutions
- Logical Separation
In the constructors and destructors of the logging and configuration classes, avoid using each other’s functionalities. This method is straightforward, but it has obvious drawbacks.
First, this approach limits the use of certain functionalities, and secondly, this limitation must be ensured by human awareness, which is not easy to achieve.
- Use Weak References to Break the Loop
The core of solving singleton circular references is to break the strong reference loop. The most common method is to change one party’s strong reference to a weak reference (std::weak_ptr). Temporarily lock it as a strong reference only when needed, avoiding binding the lifecycle. The corrected code is as follows.
// In Logger class, use weak_ptr to hold Config
class Logger : public Singleton<Logger> {
friend class Singleton<Logger>;
private:
// Weak reference: does not increase Config's reference count
std::weak_ptr<class Config> config_;
Logger() {
config_ = Config::getInstance(); // Weak reference can be constructed by strong reference
std::cout << "Logger initialized" << std::endl;
}
public:
void log(const std::string& msg) {
// Temporarily lock as strong reference (returns null if Config has been destroyed)
auto config = config_.lock();
if (!config) {
std::cout << "[unknown] " << msg << std::endl;
return;
}
std::string path = config->getLogPath();
std::cout << "[" << path << "] " << msg << std::endl;
}
~Logger() {
std::cout << "Logger closed" << std::endl;
}
};
// Config class maintains strong reference (or also change to weak reference, depending on dependency)
class Config : public Singleton<Config> {
friend class Singleton<Config>;
private:
std::shared_ptr<Logger> logger_;
std::string log_path_ = "/var/log/";
Config() {
logger_ = Logger::getInstance();
logger_->log("Config loaded");
std::cout << "Config initialized" << std::endl;
}
public:
std::string getLogPath() const { return log_path_; }
~Config() {
logger_->log("Config closed");
std::cout << "Config closed" << std::endl;
}
};
The corrected runtime result
# Logger initialized
# Config initialized
# [ /var/log/ ] Config loaded
# [ /var/log/ ] Program started
# Config closed
# [ /var/log/ ] Config closed
# Logger closed
At this point, the reference chain is broken, and Logger’s weak reference to Config does not affect Config’s reference count.
When the program exits, Config’s reference count reaches zero and destructs first, followed by Logger’s reference count reaching zero and destructing, allowing both to release resources properly.
5. Design-Level Avoidance: Reduce Singleton Dependencies
In addition to technical fixes, a more fundamental solution is to reduce dependencies between singletons at the design level.
- Merge Core Singletons
If two singletons are too tightly coupled (like Logger and Config), consider merging them into a “core service manager” to avoid cross-singleton calls.
- Introduce an Intermediate Layer
Extract shared logic into an independent utility class (non-singleton), allowing singletons to cooperate indirectly by calling the utility class instead of directly referencing each other.
- Dependency Injection
Dependencies of singletons are injected externally (e.g., constructor parameters) rather than actively obtaining other singletons internally, reducing coupling.
// Dependency injection example (simplified)
class Logger : public Singleton<Logger> {
public:
// Inject Config externally instead of obtaining it internally
void setConfig(std::shared_ptr<Config> config) {
config_ = config;
}
};
- Avoid Overusing Singletons
In many scenarios, “globally unique” is not necessary, and overusing singletons naturally leads to complex dependencies. Consider using “local instances + parameter passing” to replace some singletons.
6. Conclusion
Circular references in singletons seem to be a memory management issue, but they actually expose design coupling risks. Its harm is not only memory leaks but also potential crashes during program exit, and the issues are often hidden and hard to trace.
When using singletons, be aware: singletons should avoid holding strong references to each other to break the loop. When calling methods of other singletons in destructors, check if the other has been destroyed first. Reduce the number of singletons from a design perspective, lower direct dependencies between singletons, and introduce intermediate layers when necessary.
The core value of singletons is “resource uniqueness,” not “global reach.” Properly controlling the dependency boundaries of singletons can make this pattern a true asset rather than an “invisible shackle” that drags down the program.