2013年7月20日 星期六

了解 C/C++ 程式行為的技巧

多數情況下我們會使用別人的程式,或是參與別人開發已久的程式,比較少是自己重頭撰寫所有程式。由此可知,了解程式行為的技巧相當重要,但是很少看到有書籍討論這部份的事,或許是因為很難理出一個主題吧。

下面以了解 C/C++ 程式為主,列出自己一些零散心得。

動態分析

在程式碼裡填入觀察碼

加入程式 log function call 是直接有效的作法,而且做起來不如想像的麻煩,詳見 《trace C/C++ function call 的方法》

或是找看看前人是否有了下除錯的 flag。有經驗的程式設計師在開發時必定有一套除錯方式,只是在正式使用時關掉了這些除錯功能,詳見《C/C++ 檢查和打開 debug 功能的小技巧》

附帶一提,自己開發程式時,除了留下除錯程式之外,記得要考慮使用 gdb 的影響,讓除錯程式更易於使用,詳見 《除錯小技巧: 在程式中直接中斷及偵測是否被 gdb 監控中》

觀察使用的函式庫

程式碼通常會用到第三方函式庫,有時我們可以從中得知一些線索,《python BaseHTTPServer 速度緩慢的原因》是一個簡短的例子,說明如何使用 ltrace 找出關鍵的第三方函式,繼而找出效率瓶頸。使用 ltrace 無須重編程式或加入 debug symbol。不過有可能讓程式變得不穩定,這時可使用 -l filename 只觀察部份函式庫,執行的時候會比較穩定一些。

其它兩則和 ltrace 相關的例子:

觀察使用的 system call

system call 是程式和 kernel 溝通的途徑,介面數量相對少,容易集中觀察。有時藉由觀察 system call 夾帶的參數,可以提供一些線索。比方說,不論上層函式如何包裝,開啟檔案最後會用到 open(),可以從 open() 的參數 找出程式目前使用的設定檔或 log 檔位置。或是像《用 strace 找出 Ubuntu 如何提示未安裝的指令》,使用 strace 觀察 system call 直接解答疑惑。

ltrace 一樣,無須重編程式也不用 debug symbol。更棒的是,使用 strace() 執行程式還滿穩定的。

其它 strace 相關的例子:

使用 gdb

debugger 的重要性無須多言,《gdb 初步心得》條列我常用到的指令。另外值得一提的是,有 core dump 的時候,core dump 裡一定會記錄產生 segmentation fault 的 thread,不用擔心找錯 thread,原因見《linux thread 與 signal》

編譯上線的程式時,多數會加上 -O2 最佳化效能,讓程式實際執行的狀況和程式碼有些出入。雖然如此,仍然可以加上 -g 觀察程式的 core dump,但要留意觀察的結果不見得是對的,詳見《debug info 和 optimization》。平時觀察程式行為還是用 -O0 -g 編譯較適當。

以下是其它和 gdb 相關的小技巧:

從 kernel 切入

有些情況下我們沒辦法從程式內部或 gdb 取得資訊,比方說程式莫明奇妙地收到 SIGKILL 而結束。由於程式無法攔截 SIGKILL,不方便查出凶手是誰。雖然可以在相關程式內直接對 system call kill() 設中斷點,但若凶手是外部程式,就沒輒了。

這種時候可用 SystemTap 直接從 kernel 內觀察是什麼程式請求使用 kill 送出 SIGKILL。我自己還沒有第一手經驗,這個例子是從同事那邊學來的,在這裡備忘觀察程式時,還可以用 SystemTap 觀察更底層的行為。

靜態分析

從執行檔找出關鍵字

《配合 c++filt 讀程式》說明如何從執行檔中找關鍵字,可藉此找出 UI 相關字串或是可能的函式名稱,有時比直接從程式碼下手容易。

找出 symbol 的定義或使用到 symbol 的程式碼

C 的情況比較單純,相關工具比較正確,也有工具可以產生精美的 call graph。但 C++ 的情況複雜許多,最後我決定無視 C++ 語法,直接找出所有和目標 symbol 有關的程式。《閱讀 C/C++ 原始碼的好幫手》有整理相關工具, 《查 C/C++ symbol 定義的方法》有一點關於我使用 gj 的方法。

使用 doxygen 產生 class 階層關係圖

大型專案會依功能切成數個模組,模組本身亦有一套自己使用 class 的方法。直接看程式碼容易陷入見樹不見林的困境。這時可用 class 階層關係圖協助了解整體架構。產生階層圖的方式見《用 doxygen 產生 class hierarchy diagram》。再輔以 gdb 產生 backtrace 觀察類別、模組之間的使用關係,比較容易明白整體架構。

善用編輯器或 IDE

目前是逐步學習如何強化用編輯器或 IDE 讀程式,加快在相關程式碼中探索的時間,還沒整理出一套完整的流程。

比方說用 gj 跳到某個使用函式 foo() 的檔案後,再來我會想知道目前在那個函式裡,《vim 顯示目前函式的名稱》就是針對此情境在 vim 內加了快速鍵做這這件事。或是像《使用 vim script 自動開啟 C/C++ 程式的標頭檔或程式碼》說明如何在 .h 檔裡按 F4 快速開啟 .c 或 .cpp 檔,或是反過來在 .c 或 .cpp 裡開啟 .h。

預防勝於治療

理解到了解程式是如此不容易後,行有餘力時,別忘了加強測試碼,讓日後使用相關程式的人更快進入狀況。良好的 unit tests 也是不錯的使用範例,有助於了解模組的行為。

核心分析

2014-01-05 更新。

現在來看,這段的概念才是理解大型專案時最重要的技巧。經驗愈多,愈覺得如此。說來容易做來難,需要累積經驗後才能體會。

想像自己會如何進行開發

有經驗的軟體工程師,做事的方式十之八九也差不多。特別是需求愈嚴苛,最後的解法也不會差太多。先自己想像可能的脈絡,之後比較容易縮小觀察目標,專注驗證自己的假設,並從驗證結果獲得新的契機製定下一步。

尋找原有專案的除錯工具

經年累月的專案必定有自己一套除錯工具,不然很難長期維護。有經驗的工程師應該也會在開發過程中研發出負責專案所需的除錯工具。所以,最有效觀察程式的方式,就是用原專案專用的工具。

對目標專案有一定了解後,可以自己想像,若自己進行同樣類型的專案開發,可能會做什麼輔助工具呢?有機會從中猜中切入點。

2013年6月29日 星期六

非同步程式心得

在這個網路盛行的時代,程式中常常需要連網路取用外部資料,並且不希望因此阻礙主程式的活動,這時會使用非同步操作。或是程式中途需要使用者輸入資料、決定繼續或取消等,若不希望阻礙主程式,也會用非同步操作。

以連上外部伺服器為例,介面可能像是這樣:

bool LoginToServer(const std::string& name,const std::string& password);
void OnLoginToServer(const std::string& name,int error);

主程式呼叫 LoginToServer() 的當下只能由回傳值知道有沒有進行登入,但不知道是否登錄成功。過一段時間後,某個 thread 會呼叫 OnLoginToServer() 透過 error 的值告知主程式登入成功或失敗。

非同步溝通介面

以下以 caller 和 async module 分別表示「使用非同步 API 的函式」以及「提供非同步 API 的模組」。async module 提供的 API 有兩種選擇:

  • 使用 callback 的方式,async module 有結果時通知使用 callback 通知 caller。
  • caller 事後自行詢問先前的呼叫是否已有結果。

兩種介面各有優缺點,不過 callback 似乎比較盛行?

使用 callback 的方式

async module 會保證通知 caller,這有以下的好處:

代價是 caller 需要記下呼叫當下的 context,callback 時才知道怎麼完成剩下的工作。並且不方便閱讀以及用 debugger 追踪程式。

以續傳上傳為例,假設網站提供的介面如下:

  1. /create/?filename=X&size=Y: 得知上傳檔名和大小,回傳檔案代碼
  2. /upload/?filehandle=X&data=Y&offset=Z: 持續接收檔案內容
  3. /done/?filehandle=X: 結束上傳

client 使用 http 函式庫時,需要注意以下事項:

  • 需要類似 state machine 的結構,callback 時才明白目前進行到那一個步驟。
  • 需要記錄輔助資訊,像是目前在處理那個檔案,上傳到那一段資料。
  • 需要考慮中途的錯誤處理。
  • 結束後和主程式接軌的方法
  • 同時上傳多個檔案時,儲存狀態的資料結構需要更靈活一些。

使用詢問的方式

async module 可以提供 non-blocking 或 blocking 的詢問函式。使用 pthread_cond_wait() 之類的方式避免 busy waiting。於是 caller 可使用 non-blocking 的方式確認情況,或是使用另外特定的 thread 用 blocking 的方式確認情況。使用 blocking 的詢問方式時,caller 不需另外存下呼叫時的狀態,易寫易讀。

但是有多個非同步操作時,詢問的方式不容易即時延續先前非同步的操作。比方發出十個 http request 時,若是使用 callback 介面,那個 http response 好了,就先呼叫它的 callback 傳回 http response;使用詢問方式時,caller 得一個個用 non-blocking 詢問方式,或十個 thread 各用 blocking 詢問的方式,才能達到一樣的反應速度。

callback 介面的選擇

async module 提供 caller 傳入 callback 時,有幾種選擇:

  • async module 定義 class APIDelegate,caller 使用 async module 前放入 async module 規定的 APIDelegate,async module 之後會呼叫 APIDelegate 不同函式告知後續情況。比方說 iOS 的 NSURLConnection 定義了 NSURLConnectionDelegateNSURLConnectionDownloadDelegate
  • caller 呼叫 async module 的函式時,直接傳入 callback object,像 JavaScript 的 jQuery.ajax() 裡面的 success 以及 error 參數。

第一種介面明確,易讀難寫;第二種則是易寫難讀。若是長期發展的程式,我偏好第一種設計,有明確的名稱比較好追蹤程式碼。

不論是那種介面,caller 都需要自行記住呼叫當下的狀態,callback 回來時才知道如何完成後續工作。像 NSURLConnection 的 callback 只有告知 connection,connection 又不能作為 dictionary 的 key,使用上稍有不便。理想的情況下,callback 應該提供 context 帶有必要的資訊或是提供 unique ID 方便 caller 自行管理 context。比方說 Unix 的檔案操作提供一個 file handle,由系統記住檔案相關的設定和讀寫的狀態,caller 透過 file handle 可以取得所有相關資料。

async module 實作的選擇

實作 async module 有幾種選擇:

  • 新增一個 thread 讀寫外部資料,結束後呼叫 callback。
  • 使用 non-blocking I/O 讀寫外部資料,再由一個特定的 thread 持續追蹤結果,有結果後再呼叫 callback。由於可以使用系統函式 (如 select 或 epoll) 追蹤,負擔相對的低。

第一個作法易讀易寫,但是有以下的缺點:

  • 新增和消滅 thread 有時間成本。
  • thread 會占用額外記憶體。若一個 thread 占用 2MB,512 個會占用 1GB。
  • 大量 thread 同時讀寫資料時,增加額外 context switch 的成本。

若這個 module 只會偶而用到的話 (比方說登入伺服器),到是沒什麼問題。

第二個作法難讀難寫,也因此有些善心人士包好函式庫負責做這類事,像是 libeventlibev

雖然使用函式庫降低第二種作法的實作難度,程式碼還是不易閱讀。於是有人結合 coroutine 和 non-blocking I/O,做出更好用的框架。我目前只有用過 Python 的 gevent,寫起來如同寫 multi-thread,但是底層沒有用到大量 thread,免除了 thread 帶來的缺點。相信在其它語言也有類似的框架。

使用 coroutine 的時機

雖然 coroutine 兼具易寫易讀負擔又低等優點,它有個關鍵的難處:所有程式都要在 coroutine 的架構下,只要有一個 "thread" (正確說法是 subroutine) 卡住了 (比方不小心用到 blocking I/O),全部 "thread" 都會被卡住。現今的程式使用許多外部函式庫,很難保證不會發生這類事。

此外,依實作方式而定,coroutine 可能不方便使用多個 CPU,只要有一個工作需要大量 CPU 計算時間,也會拖累其它 "thread"。

所以一般的 GUI 程式或 client 端連線程式不見得適用 coroutine。但像 proxy server 這類主要工作是 I/O 且相當重視連線數量 scalability 的程式,就很適合用 coroutine

gevent 使用心得

以我自己的情況來說,主程式使用 gevent 處理 client 的需求,需要外部 http 連線時使用 geventhttpclient,滿容易達到 C10K。不過 unit test 的時候有些小問題,還有 gevent signal 處理有些 bug,踩到時要特別處理。

另外,雖然使用 gevent 的 monkey patch 可「無痛替換所有網路操作」,實測的結果效能不太好,而且我個人偏好「Explicit is better than implicit」,所以沒用 monkey patach。

結語

以上是這一年半來寫網路程式和 GUI 的心得。實作非同步程式時,要留意自己是處於 caller 或 async module 的角度。兩者需要考慮的事不同。特別是自己一手包辦整個程式時,切清楚兩者的角色才會有清楚的架構,方便日後維護。

備註