2.6 使用gdb调试多线程程序

前面实际上已经介绍了调试多线程程序的方法,本节进行总结。

当然,调试多线程程序的前提是我们熟悉多线程的基础知识,包括线程的创建和退出、线程之间的各种同步原语等。

2.6.1 调试多线程程序的方法

使用gdb将程序跑起来,然后按Ctrl+C组合键将程序中断,使用info threads命令查看当前进程有多少线程:

还是以redis-server为例,使用gdb将程序运行起来后,我们按Ctrl+C组合键将程序中断,此时可以使用info threads命令查看redis-server有多少线程,每个线程正在执行哪里的代码。

使用 thread 线程编号可以切换到对应的线程,然后使用 bt命令查看对应的线程从顶层到底层的函数调用,以及上层调用下层对应源码的位置。当然,也可以使用frame栈函数编号(栈函数编号即下图中的#0~#4,使用frame命令时不需要加“#”)切换到当前函数调用堆栈的任何一层函数调用中,然后分析该函数的执行逻辑,使用 print等命令输出各种变量和表达式的值,或者进行单步调试:

如上所示,我们切换到了redis-server的1号线程,然后输入bt命令查看该线程的调用堆栈,发现顶层是main函数,说明这是主线程程序,同时得到从main开始往下各个函数调用对应的源码位置,我们可以通过这些源码的位置学习和研究调用处的逻辑。对每个线程都进行这样的分析之后,我们基本上就可以搞清楚整个程序运行中的执行逻辑了。

接着我们分别通过得到的各个线程的线程函数名去源码中搜索,找到创建这些线程的函数(下文为了叙述方便,以 f 代称这个函数),再接着通过搜索 f 或者给 f 加断点重启程序,看函数f是如何被调用的,这些操作一般在程序初始化阶段进行。

redis-server 1号线程是在main函数中创建的,我们再看下2号线程的创建,使用thread 2 切换到 2号线程,然后使用 bt命令查看 2 号线程的调用堆栈,得到 2 号线程的线程函数为 bioProcessBackgroundJobs。注意,顶层的 clone和 start_thread 是系统函数,我们找的线程函数应该是项目中的自定义线程函数。

通过在项目中搜索bioProcessBackgroundJobs函数,我们发现bioProcessBackgroundJobs函数在 bioInit 中被调用,而且确实是在 bioInit 函数中创建了线程 2,因此我们看到了pthread_create(&thread,&attr,bioProcessBackgroundJobs,arg)!=0)这样的调用:

此时,我们可以继续在项目中查找 bioInit函数,看看它在哪里被调用,或者直接给bioInit 函数加上断点,然后重启redis-server,等到断点被触发再使用 bt 命令查看此时的调用堆栈,就知道bioInit函数在何处被调用了:

至此,我们发现 2 号线程在 main 函数中调用了 InitServerLast 函数,后者又调用了bioInit函数,然后在bioInit函数中创建了新的线程bioProcessBackgroundJobs,我们只要分析这个执行流,就能搞清楚其逻辑了。

同样,redis-server 还有 3 号线程和 4 号线程,我们也可以按分析 2 号线程的方式分析3号线程和4号线程。

以上是笔者阅读不熟悉的C/C++项目时的常用方法,当然,对于一些特殊的项目源码,我们还需要了解该项目的业务内容,因为只有结合业务,才能看懂各个线程调用栈及初始化各个线程函数中的业务逻辑。

2.6.2 在调试时控制线程切换

在调试多线程程序时,我们可能希望执行流一直在某个线程中执行,而不是切换到其他线程,有办法做到这样吗?

为了说明清楚这个问题,我们假设现在调试的程序有5个线程,除了主线程,其他4个工作线程的线程函数都如下所示:

为了方便表述,我们把4个工作线程分别叫作线程A、线程B、线程C、线程D。

如上图所示,假设某个时刻,线程 A停在第 3行代码处,线程 B、C、D停在代码第1~15行的任一位置,此时线程A是gdb当前的调试线程,此时我们输入next命令,期望调试器跳转到代码第4行;或者输入 util 10命令,期望调试器跳转到代码第10行。但是在实际情况下,如果在代码第1、2、13或14行设置了断点,则gdb再次停下来时,可能会停在代码第1、2、13、14行。

这是多线程程序的特点:当我们从代码第 4 行让程序继续运行时,线程 A 虽然会继续往下执行,下一次应该在代码第 14 行停下来,但是线程 B、C、D 也在同步运行,如果此时系统的线程调度将CPU时间片切换到线程B、C或者D,那么gdb最终停下来时,可能是线程B、C、D触发了代码第1、2、13、14行的断点,此时调试的线程会变为B、C或者D,打印相关的变量值时可能就不是我们期望的线程A函数中的相关变量值了。

还存在一种情况,单步调试线程 A时,我们不希望线程 A函数中的值被其他线程改变。针对调试多线程程序存在的这些情况,gdb提供了一个将程序执行流锁定在当前调试线程中的命令选项——scheduler-locking,这个选项有三个值,分别是on、step和off,使用方法如下:

set scheduler-locking on可以用来锁定当前线程,只观察这个线程的运行情况,锁定这个线程时,其他线程处于暂停状态,也就是说,在当前线程执行next、step、until、finish、return命令时,其他线程是不会运行的。

需要注意的是,在使用set scheduler-locking on/step选项时要确认当前线程是否是我们期望锁定的线程,如果不是,则可以使用thread+线程编号切换到我们需要的线程,再调用set scheduler-locking on/step锁定。

set scheduler-locking step也用来锁定当前线程,当且仅当使用next或step命令做单步调试时会锁定当前线程,如果使用 until、finish、return等线程内的调试命令(它们不是单步控制命令),则其他线程还是有机会运行的。与on选项的值相比,step选项的值为单步调试提供了更加精细化的控制,因为在某些场景下,我们希望单步调试时其他线程不要对所属的当前线程的变量值造成影响。

set scheduler-locking off用于释放锁定当前线程。

下面以一个小示例说明这三个选项的用法。编写如下代码:

以上代码在主线程(main 函数所在的线程)中创建了两个工作线程,主线程接下来的逻辑是在一个循环里面依次将全局变量 g修改成-1、-2、-3、-4,然后休眠 1秒;工作线程 worker_thread_1、worker_thread_2 分别在自己的循环里将全局变量 g修改成 100和-100。

我们编译程序后将程序使用gdb跑起来,三个线程同时运行,交错输出:

我们按 Ctrl+C 组合键将程序中断,如果当前线程不在主线程中,则可以先使用 info threads和thread id切换到主线程:

然后在代码第11行和第41行各加一个断点。反复执行until 48命令,发现工作线程1和2还是有机会被执行的:

现在再次将线程切换到主线程(如果在gdb中断后,当前线程不是主线程),执行set scheduler-locking on命令,然后继续反复执行until 48命令:

再次使用 until命令时,gdb锁定了主线程,其他两个工作线程再也不会被执行了,因此两个工作线程无任何输出。

我们再使用set scheduler-locking step模式锁定主线程,然后反复执行until 48命令:

可以看到,使用step模式锁定主线程后,使用until命令时,另外两个工作线程仍然有执行的机会。我们再次切换到主线程,使用next命令单步调试下试试:

此时发现设置了以step模式锁定主线程,工作线程不会在单步调试主线程时被执行,即使在工作线程中设置了断点。

最后,我们使用 set scheduler-locking off 取消对主线程的锁定,然后继续使用 next命令单步调试:

取消锁定之后,单步调试时三个线程都有机会被执行,线程1的断点也被正常触发。