當然,多線程調試的前提是你需要熟悉多線程的基礎知識,包括線程的創建和退出、線程之間的各種同步原語等。如果您還不熟悉多線程編程的内容,可以參考這個專欄《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 函數在何處調用的了。
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 調試,再複雜的項目,在不斷調試和分析過程中總會有搞明白的一天。
,更多精彩资讯请关注tft每日頭條,我们将持续为您更新最新资讯!