馬上就要離職了,想想工作中有些東西還是需要沉淀下來的,不僅僅要沉淀到心里,因為年紀大了_,很容易忘記,不是有句話么,好記性不如爛筆頭。
分析這個bug之前先說點別的。
解決bug的大致思路
我覺的解bug和醫生看病是一樣的,中醫看病講究望聞問切。軟件出了毛病也按這個套路來,但是不需要聞。
- 望。觀察表面現象,server端出了問題還是client端?現象是什么?log里記錄了什么?
- 問。詢問客戶最近做了什么操作?哪些是重現問題的必要步驟?
- 切。為軟件的code把把脈吧,由表及里,看一下軟件的哪些機能出現了問題,小問題還是大問題?表層應用代碼有問題?還是底層代碼庫有問題?只是邏輯的問題?還是性能問題?或者設計架構缺陷?
我們開發的產品運行在windows server 平台上,幾個月之前fix過一個線上發現的bug。對於有經驗的開發人員來說,需要解決的bug分為兩種:能穩定重現的和不能穩定重現的。只要能夠穩定重現,從客戶提供的種種數據中總能順藤摸瓜,找到問題根源。在我們的軟件中,這些數據包括以下幾種:
- Windows Event Log,這也分為兩個不同的級別(只說和我們相關的)
- windows logs,這是操作系統級別的log,若遇到組件崩潰,windows logs下面的Application會打印log記錄。
- Application and Services Logs,我們的產品自定義的log放在這個目錄下面,這些log這是最直接的信息,如插在軟件中的一個個索引,報錯之后能夠很快定位代碼位置,找到問題突破口。
- 網絡包(wireshark包),這些是實時動態的數據,可以作為review靜態代碼時的輔助,查看數據交互是否出現了問題。是不是漏發數據或者多發了等等。
- trace log,這是更加詳細的log,如果windows log是CT片,那么trace log就是核磁共振片了。這些log的打印會影響性能,因此是有開關的,平時處於關閉狀態,如果需要把開關打開后,log就會被打印出來。這些log首先被寫入windows Message Queue中,然后使用工具進行顯示或重定向到log文件。
- 數據庫備份。在實驗室里重現問題使用客戶的數據庫可能會更容易些。
- dump文件。抓取dump文件是一種在遇到代碼崩潰時行之有效的獲取相關信息的方法,它是出錯時內存的一份快照。我們會在下面介紹如何在windows環境下設置注冊表,在出現崩潰時自動抓取dump。
根據bug的難易程度,找問題出現的根源有三種手段,可以層層遞進或者同時進行:
- 開發人員review靜態代碼,這可能需要結合前面提到的兩種log或者網絡包來review。有可能問題的根源很簡單,一眼就能看出來。下面的步驟就省了。
- 如果review代碼發現不了問題或者問題不好重現,測試可以嘗試去重現問題,尋找規律,如果自己的制作的數據庫重現不了可以使用客戶數據庫。問題可以穩定重現后,開發人員可以在remote debug來進一步跟蹤代碼。但是release環境下的debug因為有編譯優化存在,有些內存數據更本顯示不出來或者是錯誤的,代碼執行順序有時候也很怪異。這點需要小心。
- 鑒於debug release環境的難處,開發人員可以自己搭建一個debug版的環境進行重現、調試。但是有一點,還是因為release和debug環境的差異,有時候只有在release環境下才能重現問題,這種情況下這種方法就失效了。
還有一種特殊情況就是出現組件崩潰時,這種情況下我們會抓取到dump文件,把dump文件導入到visual studio中,就能一步步查看call stack來尋找問題的出錯點。
好了,前面將我們解決bug時需要的信息以及解決方法做了一個簡單的總結,下面就具體說一下博主一次解決組件崩潰的經歷。很早之前的事了,dump文件和環境都沒有了,主要從以下三點介紹:
- 問題是如何重現的;
- 如何抓取dump文件;
- 問題根源在哪里
問題描述
客戶發現有一個server組件一周會出現至少一次崩潰,windows的application log報出類似如下錯誤:
根據客戶的描述,這個server組件大約有100多個socket連接,這些連接每隔幾分鍾就會有一些斷掉然后重連(有可能是網絡問題導致的),他們推斷這和組件崩潰有一定的關系。
因為問題不好重現,測試團隊先行重現客戶的這個bug。使用客戶數據庫,實現腳本模擬客戶的socket斷開和重連。
前面也說過,bug分為容易重新和不容易重現的,這個bug的第一個難點是如何重現,測試為了增加壓力,將連接數增加至300,socket的斷開時間間隔逐漸改小等等。
如何抓取dump文件
在重現問題之前,需要配置windows在組件崩潰時自動抓取dump文件,如何配置,很簡單,將下面的注冊表項導入注冊表即可,注意要把DumpComponentName.exe替換為你的需要抓dump的組件。
Windows Registry Editor Version 5.00
[HKEY_LOCAL_MACHINE\SOFTWARE\Microsoft\Windows\Windows Error Reporting\LocalDumps]
"DumpType"=dword:00000002
"DumpFolder"=hex(2):43,00,3a,00,5c,00,43,00,72,00,61,00,73,00,68,00,44,00,75,\
00,6d,00,70,00,00,00
"DumpCount"=dword:000000ff
[HKEY_LOCAL_MACHINE\SOFTWARE\Microsoft\Windows\Windows Error Reporting\LocalDumps\DumpComponentName.exe]
"DumpType"=dword:00000002
"DumpCount"=dword:000000ff
"DumpFolder"=hex(2):43,00,3a,00,5c,00,43,00,72,00,61,00,73,00,68,00,44,00,75,\
00,6d,00,70,00,5c,00,44,00,75,00,6d,00,70,00,43,00,6f,00,6d,00,70,00,6f,00,\
6e,00,65,00,6e,00,74,00,4e,00,61,00,6d,00,65,00,00,00
問題根源分析
因為dump文件和環境都不在了,不能介紹分析dump文件的過程了。只能分析最后的結果,發現了兩個問題:
問題一:有關STL容器的線程安全問題
有關這個問題建議先拜讀一下Scott Meyers的effective STL 條款12。
引用其中的話:
在STL容器(和大多數廠商的願望)里對多線程支持的黃金規則已經由SGI定義,並且在它們的STL網站[21]上
發布。大體上說,你能從實現里確定的最多是下列內容:
- 多個讀取者是安全的。多線程可能同時讀取一個容器的內容,這將正確地執行。當然,在讀取時不能
有任何寫入者操作這個容器。 - 對不同容器的多個寫入者是安全的。多線程可以同時寫不同的容器。
也就是STL容器只能夠在兩種情況下保證多線程安全:
- 同時讀取一個不會發生變化的容器。
- 同時寫多個不同的容器。
僅僅這兩種情況,看看下面的發現有問題的code,你能看出問題在哪里么?
template<typename T>
ResourceResult getValue(ResourceKey key, T value)
{
ResourceResult result = ResourceFailure;
resourcevalues_.guard();//lock
Resources::iterator resource = resourcevalues_.resources().find(key);
if (resource == resourcevalues_.resources().end()) // not found
{
resourcevalues_.addResource(key, resourceValueEntry);
resourcevalues_.unGuard();//unlock
}
else
{
// item cached
resourcevalues_.unGuard();//unlock
if ((*resource).second.getValue(value))
{
result = ResourceSuccess;
}
else
{
result = ResourceConversionError;
}
}
return result;
}
這是典型的有問題的SLT多線程編程,寫這段代碼的coder可能是這么認為的:
- 多線程不能同時寫一個容器,因此加了lock
- 多線程可以同時讀一個容器。
第一條沒有問題,但是第二條是有前提的,同時讀一個容器需要在容器不發生變化的情況下。如果恰巧在讀取容器元素時另外一個線程對此容器進行寫操作,讀線程的iterator會失效,接下來的行為是未定義的。
如何修改?對,讀也需要加鎖。
template<typename T>
ResourceResult getValue(ResourceKey key, T value)
{
ResourceResult result = ResourceFailure;
resourcevalues_.guard();//lock
Resources::iterator resource = resourcevalues_.resources().find(key);
if (resource == resourcevalues_.resources().end()) // not found
{
resourcevalues_.addResource(key, resourceValueEntry);
}
else
{
// item cached
if ((*resource).second.getValue(value))
{
result = ResourceSuccess;
}
else
{
result = ResourceConversionError;
}
}
resourcevalues_.unGuard();//unlock
return result;
}
這就沒有問題了。
問題二 : 一個線程句柄釋放問題
GeneratorThread::~GeneratorThread()
{
if (thread_)
{
stop();
thread_->wait(5000);
delete thread_;
}
delete generator_;
}
上面代碼的背景是一個controller thread(簡稱CT)管理着多個Generator Thread(GT),CT會監控每個GT的狀態,如果有的狀態不是Active,那么就delete掉它的資源,調用這個析構函數的時候會釋放掉GT所擁有的線程句柄(thread_)和線程使用的相關資源(generator_)(socket和應用層協議的相關資源),delete thread_之前需要先給這個thread發送一個stop()信號:
void
GeneratorThread::stop()
{
(void) stopRequest_.set();
}
然后調用thread_->wait(5000)確保線程收到這個stop信號之后,采取delete thead_,為的是防止打斷thread有可能正在進行的其他工作。通過抓到的dump文件,發現在這里跳出了exception。我們懷疑5秒的等待時間是不是太短了,於是改成了下面的代碼:
GeneratorThread::~GeneratorThread()
{
if (thread_)
{
stop();
thread_->wait(INFINITE);//wait forever
delete thread_;
}
delete generator_;
}
問題依然得不到解決,繼續研究,發現thread_包含的線程的真正句柄(thread_對象所屬類的成員變量,windows線程函數_beginthreadex的返回值),此時已經變成了無效句柄。問題有眉目了,竟然沒有注意到野指針問題,我們在刪除一個指針之后,編譯器只會釋放該指針所指向的內存空間,而不會刪除這個指針本身,因此,如果delete兩次GeneratorThread,析構函數會進入兩次,第二次執行下面這個句子的時候,雖然thread_已經被delete過了,但仍然能夠進入
if(thread_)
這時候一個無效句柄調用wait函數,跳出了exception。如何修改就很簡單了。
GeneratorThread::~GeneratorThread()
{
if (thread_)
{
stop();
thread_->wait(5000);
delete thread_;
thread_=nullptr;
}
delete generator_;
generator_=nullptr;
}
因此,看似復雜的問題往往是由一些低級錯誤造成的。反過來,如果寫代碼的時候如果不小心,一些小錯誤往往也會釀成大禍。
好了,最后是出補丁,release...
這篇博文結束了。
后續如果時間充足,會有工作中的其他總結,包括但不限於:
- 如何用OOP實現我們的應用層協議——協議狀態機
- 設計模式實戰——我們的產品中用到的其它設計模式
- google C++ mock測試框架在我們產品中是如何使用的
- 談談開發眼中的敏捷——我們的敏捷團隊是如何運作的
- 記錄其他疑難bug的解決過程
- 。。。