延迟计算与懒初始化:按需创建资源,减少启动时间

说实话,我见过太多嵌入式项目,一上电就把所有资源都初始化好。结果呢?启动时间慢得像蜗牛,用户按了开关要等好几秒才有反应。这其实是个很常见的误区——你以为提前准备好是好事,实际上大部分资源可能根本用不上

今天咱们聊聊延迟计算和懒初始化。说白了就是:不到万不得已,绝不创建资源。你想想看,一个系统里可能配置了10个功能模块,但用户实际只用其中2-3个。那另外7-8个模块的初始化时间,不就是白白浪费了吗?

什么是懒初始化?

懒初始化(Lazy Initialization)的核心思想很简单:对象或资源在第一次被访问时才创建。而不是在系统启动时一股脑全搞定。

我习惯把这种模式叫做「按需分配」。就像你家里请客,不会提前把20道菜全做好端上桌吧?肯定是客人来了,点一道做一道。系统资源也是这个道理。

核心优势:
  • 减少启动时间——只初始化真正需要的资源
  • 节省内存——不用的资源不占用空间
  • 提高系统响应速度——启动阶段轻装上阵

一个简单的懒初始化例子

先看个最基础的版本。假设我们有一个日志模块,系统启动时不一定需要它:

// 传统方式:启动时立即初始化
Logger g_logger;
init_logger(&g_logger);  // 不管用不用,先初始化再说

// 懒初始化方式:用的时候再创建
Logger* get_logger(void) {
    static Logger* logger = NULL;
    if (logger == NULL) {
        logger = (Logger*)malloc(sizeof(Logger));
        init_logger(logger);
    }
    return logger;
}

嗯,这里要注意:单线程环境下这么写没问题。但一旦进入多线程世界,事情就变得复杂了。

多线程下的挑战:竞态条件

我在项目中遇到过这样一个坑:两个线程同时调用 get_logger(),结果两个线程都发现 logger == NULL,于是各自创建了一个日志对象。内存泄漏不说,日志还写到了两个不同的地方,排查问题的时候差点把我搞疯。

为什么会这样?因为检查-创建这个操作不是原子的。线程A检查完还没创建,线程B也检查了,两个线程都认为需要创建。

最简单的解决办法是加锁:

pthread_mutex_t lock = PTHREAD_MUTEX_INITIALIZER;

Logger* get_logger(void) {
    static Logger* logger = NULL;
    
    pthread_mutex_lock(&lock);
    if (logger == NULL) {
        logger = (Logger*)malloc(sizeof(Logger));
        init_logger(logger);
    }
    pthread_mutex_unlock(&lock);
    
    return logger;
}

但这样有个问题:每次调用都要加锁解锁。一旦 logger 已经创建好了,后续的每次访问其实都不需要锁了。这个锁的开销虽然不大,但在高频调用的场景下,累积起来也很可观。

Double-Checked Locking 模式

这就是 DCLP(Double-Checked Locking Pattern)登场的时候了。它的思路是:先检查一次,如果没初始化再加锁,加锁后再检查一次

pthread_mutex_t lock = PTHREAD_MUTEX_INITIALIZER;

Logger* get_logger(void) {
    static Logger* logger = NULL;
    
    // 第一次检查:不加锁
    if (logger == NULL) {
        pthread_mutex_lock(&lock);
        // 第二次检查:加锁后
        if (logger == NULL) {
            logger = (Logger*)malloc(sizeof(Logger));
            init_logger(logger);
        }
        pthread_mutex_unlock(&lock);
    }
    
    return logger;
}

你看,一旦 logger 创建完成,后续所有调用都只走第一次检查,直接返回。完全不需要碰锁。这就是 DCLP 的精髓——把锁的开销降到最低

我的经验:在嵌入式系统中,DCLP 特别适合那些「可能用也可能不用」的大型资源。比如配置文件解析、网络协议栈初始化、GUI 字体缓存等。我曾经在一个 STM32 项目上用 DCLP 优化了启动时间,从原来的 3.2 秒降到了 0.8 秒。

DCLP 的陷阱:指令重排序

等等,你以为这样就完了?没那么简单。这里有个大坑——编译器和 CPU 的指令重排序

看这段代码:

logger = (Logger*)malloc(sizeof(Logger));  // 步骤1
init_logger(logger);                        // 步骤2

在单线程下,步骤1和步骤2的顺序是确定的。但在多线程环境下,CPU 可能先执行步骤2再执行步骤1!也就是说,logger 指针可能先被赋值(指向一块未初始化的内存),然后另一个线程看到 logger != NULL,直接拿去用了——结果读到的全是垃圾数据。

我曾经调试过一个这样的 bug,花了整整两天。现象是程序偶尔崩溃,但概率极低,大概跑几千次才出现一次。后来用 JTAG 抓现场才发现,是 DCLP 的指令重排序搞的鬼。

解决方案:使用内存屏障

在 C11 中,可以用 atomic_compare_exchange_strongatomic_thread_fence 来解决。在 GCC/Clang 中,可以用内置的原子操作。

#include <stdatomic.h>

Logger* get_logger(void) {
    static _Atomic Logger* logger = NULL;
    static Logger* logger_buf = NULL;
    
    Logger* tmp = atomic_load(&logger);
    if (tmp == NULL) {
        // 在临界区外分配内存,减少锁的持有时间
        Logger* new_logger = (Logger*)malloc(sizeof(Logger));
        init_logger(new_logger);
        
        // 使用 CAS 原子操作,确保只有一个人能设置成功
        if (!atomic_compare_exchange_strong(&logger, &tmp, new_logger)) {
            // 别人已经设置成功了,释放我们创建的
            free(new_logger);
        }
    }
    
    return atomic_load(&logger);
}

这个版本用了 C11 的原子操作,彻底避免了指令重排序的问题。而且你看,我把 mallocinit_logger 移到了锁外面——即使多个线程同时进入,也只是多分配几次内存,最后只有一个能成功设置,其他的会释放掉。这样锁的持有时间极短。

延迟计算的另一种形式:表达式求值延迟

除了资源初始化,延迟计算还有另一种形式——表达式不立即求值,等到真正需要结果时才计算

举个实际例子。我在做传感器数据融合时,有个公式需要计算 sqrt(a*a + b*b)。但很多时候,调用方只关心 a*a + b*b 是否超过某个阈值,并不需要真正的平方根值。这时候就可以用延迟计算:

typedef struct {
    double a;
    double b;
    double result;
    int computed;  // 标记是否已经计算过
} LazySqrt;

double get_sqrt(LazySqrt* ls) {
    if (!ls->computed) {
        ls->result = sqrt(ls->a * ls->a + ls->b * ls->b);
        ls->computed = 1;
    }
    return ls->result;
}

// 调用方可以先判断阈值,避免不必要的 sqrt 计算
if (a*a + b*b > threshold) {
    double val = get_sqrt(&ls);  // 只有这里才真正计算 sqrt
}

你想想看,如果 sqrt 被调用了 10000 次,但只有 100 次真正需要结果,那延迟计算就省掉了 9900 次昂贵的浮点运算。在 Cortex-M4 这类没有硬件浮点加速的芯片上,这个优化效果非常明显。

什么时候该用,什么时候不该用?

场景 推荐使用 原因
启动时需要快速响应的系统 ✅ 强烈推荐 减少启动时间,提升用户体验
资源使用率低于 50% 的系统 ✅ 推荐 避免创建不用的资源,节省内存
实时性要求极高的中断上下文 ❌ 不推荐 第一次访问时可能触发内存分配,延迟不可控
资源一定会被使用的场景 ❌ 不推荐 延迟初始化反而增加了代码复杂度,没有实际收益
内存极度受限的 MCU ⚠️ 谨慎使用 动态内存分配可能失败,需要做好错误处理

总结一下

延迟计算和懒初始化,说白了就是「别急着干活,等真正需要的时候再动手」。这个思想在嵌入式系统里特别实用,因为我们的资源(CPU时间、内存、功耗)都是有限的。

DCLP 模式是懒初始化在多线程环境下的标准解法。但记住:一定要处理好指令重排序的问题。我个人的建议是,能用 C11 原子操作就用原子操作,别自己手写内存屏障——太容易出错了。

最后送你一句话:优化启动时间,从「不初始化」开始。下次写代码的时候,多问问自己:「这个资源,用户真的现在就需要吗?」

公众号:蓝海资料掘金营,微信deep3321