延迟计算与懒初始化:按需创建资源,减少启动时间
说实话,我见过太多嵌入式项目,一上电就把所有资源都初始化好。结果呢?启动时间慢得像蜗牛,用户按了开关要等好几秒才有反应。这其实是个很常见的误区——你以为提前准备好是好事,实际上大部分资源可能根本用不上。
今天咱们聊聊延迟计算和懒初始化。说白了就是:不到万不得已,绝不创建资源。你想想看,一个系统里可能配置了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 的陷阱:指令重排序
等等,你以为这样就完了?没那么简单。这里有个大坑——编译器和 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_strong 或 atomic_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 的原子操作,彻底避免了指令重排序的问题。而且你看,我把 malloc 和 init_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