当然,多线程调试的前提是你需要熟悉多线程的基础知识,包括线程的创建和退出、线程之间的各种同步原语等。如果您还不熟悉多线程编程的内容,可以参考这个专栏《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) 这样的调用。
<code>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
}
/<code>
此时,我们可以继续在项目中查找 bioInit 函数,看看它在哪里被调用的,或者直接给 bioInit 函数加上断点,然后重启 redis-server,等断点触发,使用 bt 命令查看此时的调用堆栈就知道 bioInit 函数在何处调用的了。
<code>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=0
x7fffffffe318)at
server
.c
:5142
15
(gdb) /<code>
至此我们发现 2 号线程是在 main 函数中调用了 InitServerLast 函数,后者又调用 bioInit 函数,然后在 bioInit 函数中创建了新的线程 bioProcessBackgroundJobs ,我们只要分析这个执行流就能搞清楚这个逻辑流程了。
同样的道理,redis-server 还有 3 号和 4 号线程,我们也可以按分析 2 号线程的方式去分析 3 号和 4号,读者可以按照这里介绍的方法。
以上就是我阅读一个不熟悉的 C/C++ 项目常用的方法,当然对于一些特殊的项目的源码,你还需要去了解一下该项目的的业务内容,否则除了技术逻辑以外,你可能需要一些业务知识才能看懂各个线程调用栈以及初始化各个线程函数过程中的业务逻辑。
二、调试时控制线程切换
在调试多线程程序时,有时候我们希望执行流一直在某个线程执行,而不是切换到其他线程,有办法做到这样吗?
为了说明清楚这个问题,我们假设现在调试的程序有 5 个线程,除了主线程,其他 4 个工作线程的线程函数都是下面这样一个函数:
<code>1
void
*worker_thread_proc
(
void
* arg) 2 {3
while
(true
)4
{5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
}21
} /<code>
为了方便表述,我们把四个工作线程分别叫做 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,使用方法如下:<code>1set
scheduler-lockingon
/step/off
/<code>
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用于关闭锁定当前线程。
我们以一个小的示例来说明这三个选项的使用吧。编写如下代码:
<code>101
202
303
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
}
/<code>
上述代码在主线程(main 函数所在的线程)中创建了了两个工作线程,主线程接下来的逻辑是在一个循环里面依次将全局变量 g 修改成 -1、-2、-3、-4,然后休眠 1 秒;工作线程 worker_thread_1、worker_thread_2 在分别在自己的循环里面将全局变量 g 修改成 100 和 -100。
我们编译程序后将程序使用 gdb 跑起来,三个线程同时运行,交错输出:
<code>xx]# g++ -g -o main main.cpp -lpthread
xx]# gdb main
3...省略部分无关输出...
4Reading
symbols from main...
r
6Starting
program: /root/xx/main
debugging using libthread_db enabled]
8...省略部分无关输出...
Thread 0x7ffff6f56700 (LWP 402)]
10worker_thread_1
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
/<code>
我们按 Ctrl + C 将程序中断下来,如果当前线程不在主线程,可以先使用 info threads 和 thread id 切换到主线程:
<code>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
12
(gdb)
/<code>
然后在代码 11 行和 41 行各加一个断点。我们反复执行 until 48 命令,发现工作线程 1 和 2 还是有机会被执行的。
<code>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)
/<code>
现在我们再次将线程切换到主线程(如果 gdb 中断后当前线程不是主线程的话),执行 set scheduler-locking on 命令,然后继续反复执行 until 48 命令。
<code>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)
/<code>
我们再次使用 until 命令时,gdb 锁定了主线程,其他两个工作线程再也不会被执行了,因此两个工作线程无任何输出。
我们再使用 set scheduler-locking step 模式再来锁定一下主线程,然后再次反复执行 until 48 命令。
<code>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)
/<code>
可以看到使用 step 模式锁定的主线程,在使用 until 命令时另外两个工作线程仍然有执行的机会。我们再次切换到主线程,然后使用 next 命令单步调试下试试。
<code>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
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)
/<code>
此时我们发现设置了以 step 模式锁定主线程,工作线程不会在单步调试主线程时被执行,即使在工作线程设置了断点。
最后我们使用 set scheduler-locking off 取消对主线程的锁定,然后继续使用 next 命令单步调试。
<code>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)
/<code>
取消了锁定之后,单步调试时三个线程都有机会被执行,线程 1 的断点也会被正常触发。
至此,我们搞清楚了如何利用 set scheduler-locking 选项来方便我们调试多线程程序。
总而言之,熟练掌握 gdb 调试等于拥有了学习优秀 C/C++ 开源项目源码的钥匙,只要可以利用 gdb 调试,再复杂的项目,在不断调试和分析过程中总会有搞明白的一天。