- C++服務器開發精髓
- 張遠龍
- 2596字
- 2021-07-23 18:22:23
2.6 使用gdb調試多線程程序
前面實際上已經介紹了調試多線程程序的方法,本節進行總結。
當然,調試多線程程序的前提是我們熟悉多線程的基礎知識,包括線程的創建和退出、線程之間的各種同步原語等。
2.6.1 調試多線程程序的方法
使用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)這樣的調用:

此時,我們可以繼續在項目中查找 bioInit函數,看看它在哪里被調用,或者直接給bioInit 函數加上斷點,然后重啟redis-server,等到斷點被觸發再使用 bt 命令查看此時的調用堆棧,就知道bioInit函數在何處被調用了:

至此,我們發現 2 號線程在 main 函數中調用了 InitServerLast 函數,后者又調用了bioInit函數,然后在bioInit函數中創建了新的線程bioProcessBackgroundJobs,我們只要分析這個執行流,就能搞清楚其邏輯了。
同樣,redis-server 還有 3 號線程和 4 號線程,我們也可以按分析 2 號線程的方式分析3號線程和4號線程。
以上是筆者閱讀不熟悉的C/C++項目時的常用方法,當然,對于一些特殊的項目源碼,我們還需要了解該項目的業務內容,因為只有結合業務,才能看懂各個線程調用棧及初始化各個線程函數中的業務邏輯。
2.6.2 在調試時控制線程切換
在調試多線程程序時,我們可能希望執行流一直在某個線程中執行,而不是切換到其他線程,有辦法做到這樣嗎?
為了說明清楚這個問題,我們假設現在調試的程序有5個線程,除了主線程,其他4個工作線程的線程函數都如下所示:

為了方便表述,我們把4個工作線程分別叫作線程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用于釋放鎖定當前線程。
下面以一個小示例說明這三個選項的用法。編寫如下代碼:


以上代碼在主線程(main 函數所在的線程)中創建了兩個工作線程,主線程接下來的邏輯是在一個循環里面依次將全局變量 g修改成-1、-2、-3、-4,然后休眠 1秒;工作線程 worker_thread_1、worker_thread_2 分別在自己的循環里將全局變量 g修改成 100和-100。
我們編譯程序后將程序使用gdb跑起來,三個線程同時運行,交錯輸出:


我們按 Ctrl+C 組合鍵將程序中斷,如果當前線程不在主線程中,則可以先使用 info threads和thread id切換到主線程:

然后在代碼第11行和第41行各加一個斷點。反復執行until 48命令,發現工作線程1和2還是有機會被執行的:


現在再次將線程切換到主線程(如果在gdb中斷后,當前線程不是主線程),執行set scheduler-locking on命令,然后繼續反復執行until 48命令:


再次使用 until命令時,gdb鎖定了主線程,其他兩個工作線程再也不會被執行了,因此兩個工作線程無任何輸出。
我們再使用set scheduler-locking step模式鎖定主線程,然后反復執行until 48命令:


可以看到,使用step模式鎖定主線程后,使用until命令時,另外兩個工作線程仍然有執行的機會。我們再次切換到主線程,使用next命令單步調試下試試:


此時發現設置了以step模式鎖定主線程,工作線程不會在單步調試主線程時被執行,即使在工作線程中設置了斷點。
最后,我們使用 set scheduler-locking off 取消對主線程的鎖定,然后繼續使用 next命令單步調試:


取消鎖定之后,單步調試時三個線程都有機會被執行,線程1的斷點也被正常觸發。
- The Complete Rust Programming Reference Guide
- Debian 7:System Administration Best Practices
- 數據庫系統原理及MySQL應用教程
- Vue.js 3.0源碼解析(微課視頻版)
- Java持續交付
- Eclipse Plug-in Development:Beginner's Guide(Second Edition)
- 組態軟件技術與應用
- Working with Odoo
- Python 3.7從入門到精通(視頻教學版)
- Qt5 C++ GUI Programming Cookbook
- Managing Microsoft Hybrid Clouds
- Python青少年趣味編程
- Python物理建模初學者指南(第2版)
- AngularJS UI Development
- 面向物聯網的Android應用開發與實踐