4. 单例模式:C语言中的单例实现、线程安全考虑、饿汉式与懒汉式
单例模式,说白了就是保证一个类只有一个实例。你想想看,在嵌入式系统里,有些资源天生就是独占的——比如硬件寄存器、系统时钟、日志缓冲区。如果谁都能 new 一个出来,那不乱套了?
我最早接触单例是在做 RTOS 驱动的时候。那时候刚入行,觉得这玩意儿不就是个全局变量嘛,搞这么复杂干嘛。后来踩了坑才明白——全局变量没法控制初始化时机,也没法做延迟加载。嗯,单例模式的价值就在这里。
4.1 饿汉式:简单粗暴,但有个坑
饿汉式,顾名思义,程序一启动就把实例创建好。你不管用不用,它都在那里。
// 饿汉式单例
typedef struct {
uint32_t config;
uint8_t buffer[256];
} Logger;
static Logger s_logger = {0}; // 程序启动时初始化
Logger* Logger_GetInstance(void) {
return &s_logger;
}
这段代码够简单吧?没有锁,没有判断,直接返回地址。性能开销几乎为零。
优点:实现简单,天生线程安全(初始化在 main 之前完成)。
缺点:如果这个对象很重(比如分配大内存、加载配置文件),程序启动会变慢。而且你没法控制它什么时候被构造。
我在项目中遇到过一个问题:某个模块的初始化依赖外设时钟使能,但饿汉式单例在时钟还没配好时就调用了构造函数。结果?硬件死锁。所以啊,饿汉式虽然简单,但初始化顺序你得心里有数。
4.2 懒汉式:用的时候再创建
懒汉式就灵活多了。你第一次调用 GetInstance 的时候,它才创建实例。之后每次调用,直接返回已有的那个。
// 懒汉式单例(非线程安全版本)
typedef struct {
uint32_t counter;
void (*reset)(void);
} Timer;
static Timer* s_timer = NULL;
Timer* Timer_GetInstance(void) {
if (s_timer == NULL) {
s_timer = (Timer*)malloc(sizeof(Timer));
s_timer->counter = 0;
s_timer->reset = NULL;
}
return s_timer;
}
你看,这里有个 if 判断。第一次进来时 s_timer 是 NULL,就分配内存并初始化。之后再来就直接返回。
注意:这个版本在多线程环境下会出问题。两个线程同时进入 if 判断,都发现 s_timer 是 NULL,然后各自 malloc 一次——单例就变成双例了。
我曾经在一个通信协议栈里用过这个版本,当时是单线程裸机程序,跑得挺好。后来移植到 RTOS 上,突然发现日志打印重复了。查了半天,原来是两个任务同时调用了 GetInstance。嗯,从那以后我再也不敢在 RTOS 里用裸机版的懒汉式了。
4.3 线程安全的懒汉式:加锁的艺术
要解决多线程问题,最直接的办法就是加锁。但加锁也有讲究——你不能每次调用都加锁,那样性能太差了。
// 线程安全的懒汉式(双重检查锁定)
#include <mutex.h> // 假设这是你的 RTOS 锁接口
static Mutex s_mutex;
static Timer* s_timer = NULL;
Timer* Timer_GetInstance(void) {
if (s_timer == NULL) {
Mutex_Lock(&s_mutex);
if (s_timer == NULL) {
s_timer = (Timer*)malloc(sizeof(Timer));
s_timer->counter = 0;
s_timer->reset = NULL;
}
Mutex_Unlock(&s_mutex);
}
return s_timer;
}
这就是经典的「双重检查锁定」。第一次检查避免不必要的加锁,第二次检查防止多个线程同时通过第一道门。
小技巧:在嵌入式系统里,如果实例很小(比如就几个字节),可以用原子操作代替锁。像 Cortex-M 系列支持 LDREX/STREX 指令,性能比锁好得多。
不过说实话,双重检查锁定在 C 语言里有个隐患——编译器的乱序优化。你想想看,s_timer 的赋值可能在对象初始化之前就被其他线程看到了。解决办法?加个内存屏障,或者用 C11 的 atomic 类型。
4.4 饿汉式 vs 懒汉式:怎么选?
| 对比维度 | 饿汉式 | 懒汉式 |
|---|---|---|
| 初始化时机 | 程序启动时 | 第一次使用时 |
| 线程安全 | 天然安全 | 需要额外处理 |
| 性能开销 | 无运行时开销 | 有锁或原子操作开销 |
| 启动速度 | 可能变慢(对象重时) | 启动快 |
| 适用场景 | 对象轻量、启动顺序可控 | 对象重量、需要延迟加载 |
我个人习惯是:能用饿汉式就用饿汉式。原因很简单——少写代码,少出 bug。只有在对象初始化依赖运行时条件(比如外设就绪、文件系统挂载)时,我才考虑懒汉式。
4.5 单例模式的核心逻辑图
下面这张图把饿汉式和懒汉式的流程画清楚了。你看一眼就能明白两者的区别。
4.6 避坑指南
最后,我把自己踩过的坑总结一下,你写代码时留个心眼:
- 不要在中断里调用 GetInstance——如果里面用了锁,中断里拿锁就是死锁。我见过有人把日志单例放在中断里打印,结果系统直接挂掉。
- 懒汉式记得处理内存释放——嵌入式系统一般不主动释放单例,但如果你用 malloc,最好在系统退出时调用 Destroy 函数。虽然很多嵌入式程序不会正常退出,但写代码的习惯要养好。
- 小心编译器优化——双重检查锁定里,s_timer 的赋值可能被编译器重排。加 volatile 或者用 atomic_store 能解决。我曾在 ARMCC 上遇到过这个问题,加了 volatile 才搞定。
- 不要滥用单例——我见过有人把每个模块都做成单例,结果代码耦合得一塌糊涂。单例适合管理共享资源,不适合做业务逻辑的容器。
好了,单例模式就讲到这里。记住一句话:饿汉式省心,懒汉式灵活。根据你的场景选,别硬套。
公众号:蓝海资料掘金营,微信deep3321