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语言中的全貌:

C语言单例模式知识体系 单例模式 饿汉式 懒汉式 编译期静态分配 天然线程安全 始终占用内存 运行时动态分配 需线程安全处理 按需分配内存

嗯,单例模式就讲到这里。记住一句话:能用饿汉就别用懒汉,能用静态就别用动态,能用pthread_once就别自己加锁。这些都是我踩过坑之后总结出来的。

可可精品资料库