2026/10/20 12:30:45

C++中实现线程安全和延迟执行详解

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