3. 单例模式(Singleton):C语言中的单例实现、饿汉式与懒汉式、线程安全考量
单例模式,说白了就是保证一个类只有一个实例。你想想看,在嵌入式系统里,有些资源天生就是独占的——比如硬件寄存器、系统时钟、全局配置表。要是谁都能new一个出来,那不乱套了?
我刚开始做嵌入式那会儿,就吃过这个亏。一个串口驱动被两个任务同时初始化,结果波特率配置互相覆盖,调试了整整两天。嗯,从那以后,我对单例模式就格外上心。
3.1 什么是单例模式?
单例模式的核心就三点:
- 一个类只能有一个实例——全局唯一
- 自己创建自己的实例——外部不能new
- 提供全局访问点——谁都能拿到这个实例
在C语言里,我们没有class,没有构造函数,更没有new关键字。那怎么实现?用结构体+函数指针+静态变量。说白了,就是用C的语法,写出面向对象的感觉。
核心思想:把结构体定义放在.c文件里,对外只暴露一个获取实例的接口。内部用static变量保存唯一实例。
3.2 饿汉式单例
饿汉式,顾名思义——程序一启动,实例就创建好了。不管用不用,先占着坑。
我个人习惯在资源充足、启动时间不敏感的场景用饿汉式。为什么?因为它简单、安全、没有线程竞争问题。
// singleton_eager.h
#ifndef SINGLETON_EAGER_H
#define SINGLETON_EAGER_H
typedef struct {
int config_value;
void (*do_something)(void);
} Singleton;
Singleton* Singleton_GetInstance(void);
#endif
// singleton_eager.c
#include "singleton_eager.h"
#include <stdio.h>
static Singleton instance = {
.config_value = 42,
.do_something = NULL
};
static void do_something_impl(void) {
printf("Singleton is working, config = %d\n", instance.config_value);
}
Singleton* Singleton_GetInstance(void) {
instance.do_something = do_something_impl;
return &instance;
}
你看,实例在编译期就分配好了。GetInstance只是返回地址,没有任何锁、没有判断。性能极高。
适用场景:系统启动时资源充足、单例对象初始化简单、对实时性要求高。
3.3 懒汉式单例
懒汉式就不同了——不到万不得已,绝不创建。第一次调用GetInstance时才初始化。
我在项目中遇到过内存受限的MCU,比如STM32F103只有20KB RAM。这时候饿汉式就不合适了——万一这个单例一直没人用,白白浪费几百字节。懒汉式正好解决这个问题。
// singleton_lazy.h
#ifndef SINGLETON_LAZY_H
#define SINGLETON_LAZY_H
typedef struct {
int config_value;
void (*do_something)(void);
} Singleton;
Singleton* Singleton_GetInstance(void);
#endif
// singleton_lazy.c
#include "singleton_lazy.h"
#include <stdlib.h>
#include <stdio.h>
static Singleton* instance = NULL;
static void do_something_impl(void) {
printf("Lazy Singleton working, config = %d\n", instance->config_value);
}
Singleton* Singleton_GetInstance(void) {
if (instance == NULL) {
instance = (Singleton*)malloc(sizeof(Singleton));
if (instance != NULL) {
instance->config_value = 42;
instance->do_something = do_something_impl;
}
}
return instance;
}
这里有个坑——malloc在嵌入式系统里要慎用。我曾经在一个RTOS项目里用懒汉式,结果堆碎片化导致分配失败,系统直接挂掉。嗯,从那以后,我更喜欢用静态分配+懒初始化。
注意:懒汉式在多线程环境下有竞态条件。两个线程同时判断instance == NULL,会创建两个实例,违背了单例的初衷。
3.4 线程安全的懒汉式
多线程环境下,懒汉式必须加锁。我常用的方案有三种:
| 方案 | 实现方式 | 性能 | 适用场景 |
|---|---|---|---|
| 互斥锁 | pthread_mutex_lock | 每次调用都加锁,较慢 | 调用不频繁的场景 |
| 双重检查锁 | 先判断NULL,再加锁,再判断 | 初始化后无锁开销 | 调用频繁的场景 |
| 一次初始化 | pthread_once | 最优雅,只执行一次 | POSIX系统推荐 |
我个人最推荐一次初始化方案。为什么?因为C11标准提供了call_once,POSIX提供了pthread_once。它们保证初始化函数只被执行一次,而且线程安全。
// 使用 pthread_once 实现线程安全懒汉式
#include <pthread.h>
static Singleton* instance = NULL;
static pthread_once_t once = PTHREAD_ONCE_INIT;
static void init_instance(void) {
instance = (Singleton*)malloc(sizeof(Singleton));
if (instance != NULL) {
instance->config_value = 42;
instance->do_something = do_something_impl;
}
}
Singleton* Singleton_GetInstance(void) {
pthread_once(&once, init_instance);
return instance;
}
你看,代码简洁多了。pthread_once内部用了屏障指令,保证内存可见性。你想想看,这比你自己加锁判断要靠谱得多。
避坑指南:我曾经在Cortex-M3上用过双重检查锁,结果编译器优化把instance的赋值重排到了构造函数之前。另一个线程拿到的是未初始化的实例。所以,记得加volatile和内存屏障。
3.5 饿汉式 vs 懒汉式:怎么选?
我整理了一个对比表,方便你决策:
| 维度 | 饿汉式 | 懒汉式 |
|---|---|---|
| 初始化时机 | 程序启动时 | 首次使用时 |
| 线程安全 | 天然安全 | 需要额外处理 |
| 内存占用 | 始终占用 | 按需分配 |
| 启动速度 | 稍慢(初始化多) | 快 |
| 运行时性能 | 极快(无判断) | 有NULL判断开销 |
| 适用场景 | 资源充足、频繁使用 | 资源受限、不常用 |
3.6 嵌入式中的特殊考量
嵌入式环境跟PC不一样。我总结了几点经验:
- 避免动态内存分配——malloc/free在中断里不能用,堆碎片也是大问题
- 考虑中断上下文——中断里调用GetInstance,如果加锁会导致死锁
- ROM化部署——有些单例可以放在常量区,连初始化都省了
- 看门狗影响——懒汉式第一次调用如果初始化耗时太长,可能触发看门狗复位
我的建议:在裸机系统里,直接用饿汉式。在RTOS里,用pthread_once或任务级初始化。别在中断里调用GetInstance——真的会出事。
3.7 本章知识体系
下面这张图,帮你理清单例模式在C语言中的全貌:
嗯,单例模式就讲到这里。记住一句话:能用饿汉就别用懒汉,能用静态就别用动态,能用pthread_once就别自己加锁。这些都是我踩过坑之后总结出来的。
可可精品资料库