使用std::mutex保护多线程cout时出现死锁的原因排查
咱们来拆解这个死锁问题——大概率是不可重入的std::mutex撞上了std::async的延迟执行策略,或者是std::cout内部的线程安全机制和你自定义的锁搞出了锁顺序冲突,具体来看:
1. 最可能的元凶:std::async延迟执行导致主线程重入锁
当你用std::async却不指定启动策略时,默认是std::launch::async | std::launch::deferred——系统可以选择在后台线程跑任务,也可以延迟到你调用future.get()时,在主线程里执行任务。
假设有个任务被标记为延迟执行,当主线程调用fut.get()时,这个任务就跑到主线程的上下文里了:
- 任务里的
print函数会锁住cout_mutex,输出"Started thread ...",正常情况下lock_guard离开作用域就会解锁——这步没问题。 - 但如果你的任务里有其他操作(或者不小心在任务里又调用了
print),或者更隐蔽的情况:std::cout的输出操作触发了内部逻辑,间接让主线程又去抢同一个cout_mutex——而std::mutex是不可重入的!同一个线程不能多次获取同一个mutex,一旦尝试就会直接死锁(因为mutex不记录持有者,只要被锁就会阻塞)。
举个直观的例子:任务里的print刚输出完,lock_guard还没来得及析构(比如std::endl的刷新操作还在执行),主线程立刻调用自己的print去输出"Done thread ...",这时候主线程要抢已经被自己持有的锁,直接卡死——自己等自己解锁,永远等不到,其他后台线程也全堵在锁上。
2. 另一种可能:cout内部锁与自定义锁的顺序死锁
C++11之后std::cout本身是线程安全的——单个operator<<调用是原子的,但多个<<拼接的操作(比如cout << "Started " << id << endl;)还是会交错,这也是你加自定义锁的原因。但如果代码里出现锁顺序颠倒:
- 线程A:先获取自定义的
cout_mutex,然后调用cout,此时cout内部会获取它自己的内部锁。 - 线程B:(比如你不小心在某个地方直接调用了
cout,没通过print函数)先获取cout的内部锁,然后又去调用print抢cout_mutex。
这就形成了经典的循环等待死锁:A拿着cout_mutex等cout内部锁,B拿着cout内部锁等cout_mutex,谁都动不了,最后所有线程都堵在锁上。
为啥lock_guard没按预期解锁?
你说的没错,lock_guard是RAII工具,正常情况下不管是正常返回还是抛出异常,离开作用域都会自动解锁。但死锁发生时,持有锁的线程根本没机会走到lock_guard的析构步骤:
- 如果是主线程重入锁的情况:主线程第二次抢锁时直接阻塞,永远到不了析构
lock_guard的环节,锁就被永久占用。 - 如果是锁顺序冲突的情况:两个线程都卡在各自的锁等待上,都没法继续执行到析构代码,锁就一直被持有。
解决办法
- 强制std::async使用异步策略:把
std::async的调用改成std::async(std::launch::async, ...),确保所有任务都在后台线程执行,避免主线程重入锁的问题。 - 使用可重入的mutex:如果确实需要允许同一个线程多次获取锁,可以换成
std::recursive_mutex,但这只是权宜之计,最好先排查为什么会出现重入场景。 - 所有cout操作都通过print函数:杜绝直接调用
cout的情况,从根源避免锁顺序颠倒的可能。 - 检查cout状态:如果输出流出现错误(比如重定向到无效设备),可能导致cout操作阻塞,不过这种情况比较少见。
内容的提问来源于stack exchange,提问作者frankk

