當然,多線程調試的前提是你需要熟悉多線程的基礎知識,包括線程的創建和退出、線程之間的各種同步原語等。如果您還不熟悉多線程編程的內容,可以參考這個專欄《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 調試,再複雜的項目,在不斷調試和分析過程中總會有搞明白的一天。