当然,多线程调试的前提是你需要熟悉多线程的基础知识,包括线程的创建和退出、线程之间的各种同步原语等。如果您还不熟悉多线程编程的内容,可以参考这个专栏《C++ 多线程编程专栏》,如果您不熟悉 gdb 调试可以参考这个专栏《Linux GDB 调试教程》。 一、调试多线程的方法 使用 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) 这样的调用。 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 函数在何处调用的了。
至此我们发现 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 //代码行510 //代码行611 //代码行712 //代码行813 //代码行914 //代码行1015 //代码行1116 //代码行1217 //代码行1318 //代码行1419 //代码行1520 } 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,使用方法如下:
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 }15151616 return NULL;1717 }18181919 void* worker_thread_2(void* p)2020 {2121 while (true)2222 {2323 g = -100;2424 printf('worker_thread_2\n');2525 usleep(500000);2626 }27272828 return NULL;2929 }30303131 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); 37373838 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);48484949 usleep(1000000);5050 }51515252 return 0;5353 } 上述代码在主线程(main 函数所在的线程)中创建了了两个工作线程,主线程接下来的逻辑是在一个循环里面依次将全局变量 g 修改成 -1、-2、-3、-4,然后休眠 1 秒;工作线程 worker_thread_1、worker_thread_2 在分别在自己的循环里面将全局变量 g 修改成 100 和 -100。 我们编译程序后将程序使用 gdb 跑起来,三个线程同时运行,交错输出:
我们按 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 110[Switching to thread 1 (Thread 0x7ffff7feb740 (LWP 1191))]11#0 0x00007ffff701bfad in nanosleep () from /usr/lib64/libc.so.612(gdb) 然后在代码 11 行和 41 行各加一个断点。我们反复执行 until 48 命令,发现工作线程 1 和 2 还是有机会被执行的。
现在我们再次将线程切换到主线程(如果 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=-310g=-411main () at main.cpp:491249 usleep(1000000);13(gdb) until 481415Thread 1 'main' hit Breakpoint 1, main () at main.cpp:411641 printf('g=%d\n', g);17(gdb) 18g=-119g=-220g=-321g=-422main () at main.cpp:492349 usleep(1000000);24(gdb) until 482526Thread 1 'main' hit Breakpoint 1, main () at main.cpp:412741 printf('g=%d\n', g);28(gdb) 29g=-130g=-231g=-332g=-433main () at main.cpp:493449 usleep(1000000);35(gdb) until 483637Thread 1 'main' hit Breakpoint 1, main () at main.cpp:413841 printf('g=%d\n', g);39(gdb) 我们再次使用 until 命令时,gdb 锁定了主线程,其他两个工作线程再也不会被执行了,因此两个工作线程无任何输出。 我们再使用 set scheduler-locking step 模式再来锁定一下主线程,然后再次反复执行 until 48 命令。
可以看到使用 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 step10(gdb) next11Single stepping until exit from function nanosleep,12which has no line number information.130x00007ffff704c884 in usleep () from /usr/lib64/libc.so.614(gdb) next15Single stepping until exit from function usleep,16which has no line number information.17main () at main.cpp:401840 g = -1;19(gdb) next2021Thread 1 'main' hit Breakpoint 1, main () at main.cpp:412241 printf('g=%d\n', g);23(gdb) next24g=-12542 g = -2;26(gdb) next2743 printf('g=%d\n', g);28(gdb) next29g=-23044 g = -3;31(gdb) next3245 printf('g=%d\n', g);33(gdb) next34g=-33546 g = -4;36(gdb) next3747 printf('g=%d\n', g);38(gdb) next39g=-44049 usleep(1000000);41(gdb) next4240 g = -1;43(gdb) next4445Thread 1 'main' hit Breakpoint 1, main () at main.cpp:414641 printf('g=%d\n', g);47(gdb) next48g=-14942 g = -2;50(gdb) next5143 printf('g=%d\n', g);52(gdb) next53g=-25444 g = -3;55(gdb) next5645 printf('g=%d\n', g);57(gdb) next58g=-35946 g = -4;60(gdb) next6147 printf('g=%d\n', g);62(gdb) next63g=-46449 usleep(1000000);65(gdb) next6640 g = -1;67(gdb) next6869Thread 1 'main' hit Breakpoint 1, main () at main.cpp:417041 printf('g=%d\n', g);71(gdb) 此时我们发现设置了以 step 模式锁定主线程,工作线程不会在单步调试主线程时被执行,即使在工作线程设置了断点。 最后我们使用 set scheduler-locking off 取消对主线程的锁定,然后继续使用 next 命令单步调试。
取消了锁定之后,单步调试时三个线程都有机会被执行,线程 1 的断点也会被正常触发。 至此,我们搞清楚了如何利用 set scheduler-locking 选项来方便我们调试多线程程序。 总而言之,熟练掌握 gdb 调试等于拥有了学习优秀 C/C++ 开源项目源码的钥匙,只要可以利用 gdb 调试,再复杂的项目,在不断调试和分析过程中总会有搞明白的一天。 |
|
来自: 启云_9137 > 《计算机及软件应用》