前言
「线程安全」和「延迟执行」是两个方向相反的需求:前者要求多个线程同时访问时结果仍然正确,后者要求把工作推到未来某个时刻再做。它们经常一起出现——比如一个需要昂贵初始化的全局配置,既希望只初始化一次(线程安全),又希望第一次真正用到时才初始化(延迟执行);再比如一个后台任务队列,任务提交后不立刻执行,而是等到有工作线程空闲或到达指定时间点。
常见的误解有两类。一类是把「线程安全」当成给整个类加一把大锁就完事,结果把互斥量保护的范围扩大到包含 I/O 和耗时计算,程序虽然正确却退化成串行。另一类是以为std::async一定在另一个线程里异步执行,或者以为创建了std::future任务就已经开始跑了——实际上std::async的默认启动策略允许实现选择延迟执行,std::launch::deferred更是明确要求等到get()或wait()时才在调用线程上执行。
本文先讲清线程安全的几种实现手段及各自的边界,再讲延迟执行的三种语义(惰性求值、延后调度、定时等待),最后把它们组合成一个线程安全的惰性初始化例子。代码以 C++17 为基准;本机没有工具链,所有代码均未编译运行,行为描述依据标准语义。
一、线程安全护的是什么
严格说,「线程安全」不是函数的固有属性,而是「这段代码在多个线程并发调用时,对共享数据的访问是否满足标准的要求」。如果函数只操作局部变量和参数,它天然是可重入的;一旦读写全局变量、静态变量、堆上被多个线程共享的对象,就必须建立同步。
同步手段可以按代价和适用面排序:
| 手段 | 保护范围 | 是否阻塞 | 适用场景 |
|---|
不共享(线程局部thread_local、按值传参) | 无共享数据 | 否 | 首选,能设计成无共享就不要共享 |
局部static初始化 | 单个对象的初始化 | 首次可能阻塞 | 单例、惰性全局对象(C++11 起保证线程安全) |
std::call_once | 一次性初始化 | 首次可能阻塞 | 多个对象或非局部静态的初始化 |
std::mutex+lock_guard | 临界区内的所有访问 | 是 | 一般共享数据结构 |
std::atomic<T> | 单个对象的读改写 | 通常不阻塞 | 计数器、标志位 |
std::shared_mutex(C++17) | 读多写少 | 读写互斥 | 缓存、配置表 |
两条边界必须说清楚。第一,std::atomic<T>只保证对T本身的加载、存储、读改写是原子的,T是结构体时,通过原子对象拿到的副本再访问其成员,不受保护。第二,volatile不是同步手段,它只阻止编译器把访问优化掉,不提供原子性和跨线程的顺序保证;用它做线程间标志位是典型的未定义行为(undefined behavior, UB)。
C++11 起,函数内局部静态变量的初始化是线程安全的:多个线程同时到达时,只有一个执行初始化,其余阻塞等待;如果初始化抛出异常,下一次到达的线程会重新尝试。这条规则让「惰性单例」不再需要手写双重检查锁定:
// 适用于 C++17
class Config {
public:
static Config& instance() {
static Config inst; // 首次调用时构造,C++11 起线程安全
return inst;
}
int timeout_ms() const { return timeout_ms_; }
private:
Config() : timeout_ms_(3000) {}
int timeout_ms_;
};老教材里的双重检查锁定(两次判空、中间加锁)在 C++11 之前依赖编译器和内存模型的细节,普通写法存在指令重排导致其他线程看到「指针已赋值但对象未构造完」的风险。现在应当直接用局部静态或std::call_once,不要自己拼这套。
二、延迟执行的三种语义
「延迟」在不同语境下指三件不同的事,混用会导致程序行为和预期不符。
第一种是惰性求值(lazy evaluation)。std::async(std::launch::deferred, f)返回一个 future,但f并不会立刻执行;直到某个线程对 future 调用get()或wait(),f才在该调用线程上同步执行,结果通过 future 返回。如果始终不调用get()/wait(),f永远不会运行。这一点常被误解为「后台任务」——它不是,它更像一个带缓存的函数调用。
第二种是延后调度。任务已经确定,但什么时候执行由调度器决定。可以用std::packaged_task把可调用对象和 future 绑定,先存进队列,稍后由工作线程或当前线程调用;也可以用std::function<void()>存入容器。std::packaged_task是只可移动的(move-only),调用一次后再调用会抛出std::future_error。
第三种是定时等待。线程先std::this_thread::sleep_until(deadline)或sleep_for(duration),醒来再执行。它占用一个线程但不占 CPU,适合粗粒度的定时任务;精度受调度器和系统时钟影响,标准不保证准点。
还要注意std::async不指定策略时的行为:默认参数是std::launch::async | std::launch::deferred,由实现选择。也就是说同一段代码在不同标准库上可能立即开线程,也可能延迟到get()才执行。需要确定性时必须显式写出策略。
// 适用于 C++17
#include <chrono>
#include <future>
#include <thread>
std::future<int> compute_lazily() {
return std::async(std::launch::deferred, [] {
std::this_thread::sleep_for(std::chrono::milliseconds(100));
return 42;
});
}
void use_deferred() {
std::future<int> f = compute_lazily(); // 此刻还没有开始计算
// ... 做别的事情
int v = f.get(); // 在这里、当前线程执行并取得结果
(void)v;
}与之相对,std::launch::async要求在新线程上执行。需要提醒的是,由std::async产生的 future,如果一直不调用get()或wait(),其析构函数会阻塞到任务完成(标准对async策略的 future 有此要求),这一点常被拿来实现「析构即等待」的同步点,但依赖它会让代码的阻塞位置变得不直观。
三、组合:线程安全的惰性初始化
把上面两块拼起来,一个典型需求是:某个昂贵对象在第一次被访问时构造,多个线程可能同时访问,必须只构造一次,且构造完成后所有线程都能安全读取。
std::call_once正好覆盖这个语义。它接收一个std::once_flag和一个可调用对象:第一个到达的线程执行初始化,其余线程阻塞等待;初始化正常返回后,所有线程的call_once调用构成同步关系,之后读取初始化结果是安全的。如果初始化抛出异常,标志不会被置位,下一个线程会再次尝试——这通常是对的,但要求初始化过程可以安全重试。
// 适用于 C++17
#include <chrono>
#include <iostream>
#include <memory>
#include <mutex>
#include <string>
#include <thread>
#include <vector>
class LazyConfig {
public:
const std::string& value() {
std::call_once(once_, [this] {
// 模拟耗时初始化;异常时 once_ 不会置位,下一个线程会重试
std::this_thread::sleep_for(std::chrono::milliseconds(50));
auto p = std::make_shared<std::string>("host=127.0.0.1;port=8080");
data_ = std::move(p);
});
return *data_;
}
private:
std::once_flag once_;
std::shared_ptr<std::string> data_;
};
int main() {
LazyConfig cfg;
std::vector<std::thread> threads;
for (int i = 0; i < 4; ++i) {
threads.emplace_back([&cfg, i] {
const std::string& s = cfg.value();
std::cout << i << ": " << s << '\n';
});
}
for (auto& t : threads) t.join();
return 0;
}这段代码的同步点全在call_once上:初始化线程写入data_,等待线程从call_once返回后读取data_,两者之间有 happens-before 关系。value()返回的是const std::string&,由于data_初始化后不再被修改,多个线程同时读是安全的;如果后续有人给data_重新赋值,引用和并发读就都失效了,必须加锁或改用返回值拷贝。
如果需求不是「只初始化一次」而是「每个线程各自一份」,应当用thread_local,它不需要任何锁,但要注意每个线程的副本独立、生命周期与线程绑定。
常见坑点
- ❌ 用
std::async(f)不写启动策略,并假设它一定异步执行。
✅ 写std::async(std::launch::async, f)明确要求新线程,或用deferred明确要求惰性求值。
- ❌ 创建了
deferred的 future 就以为任务在后台跑,从不调用get()。
✅ 惰性任务只有get()/wait()才执行;不需要结果时不要用 deferred 来「启动」任务。
- ❌ 用
volatile bool stop在线程间传递停止信号。
✅ 用std::atomic<bool>,或条件变量加互斥量;volatile不提供同步语义。
- ❌ 把耗时 I/O、日志写盘放在互斥量的临界区里。
✅ 临界区只包住共享数据的读写,耗时操作移到锁外;必要时先拷贝出数据再处理。
- ❌ 手写双重检查锁定,用普通指针做「已初始化」标志。
✅ 用局部静态变量或std::call_once,让标准库处理同步。
- ❌ 在
std::call_once的初始化里留下半成品状态就抛异常,并假设不会重试。
✅ 初始化要么完整成功,要么把状态恢复到可重试;once_flag在异常时不会置位。
- ❌
detach一个定时线程,让它在main返回后继续访问全局对象。
✅ 让主线程join,或用条件变量等待;进程退出时静态对象销毁,后台线程访问它们是 UB。
- ❌ 认为「加了锁就线程安全」,却通过引用把内部数据泄漏给外部无锁访问。
✅ 线程安全要覆盖所有访问路径;返回引用/指针时明确所有权和同步责任。
总结
| 需求 | 推荐手段 | 关键约束 |
|---|
| 只初始化一次 | 局部static/std::call_once | 初始化异常时once_flag不置位,需可重试 |
| 保护共享数据 | std::mutex+lock_guard | 临界区尽量短,不要跨 I/O |
| 单个标志/计数 | std::atomic<T> | 只保护该对象本身的操作 |
| 每线程独立状态 | thread_local | 副本独立,析构随线程 |
| 惰性求值 | std::async(std::launch::deferred, ...) | 不get()就不执行 |
| 延后调度 | std::packaged_task/ 任务队列 | 只能调用一次,move-only |
| 定时执行 | sleep_until+ 工作线程 | 精度不保证,注意退出时的生命周期 |
线程安全的核心是找出所有共享访问路径并建立明确的同步关系,而不是无脑加锁;延迟执行的核心是分清「没开始」「在别的线程跑」「到点才跑」这三种状态。把call_once与 future 的语义用对,大多数惰性初始化场景都不需要自己发明同步协议。