如何調試多線程程序

當然,多線程調試的前提是你需要熟悉多線程的基礎知識,包括線程的創建和退出、線程之間的各種同步原語等。如果您還不熟悉多線程編程的內容,可以參考這個專欄《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>

為了方便表述,我們把四個工作線程分別叫做 AB

CD

如何調試多線程程序

如上圖所示,假設某個時刻, 線程 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 處停下來,但是線程 BCD 也在同步運行呀,如果此時系統的線程調度將 CPU 時間片切換到線程 B

C 或者 D 呢?那麼 gdb 最終停下來的時候,可能是線程 BCD 觸發了 代碼行 1代碼行 2代碼行 13代碼行 14 處的斷點,此時調試的線程會變為 BC 或者 D ,而此時打印相關的變量值,可能就不是我們期望的線程 A 函數中的相關變量值了。

還存在一個情況,我們單步調試線程 A 時,我們不希望線程 A 函數中的值被其他線程改變。

針對調試多線程存在的上述狀況,gdb 提供了一個在調試時將程序執行流鎖定在當前調試線程的命令選項——

scheduler-locking 選項,這個選項有三個值,分別是 on、step 和 off,使用方法如下:

<code>

1set

scheduler-locking

on

/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 調試,再複雜的項目,在不斷調試和分析過程中總會有搞明白的一天。


分享到:


相關文章: