如何调试多线程程序
在上一篇文章《使用 gdb 调试多进程程序 —— 以调试 nginx 为例》我们介绍了如何使用 gdb 调试多进程程序,这篇文章我们来介绍下如何使用 gdb 调试多线程程序,同时这个方法也是我阅读和分析一个新的 C/C++ 项目常用的方法。
当然,多线程调试的前提是你需要熟悉多线程的基础知识,包括线程的创建和退出、线程之间的各种同步原语等。如果您还不熟悉多线程编程的内容,可以参考这个专栏《C++ 多线程编程专栏》,如果您不熟悉 gdb 调试可以参考这个专栏《Linux GDB 调试教程》。
一、调试多线程的方法
使用 gdb 将程序跑起来,然后按 Ctrl + C 将程序中断下来,使用 info threads
命令查看当前进程有多少线程。
使用 thread 线程编号
可以切换到对应的线程去,然后使用 bt
命令可以查看对应线程从顶到底层的函数调用,以及上层调用下层对应的源码中的位置;当然,你也可以使用 frame 栈函数编号
(栈函数编号即下图中的 #0 ~ #4,使用 frame 命令时不需要加 #)切换到当前函数调用堆栈的任何一层函数调用中去,然后分析该函数执行逻辑,使用 print
等命令输出各种变量和表达式值,或者进行单步调试。
接着我们分别通过得到的各个线程的线程函数名去源码中搜索,找到创建这些线程的函数(下文为了叙述方便,以 f 代称这个函数),再接着通过搜索 f 或者给 f 加断点重启程序看函数 f 是如何被调用的,这些操作一般在程序初始化阶段。
redis-server 1 号线线程是在 main 函数中创建的,我们再看下 2 号线程的创建,使用 thread 2
切换到 2号线程,然后使用 bt 命令查看 2 号线程的调用堆栈,得到 2 号线程的线程函数为 bioProcessBackgroundJobs
,注意在顶层的 clone
和 start_thread
是系统函数,我们找的线程函数应该是项目中的自定义线程函数。
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}
为了方便表述,我们把四个工作线程分别叫做 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,使用方法如下:
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 调试,再复杂的项目,在不断调试和分析过程中总会有搞明白的一天。
推荐阅读:
原创不易,点个在看呗~