本節書摘來自華章出版社《Effective Debugging:軟件和系統調試的66個有效方法》一書中的第1章,第1.5節,作[希]迪歐米迪斯·斯賓奈里斯(Diomidis Spinellis),更多章節內容可以訪問云棲社區“華章計算機”公眾號查看
第5條:在能夠正常運作的系統與發生故障的系統之間尋找差別
我們通常都能夠同時訪問這樣兩個系統,其中一個是發生故障的系統,另一個是與之相似但卻可以正常運行的系統。當我們實現了某項新功能、更新了某些工具或基礎組件,或是把系統部署在某個新的平臺上面時,就可能會遇到新系統無法正常運行的問題,此時如果舊系統依然正常,那么我們通常可以通過尋找(下面就會講到如何尋找)或盡量縮小(參見第45條)新舊兩個系統之間的差別來鎖定問題的原因。
之所以能根據新舊系統間的差距來進行調試,其原因在于:盡管各人所經歷的問題有所不同,但計算機的底層運作方式卻是十分確定的,也就是說,同樣的輸入會產生同樣的輸出。因此,只要能夠深入故障系統中,并對其進行足夠的探查,我們就遲早能夠找到相關的bug,從而揭示出該系統為什么會在行為上與正常系統有所不同。
其實有很多時候,系統的故障原因都會非常明確地出現在你面前,只要你肯打開程序的日志文件(參見第56條),就有可能發現里面有一條消息告訴你,clients.conf這個配置文件有錯誤:
在另外一些情況下,錯誤的原因可能會隱藏得比較深,此時你必須提升系統日志的詳細程度(verbosity),才能把它暴露出來。
如果系統沒有提供足夠詳細的日志機制,那我們就需要用追蹤工具來梳理其運行時的行為。除了DTrace和SystemTap等通用的工具,還有一些專門的工具可以用來追蹤對操作系統的調用(strace、truss、Procmon)、對動態鏈接庫的調用(ltrace、Procmon)、網絡包(tcpdump、Wireshark)以及SQL數據庫調用(參見第58條)。有很多Unix應用程序(如R Project)是借助復雜的shell腳本來啟動的,因此可能會以極其隱晦的方式出錯。針對這樣的錯誤,在大多數情況下,我們都可以通過給相應shell傳入-x選項的辦法來進行追蹤,這樣得到的數據通常很龐大,所幸現在的系統都有很大的容量能夠存放這兩份日志(以其中一份表示那個可以正常運作的系統,另一份表示出現了故障的系統),而且都有很強的CPU能夠對其進行處理與比較。
就系統的操作環境而言,我們應該盡量確保這兩個系統擁有相似的環境,因為這樣能夠更加方便地對比日志文件或追蹤信息,有時甚至可以直接找到造成bug的原因。我們可以先從一些較為明顯的部分入手,例如,程序的輸入以及命令行參數等。與早前所說的原則一樣,我們也要親自進行驗證,而不能想當然地接受假設。例如,應該在兩個系統的輸入文件之間進行對比,如果它們都比較龐大并且離得比較遠,那可以考慮對比它們的MD5校驗和。
然后,我們應該把重點放在代碼上。首先對源代碼進行對比,我們可能要挖得深一些才能找到bug所在的地方。可以通過ldd命令(適用于Unix系統)或是帶有/dependents選項的dumpbin命令(適用于Visual Studio)來查看與每個可執行文件有關的動態程序庫,并通過nm命令(適用于Unix系統)、帶有/exports/imports選項的dumpbin命令(適用于Visual Studio)或javap命令(適用于以Java語言開發出來的程序)來查看程序所定義和使用的符號。如果你確信問題肯定出現在代碼中,但又看不出明顯的差別,那么可能就要往更深的層次去探查了,也就是需要對比由編譯器所生成的匯編代碼(參見第37條)。
然而在進行更深層次的探查之前,應該先考慮一下有沒有其他因素會影響程序的執行情況,環境變量就是這樣一個容易忽視的因素,即便是沒有特權的用戶,也依然可以通過設置環境變量來破壞程序的正常執行。另一個因素是操作系統。與運行著正常程序的那個操作系統相比,故障程序所在的這個操作系統,可能新了10年或是舊了10年。此外,也要考慮編譯器、開發框架、第三方鏈接庫、瀏覽器、應用程序服務器、數據庫系統以及其他一些中間件。至于怎樣在這么多的因素中確定問題的根源,則是我們接下來要講的話題。
大多數情況下,我們都是在一堆干草里面找一根針(大海撈針),因此應該盡量使這堆干草變得小一些,于是,就要花時間來構造一個既能體現bug,又最為簡單的測試用例(參見第10條)。(另外一種辦法是把要找的針變大一些,也就是命令這個有bug的程序輸出更多的信息,然而這種做法很少能起到比較好的效果。)簡明的測試用例可以縮短日志文件與追蹤信息的長度并減少處理時間,從而令調試工作變得更加輕松。要想有條理地簡化測試用例,我們可以在確保能夠重現bug的前提下,逐漸刪除用例中的元素或系統中的配置選項,直到刪至最簡。
如果正常系統和故障系統的區別位于源代碼中,那么有一種很實用的辦法,就是對這兩個版本之間的歷次修改進行二分搜索(binary search),以確定問題所在。例如,如果正常系統的版本號是100,而故障系統的版本號是132,那我們首先測試116版的程序是否正常,如果116版正常,那就判斷它與132版之間的中點,也就是124版是否正常,如果116版有錯,則判斷它與100版之間的中點,也就是108版是否正常,并依此類推。每次修改完程序之后,我們都應該把代碼單獨提交到版本控制系統里面,這樣做的好處之一,就是使得我們能夠進行二分搜索。某些版本控制系統提供了可以自動執行搜索的命令,例如,Git就提供了git bisect命令(參見第26條)。
還有一個很有效的辦法,是用Unix工具對比兩份日志文件(參見第56條),以找出其中與bug有關的區別。我們在這種情況下所使用的工具,是diff命令,它可以顯示出兩份文件的不同之處。然而日志文件經常會在無關緊要的地方表現出差別,這會把那些與bug真正有關的差別給掩蓋掉,于是,我們可以考慮用各種辦法來過濾干擾因素。例如,如果每一行開頭的幾個字段,都是時間戳與進程ID等信息,那我們就可以用cut或awk命令來把這些大同小異的信息裁掉。下面這條命令可以對Unix系統的messages日志文件進行裁切,它會從每一行的第4個字段開始顯示其內容:
只把你感興趣的那些事件選出來就可以了,例如,如果你只對打開的文件感興趣,那么可以用grep'open('這樣的命令來進行篩選。你也可以用grep-v gettimeofday等命令來把對自己有干擾的文本行過濾掉(例如,在Java程序里面,會有成千上萬次與獲取系統時間有關的調用)。此外,還可以在sed命令中指定適當的正則表達式,以便把文本行中自己不感興趣的那一部分裁掉。
最后再講一個高級的實用技巧:如果兩份文件各自的排序方式無法使diff命令給出有效的對比結果,那我們可以把感興趣的字段提取出來,對其進行排序,然后用comm工具在排好順序的兩個集合中找尋不同的元素。例如,如果我們想對比t1和t2這兩份追蹤信息,以找出有哪些文件只出現于t1中,那么可以在Unix的Bash shell中輸入下列命令,它會在包含字符串open(的那些文本行里面提取表示文件名的第二個字段,并在提取出來的這兩個集合之間尋找差別:
兩對小括號里面的那兩個元素,會分別生成兩份有序列表,列表中的每一項都是一個傳給open的文件名,而comm命令(這個命令用來在兩份列表之間尋找共同的元素)則以這兩份列表為輸入值,并把只出現在第一份列表中的內容列出來。
要點
在能夠正常運作的系統與出現故障的系統之間對比,找出行為上的區別,以求發現故障的原因。
影響系統行為的所有因素都要考慮到,包括代碼、輸入、調用時的參數、環境變量、服務以及動態鏈接庫。