当然,多线程调试的前提是你需要熟悉多线程的基础知识,包括线程的创建和退出、线程之间的各种同步原语等。如果您还不熟悉多线程编程的内容,可以参考这个专栏《C 多线程编程专栏》,如果您不熟悉 gdb 调试可以参考这个专栏《Linux GDB 调试教程》。

一、调试多线程的方法

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

编写多线程程序(如何调试多线程程序)(1)

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

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

编写多线程程序(如何调试多线程程序)(2)

如上图所示,我们切换到了 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 是系统函数,我们找的线程函数应该是项目中的自定义线程函数。

编写多线程程序(如何调试多线程程序)(3)

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

1//bio.c 96行 2void bioInit(void) { 3 //...省略部分代码... 4 5 for (j = 0; j < BIO_NUM_OPS; j ) { 6 void *arg = (void*)(unsigned long) j; 7 //在这里创建了线程 bioProcessBackgroundJobs 8 if (pthread_create(&thread,&attr,bioProcessBackgroundJobs,arg) != 0) { 9 serverLog(LL_WARNING,"Fatal: Can't initialize Background Jobs."); 10 exit(1); 11 } 12 bio_threads[j] = thread; 13 } 14}

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

1(gdb) b bioInit 2Breakpoint 1 at 0x498e5e: file bio.c, line 103. 3(gdb) r 4The program being debugged has been started already. 5Start it from the beginning? (y or n) y 6Starting program: /root/redis-6.0.3/src/redis-server 7[Thread debugging using libthread_db enabled] 8//...省略部分无关输出... 9Breakpoint 1, bioInit () at bio.c:103 10103 for (j = 0; j < BIO_NUM_OPS; j ) { 11(gdb) bt 12#0 bioInit () at bio.c:103 13#1 0x0000000000431b5d in InitServerLast () at server.c:2953 14#2 0x000000000043724f in main (argc=1, argv=0x7fffffffe318) at server.c:5142 15(gdb)

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

同样的道理,redis-server 还有 3 号和 4 号线程,我们也可以按分析 2 号线程的方式去分析 3 号和 4号,读者可以按照这里介绍的方法。

以上就是我阅读一个不熟悉的 C/C 项目常用的方法,当然对于一些特殊的项目的源码,你还需要去了解一下该项目的的业务内容,否则除了技术逻辑以外,你可能需要一些业务知识才能看懂各个线程调用栈以及初始化各个线程函数过程中的业务逻辑。

二、调试时控制线程切换

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

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

1void* worker_thread_proc(void* arg) 2{ 3 while (true) 4 { 5 //代码行1 6 //代码行2 7 //代码行3 8 //代码行4 9 //代码行5 10 //代码行6 11 //代码行7 12 //代码行8 13 //代码行9 14 //代码行10 15 //代码行11 16 //代码行12 17 //代码行13 18 //代码行14 19 //代码行15 20 } 21}

为了方便表述,我们把四个工作线程分别叫做 ABCD

编写多线程程序(如何调试多线程程序)(4)

如上图所示,假设某个时刻, 线程 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 处停下来,但是线程 BCD 也在同步运行呀,如果此时系统的线程调度将 CPU 时间片切换到线程 BC 或者 D 呢?那么 gdb 最终停下来的时候,可能是线程 BCD 触发了 代码行 1代码行 2代码行 13代码行 14 处的断点,此时调试的线程会变为 BC 或者 D ,而此时打印相关的变量值,可能就不是我们期望的线程 A 函数中的相关变量值了。

还存在一个情况,我们单步调试线程 A 时,我们不希望线程 A 函数中的值被其他线程改变。

针对调试多线程存在的上述状况,gdb 提供了一个在调试时将程序执行流锁定在当前调试线程的命令选项—— scheduler-locking 选项,这个选项有三个值,分别是 on、step 和 off,使用方法如下:

1set 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用于关闭锁定当前线程。

我们以一个小的示例来说明这三个选项的使用吧。编写如下代码:

101 #include <stdio.h> 202 #include <pthread.h> 303 #include <unistd.h> 404 505 long g = 0; 606 707 void* worker_thread_1(void* p) 808 { 909 while (true) 1010 { 1111 g = 100; 1212 printf("worker_thread_1\n"); 1313 usleep(300000); 1414 } 1515 1616 return NULL; 1717 } 1818 1919 void* worker_thread_2(void* p) 2020 { 2121 while (true) 2222 { 2323 g = -100; 2424 printf("worker_thread_2\n"); 2525 usleep(500000); 2626 } 2727 2828 return NULL; 2929 } 3030 3131 int main() 3232 { 3333 pthread_t thread_id_1; 3434 pthread_create(&thread_id_1, NULL, worker_thread_1, NULL); 3535 pthread_t thread_id_2; 3636 pthread_create(&thread_id_2, NULL, worker_thread_2, NULL); 3737 3838 while (true) 3939 { 4040 g = -1; 4142 printf("g=%d\n", g); 4242 g = -2; 4343 printf("g=%d\n", g); 4444 g = -3; 4545 printf("g=%d\n", g); 4646 g = -4; 4747 printf("g=%d\n", g); 4848 4949 usleep(1000000); 5050 } 5151 5252 return 0; 5353 }

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

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

1[root@myaliyun xx]# g -g -o main main.cpp -lpthread 2[root@myaliyun xx]# gdb main 3...省略部分无关输出... 4Reading symbols from main... 5(gdb) r 6Starting program: /root/xx/main 7[Thread debugging using libthread_db enabled] 8...省略部分无关输出... 9[New Thread 0x7ffff6f56700 (LWP 402)] 10worker_thread_1 11[New Thread 0x7ffff6755700 (LWP 403)] 12g=-1 13g=-2 14g=-3 15g=-4 16worker_thread_2 17worker_thread_1 18worker_thread_2 19worker_thread_1 20worker_thread_1 21g=-1 22g=-2 23g=-3 24g=-4 25worker_thread_2 26worker_thread_1 27worker_thread_1 28worker_thread_2 29worker_thread_1 30g=-1 31g=-2 32g=-3 33g=-4 34worker_thread_2 35worker_thread_1 36worker_thread_1 37worker_thread_2

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

1^C 2Thread 1 "main" received signal SIGINT, Interrupt. 30x00007ffff701bfad in nanosleep () from /usr/lib64/libc.so.6 4(gdb) info threads 5 Id Target Id Frame 6* 1 Thread 0x7ffff7feb740 (LWP 1191) "main" 0x00007ffff701bfad in nanosleep () from /usr/lib64/libc.so.6 7 2 Thread 0x7ffff6f56700 (LWP 1195) "main" 0x00007ffff701bfad in nanosleep () from /usr/lib64/libc.so.6 8 3 Thread 0x7ffff6755700 (LWP 1196) "main" 0x00007ffff701bfad in nanosleep () from /usr/lib64/libc.so.6 9(gdb) thread 1 10[Switching to thread 1 (Thread 0x7ffff7feb740 (LWP 1191))] 11#0 0x00007ffff701bfad in nanosleep () from /usr/lib64/libc.so.6 12(gdb)

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

1(gdb) b main.cpp:41 2Breakpoint 1 at 0x401205: file main.cpp, line 41. 3(gdb) b main.cpp:11 4Breakpoint 2 at 0x40116e: file main.cpp, line 11. 5(gdb) until 48 60x00007ffff704c884 in usleep () from /usr/lib64/libc.so.6 7(gdb) 8worker_thread_2 9[Switching to Thread 0x7ffff6f56700 (LWP 1195)] 10 11Thread 2 "main" hit Breakpoint 2, worker_thread_1 (p=0x0) at main.cpp:11 1211 g = 100; 13(gdb) 14worker_thread_2 15[Switching to Thread 0x7ffff7feb740 (LWP 1191)] 16 17Thread 1 "main" hit Breakpoint 1, main () at main.cpp:41 1841 printf("g=%d\n", g); 19(gdb) 20worker_thread_1 21worker_thread_2 22g=-1 23g=-2 24g=-3 25g=-4 26main () at main.cpp:49 2749 usleep(1000000); 28(gdb) 29worker_thread_2 30[Switching to Thread 0x7ffff6f56700 (LWP 1195)] 31 32Thread 2 "main" hit Breakpoint 2, worker_thread_1 (p=0x0) at main.cpp:11 3311 g = 100; 34(gdb)

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

1(gdb) set scheduler-locking on 2(gdb) until 48 3 4Thread 1 "main" hit Breakpoint 1, main () at main.cpp:41 541 printf("g=%d\n", g); 6(gdb) until 48 7g=-1 8g=-2 9g=-3 10g=-4 11main () at main.cpp:49 1249 usleep(1000000); 13(gdb) until 48 14 15Thread 1 "main" hit Breakpoint 1, main () at main.cpp:41 1641 printf("g=%d\n", g); 17(gdb) 18g=-1 19g=-2 20g=-3 21g=-4 22main () at main.cpp:49 2349 usleep(1000000); 24(gdb) until 48 25 26Thread 1 "main" hit Breakpoint 1, main () at main.cpp:41 2741 printf("g=%d\n", g); 28(gdb) 29g=-1 30g=-2 31g=-3 32g=-4 33main () at main.cpp:49 3449 usleep(1000000); 35(gdb) until 48 36 37Thread 1 "main" hit Breakpoint 1, main () at main.cpp:41 3841 printf("g=%d\n", g); 39(gdb)

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

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

1(gdb) set scheduler-locking step 2(gdb) until 48 3worker_thread_2 4worker_thread_1 5g=-100 6g=-2 7g=-3 8g=-4 9main () at main.cpp:49 1049 usleep(1000000); 11(gdb) until 48 12worker_thread_2 13[Switching to Thread 0x7ffff6f56700 (LWP 1195)] 14 15Thread 2 "main" hit Breakpoint 2, worker_thread_1 (p=0x0) at main.cpp:11 1611 g = 100; 17(gdb) until 48 18worker_thread_2 19worker_thread_1 20 21Thread 2 "main" hit Breakpoint 2, worker_thread_1 (p=0x0) at main.cpp:11 2211 g = 100; 23(gdb) until 48 24worker_thread_2 25[Switching to Thread 0x7ffff7feb740 (LWP 1191)] 26 27Thread 1 "main" hit Breakpoint 1, main () at main.cpp:41 2841 printf("g=%d\n", g); 29(gdb) until 48 30worker_thread_1 31worker_thread_2 32g=-100 33g=-2 34g=-3 35g=-4 36main () at main.cpp:49 3749 usleep(1000000); 38(gdb) until 48 39worker_thread_2 40[Switching to Thread 0x7ffff6f56700 (LWP 1195)] 41 42Thread 2 "main" hit Breakpoint 2, worker_thread_1 (p=0x0) at main.cpp:11 4311 g = 100; 44(gdb) until 48 45worker_thread_2 46worker_thread_1 47 48Thread 2 "main" hit Breakpoint 2, worker_thread_1 (p=0x0) at main.cpp:11 4911 g = 100; 50(gdb)

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

1(gdb) info threads 2 Id Target Id Frame 3 1 Thread 0x7ffff7feb740 (LWP 1191) "main" 0x00007ffff701bfad in nanosleep () from /usr/lib64/libc.so.6 4* 2 Thread 0x7ffff6f56700 (LWP 1195) "main" worker_thread_1 (p=0x0) at main.cpp:11 5 3 Thread 0x7ffff6755700 (LWP 1196) "main" 0x00007ffff701bfad in nanosleep () from /usr/lib64/libc.so.6 6(gdb) thread 1 7[Switching to thread 1 (Thread 0x7ffff7feb740 (LWP 1191))] 8#0 0x00007ffff701bfad in nanosleep () from /usr/lib64/libc.so.6 9(gdb) set scheduler-locking step 10(gdb) next 11Single stepping until exit from function nanosleep, 12which has no line number information. 130x00007ffff704c884 in usleep () from /usr/lib64/libc.so.6 14(gdb) next 15Single stepping until exit from function usleep, 16which has no line number information. 17main () at main.cpp:40 1840 g = -1; 19(gdb) next 20 21Thread 1 "main" hit Breakpoint 1, main () at main.cpp:41 2241 printf("g=%d\n", g); 23(gdb) next 24g=-1 2542 g = -2; 26(gdb) next 2743 printf("g=%d\n", g); 28(gdb) next 29g=-2 3044 g = -3; 31(gdb) next 3245 printf("g=%d\n", g); 33(gdb) next 34g=-3 3546 g = -4; 36(gdb) next 3747 printf("g=%d\n", g); 38(gdb) next 39g=-4 4049 usleep(1000000); 41(gdb) next 4240 g = -1; 43(gdb) next 44 45Thread 1 "main" hit Breakpoint 1, main () at main.cpp:41 4641 printf("g=%d\n", g); 47(gdb) next 48g=-1 4942 g = -2; 50(gdb) next 5143 printf("g=%d\n", g); 52(gdb) next 53g=-2 5444 g = -3; 55(gdb) next 5645 printf("g=%d\n", g); 57(gdb) next 58g=-3 5946 g = -4; 60(gdb) next 6147 printf("g=%d\n", g); 62(gdb) next 63g=-4 6449 usleep(1000000); 65(gdb) next 6640 g = -1; 67(gdb) next 68 69Thread 1 "main" hit Breakpoint 1, main () at main.cpp:41 7041 printf("g=%d\n", g); 71(gdb)

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

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

1(gdb) set scheduler-locking off 2(gdb) next 3worker_thread_2 4worker_thread_1 5g=-100 642 g = -2; 7(gdb) next 8worker_thread_2 9[Switching to Thread 0x7ffff6f56700 (LWP 1195)] 10 11Thread 2 "main" hit Breakpoint 2, worker_thread_1 (p=0x0) at main.cpp:11 1211 g = 100; 13(gdb) next 14g=100 15g=-3 16g=-4 17worker_thread_2 1812 printf("worker_thread_1\n"); 19(gdb) next 20worker_thread_1 2113 usleep(300000); 22(gdb) next 23worker_thread_2 24[Switching to Thread 0x7ffff7feb740 (LWP 1191)] 25 26Thread 1 "main" hit Breakpoint 1, main () at main.cpp:41 2741 printf("g=%d\n", g); 28(gdb) next 29[Switching to Thread 0x7ffff6f56700 (LWP 1195)] 30 31Thread 2 "main" hit Breakpoint 2, worker_thread_1 (p=0x0) at main.cpp:11 3211 g = 100; 33(gdb) next 34g=-1 35g=-2 36g=-3 37g=-4 38worker_thread_2 3912 printf("worker_thread_1\n"); 40(gdb)

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

至此,我们搞清楚了如何利用 set scheduler-locking 选项来方便我们调试多线程程序。

总而言之,熟练掌握 gdb 调试等于拥有了学习优秀 C/C 开源项目源码的钥匙,只要可以利用 gdb 调试,再复杂的项目,在不断调试和分析过程中总会有搞明白的一天。

,