4. 多线程调试
多线程调试,说实话,是很多Qt开发者的一道坎。我自己刚入行那会儿,一遇到死锁或者数据错乱,第一反应就是加日志、加sleep,结果越调越乱。后来老老实实把GDB的多线程命令啃下来,才发现——原来很多问题,几行命令就能定位。
这一章,我们就来聊聊Qt的线程模型,以及怎么用GDB把多线程问题揪出来。
4.1 Qt的线程模型:你心里要有张图
Qt的线程,说白了就两种:QThread 和 QObject的线程亲和性。很多人以为new一个QThread就是开线程了,其实不对。QThread本身只是一个管理线程的接口,真正的执行体是run()函数,或者通过信号槽跨线程调用。
我个人习惯把Qt的线程模型画成下面这张图,每次调试前先看一眼,思路就清晰了:
这张图里,主线程跑事件循环,工作线程跑耗时任务。两个线程如果同时访问同一个变量,不加锁的话——嗯,数据错乱是迟早的事。
4.2 GDB多线程基础命令
进入GDB后,默认是停在主线程的。你需要先知道当前有多少线程在跑。
4.2.1 查看线程列表:info threads
(gdb) info threads
Id Target Id Frame
* 1 Thread 0x7ffff7fc1740 (LWP 12345) "myapp" 0x00007ffff7a0b4c7 in QApplication::exec()
2 Thread 0x7ffff6ffb700 (LWP 12346) "worker1" 0x00007ffff7b2e1a3 in QThread::sleep()
3 Thread 0x7ffff67fa700 (LWP 12347) "worker2" 0x00007ffff7a3f2d1 in QMutex::lock()
看到没?线程3卡在QMutex::lock()里了。这通常意味着它在等一把被别人拿着的锁。我遇到这种情况,第一反应就是——谁拿着这把锁不放?
thread apply all bt 可以一次性打印所有线程的调用栈。省得一个个切。
4.2.2 切换线程:thread N
(gdb) thread 2
[Switching to thread 2 (Thread 0x7ffff6ffb700 (LWP 12346))]
#0 0x00007ffff7b2e1a3 in QThread::sleep() from /usr/lib/libQt5Core.so.5
(gdb) bt
#0 0x00007ffff7b2e1a3 in QThread::sleep()
#1 0x00005555555551a0 in Worker::run() at worker.cpp:25
切到线程2,一看,它在sleep。那锁肯定不是它拿着的。再切到线程3看看。
4.2.3 锁定分析:frame 和 mutex信息
GDB本身不直接告诉你“哪个线程拿着锁”,但你可以通过查看QMutex的内部状态来推断。
(gdb) thread 3
(gdb) frame 0
#0 0x00007ffff7a3f2d1 in QMutex::lock() at qmutex.cpp:210
(gdb) p *mutex
$1 = {d = 0x5555555592a0}
(gdb) p mutex->d->lock
$2 = {__data = {__lock = 1, __count = 0, __owner = 12346, ...}}
关键看__owner字段。这里显示12346,就是LWP ID。对应回info threads里的LWP 12346——就是线程2。好,锁在它手里,但它正在sleep。死锁了。
__owner 是pthreads内部实现细节,不同平台可能字段名不同。在Linux上用glibc,这个字段是可靠的。
4.3 实战:一个典型的死锁场景
假设你有这么一段代码:
// worker.cpp
void Worker::run()
{
QMutexLocker locker(&mutex);
qDebug() << "Worker running...";
QThread::sleep(10); // 模拟耗时操作
}
主线程里也有一处加锁:
// main.cpp
void MainWindow::onButtonClicked()
{
QMutexLocker locker(&mutex);
// 做一些事情
}
如果用户点了按钮,同时工作线程正在sleep——主线程就会卡在QMutex::lock()上。界面卡死,用户骂娘。
用GDB怎么查?
info threads看哪个线程卡在lock上。- 切到那个线程,看
__owner是谁。 - 切到持有锁的线程,看它在干什么。
我曾经在一个项目里遇到类似问题,查了半天发现是某个线程在加锁后调用了QDialog::exec()——这玩意儿会启动一个嵌套的事件循环,锁一直不释放。嗯,从那以后我定了个规矩:加锁的代码块里,绝对不要调用任何可能阻塞的事件循环函数。
4.4 线程切换与条件断点
有时候你只想在某个特定线程命中断点。GDB支持按线程设置断点:
(gdb) break worker.cpp:25 thread 2
(gdb) break worker.cpp:25 thread 3
这样线程2和线程3分别停在同一个位置,你可以分别查看它们的状态。我个人习惯在调试死锁时,给每个线程的加锁位置都设一个断点,然后单步跟踪锁的获取顺序。
1. 当前线程在干什么?
2. 其他线程在干什么?
3. 共享资源被谁占着?
4.5 常用GDB多线程命令速查
| 命令 | 作用 | 我的使用场景 |
|---|---|---|
info threads |
列出所有线程 | 一进GDB先敲这个,心里有数 |
thread N |
切换到线程N | 看到可疑线程,切过去看栈 |
thread apply all bt |
所有线程的调用栈 | 死锁时一键查看,效率最高 |
break file:line thread N |
只在线程N触发断点 | 避免被其他线程的断点干扰 |
p mutex->d->lock.__owner |
查看锁的持有者 | 定位死锁的“罪魁祸首” |
说实话,这些命令你练上两三次就熟了。关键是形成一套自己的调试流程——我每次都是:info threads → 看有没有线程卡在lock() → thread apply all bt → 分析谁在等谁。
多线程调试没那么玄乎。你想想看,GDB给了你上帝视角,能看到所有线程的状态。只要你会切线程、会看锁的持有者,大部分问题都能在十分钟内定位。
好了,这一章的内容就到这里。记住:调试多线程,先看锁,再看栈,最后看代码。