C++多线程安全编程
多线程访问共享数据时那些让人头疼的崩溃和死锁线上服务跑着跑着就core dumpdebug版本却怎么也复现不了。这个问题在C多线程编程里太常见了尤其是涉及全局变量或单例对象时。你盯着日志里最后一条正常记录再往下就是段错误连堆栈都来不及打出来。这种问题排查起来特别耗时间因为多线程的竞争条件往往只在特定时序下触发换个环境可能就消失了。先看一个最典型的场景多个线程同时对一个std::vector进行push_back操作。你可能会觉得vector是标准容器应该没问题。但事实上vector的push_back在内存不够时会重新分配内存把旧元素拷贝到新位置。这时候如果另一个线程正在访问旧内存地址轻则读到脏数据重则直接访问已释放的内存。这就是未定义行为表现形式多种多样core dump只是其中一种。解决这个问题的第一反应是加锁。用std::mutex把push_back和遍历操作都保护起来。但加锁也有坑比如锁的粒度太粗会导致性能急剧下降。我曾经遇到过把整个数据处理流程都锁住的代码十六核的机器跑起来跟单线程差不多。锁的粒度要尽量小只保护临界区不要在持有锁的时候做IO操作或复杂计算。另一个容易被忽略的是std::shared_ptr的线程安全性。shared_ptr的控制块本身是线程安全的引用计数的增减是原子操作。但shared_ptr指向的对象并不是线程安全的。两个线程同时通过shared_ptr修改对象的不同成员变量依然会产生数据竞争。很多人以为用了shared_ptr就万事大吉结果该崩溃还是崩溃。死锁的问题也很头疼。比如线程A持有锁1等待锁2线程B持有锁2等待锁1。这时候两个线程就互相卡死了。解决方法之一是使用std::lock一次性锁住多个互斥量避免分步加锁。另一个是定义明确的锁获取顺序所有线程都按照同样的顺序获取锁。比如先锁住数据库连接池的锁再锁住缓存操作的锁不能反过来。还有一种情况是条件变量配合互斥锁使用时容易出问题。典型的模式是std::unique_lockstd::mutex lock(mtx);cv.wait(lock, []{ return data_ready; });这个lambda谓词很重要不能省略。如果不提供谓词wait返回时可能只是被虚假唤醒此时data_ready可能还是false。虚假唤醒在pthreads和C标准库中都是允许的所以必须用循环或谓词检查条件。对于性能敏感的场景可以考虑读写锁std::shared_mutex。读操作多、写操作少的情况下读写锁能显著提升并发性能。但要注意写锁会阻塞所有读锁所以写操作不能太频繁。另外std::shared_mutex的实现开销比普通mutex大如果临界区执行时间极短可能还不如直接用普通mutex。原子操作是另一个方向。对于简单的整型变量比如计数器、标志位用std::atomicint代替mutex能减少锁竞争。但原子操作不是万能的对于复杂的数据结构比如链表、树原子操作很难保证一致性。而且内存序的选择也需要仔细考虑默认的memory_order_seq_cst保证最强的一致性但性能最差。如果场景允许可以用memory_order_relaxed或memory_order_acquire/release来提升性能。在实际项目中我见过最隐蔽的问题来自静态局部变量的初始化。C11保证了静态局部变量的初始化是线程安全的但老版本的编译器可能不支持这个特性。如果项目需要兼容GCC 4.8之前的版本就要自己加锁保护静态局部变量的初始化。否则两个线程同时第一次访问这个函数可能导致重复初始化或者未定义行为。调试多线程问题也有一些技巧。比如在怀疑数据竞争的地方可以用ThreadSanitizerTSan来检测。TSan是LLVM/Clang的一部分编译时加上-fsanitizethread就能启用。它能检测到大多数数据竞争包括原子操作使用不当的情况。缺点是会大幅降低运行速度而且不支持所有平台。但相比人工排查这点代价还是值得的。还有一个实用工具是std::async配合std::future。比起直接操作std::threadasync能自动管理线程的生命周期还能获取返回值。但要注意async的启动策略默认是std::launch::async|std::launch::deferred具体使用哪种策略由实现决定。如果希望立即执行应该显式指定std::launch::async。否则在某些实现中future析构时可能阻塞等待结果导致意想不到的同步问题。最后提一下线程池。自己实现线程池很容易出错尤其是任务队列的同步和线程的优雅退出。建议使用现成的库比如Intel TBB、Boost.Asio或者C20的std::jthread。C20的std::jthread在析构时会自动join而且支持中断比std::thread更安全。如果项目还在用C11/14Boost.Asio是个不错的选择它提供了跨平台的线程池实现而且社区活跃bug修复及时。多线程编程的坑远不止这些但掌握了数据竞争和死锁这两个核心问题大部分场景都能应对。写代码时多想想这个变量会被多个线程同时读写吗锁的顺序会不会导致死锁条件变量有没有被虚假唤醒多花几分钟思考可能就省下几天的debug时间。

相关新闻