天天看點

libuv 初窺--轉

過年了,人都走光了,結果一個人活也幹不了。是以我便想找點東西玩玩。

今天想試一下 libev 寫點代碼。原本在我那台 ubuntu 機器上一點問題都沒有,可在 windows 機上用 mingw 編譯出來的庫一個 backend 都沒有,基本不可用。然後網上就有同學推薦我試一下 libuv 。

libuv 是 node.js 作者做的一個封裝庫,在 unix 環境整合的 libev ,而在 windows 下用 IOCP 另實作了一套。看起來挺滿足我的玩兒的需求的。是以就試了一下。

編譯倒挺順利,照着 test 寫點小東西也不複雜。是以我也就逐漸開始了解這個東東了。

老實說,對于一個别人寫的庫,我愛用不愛用主要是考察其 API 設計。也就是該怎麼用,設計的好不好,有沒有備援設計。文檔什麼的其實不太所謂,反正有代碼可以看嘛。

libuv 大體上我還算滿意,用 C 實作可以加很多分。不過有些小細節我覺得還是有點小遺憾的。(這個遺憾是指,如果我自己來設計,絕對不會像這個樣子)

為什麼我覺得這樣不好?

因為我覺得一個庫,若想被人當成黑盒子去使用,以後也作用黑盒子來維護,甚至可以用别的盒子去替代它。關鍵的一點就是接口簡單。這個簡單包括了使用最少的概念、需要最少的知識去了解它。

文檔通常是對接口無法自描述所需知識的一種補充。對一些例外的說明。而這些當然是越少越好。

我傾向于不在對外接口(對于 C 庫來說,就是 .h 檔案)中定義所用資料結構的具體布局。通常隻需要一個名字即可。這個名字是用來做強類型限制的。

過多的結構定義導緻了過多的概念,增加了接口複雜度。

接口的最小知識表達就是用一緻的 C 函數調用約定。有明确的輸入參數、輸出參數。對于接口函數,應該是無全局相關狀态的。這不僅僅是為了線程安全,而是可以保證沒有隐式約定(額外的知識)。

如果某些行為需要使用者設定或讀取某個結構體的一個特定域,我覺得就是在 C 函數調用這一方式外,增加了一種改變子產品行為的接口形式。或許這樣做的成本比 C 函數調用要來的低,但我以為得不償失。

尤其是、你的子產品如果相當依賴這種形式:直接對結構體的特定域指派的形式來交換資訊。這種依賴可能來至于你對性能的追求。那我覺得一般都是整個子產品的需求定義出了什麼問題。一個獨立子產品需要解決的問題,通常對外界的資訊交換應該是低頻的,它應該是可以獨立工作解決更複雜的問題的。而不應該是不斷的要求外部告知它新的狀态變換。

ps. 對于接口中的結構體定義問題。有另一種情況需要區分開。就是有大量的輸入參數或輸出參數需要一次性交換時,可以考慮定義一個結構體來做。這樣比在 C 函數調用前壓一大堆的資料去堆棧裡要幹淨的多。

寫了這麼多,我是想說說我初步閱讀 libuv 代碼的感受。我碰到的第一個問題就是:libuv 用了大量 callback 機制來完成異步 IO 的問題。而這些 callback 函數通常都帶有一個參數 <code>uv_stream_t</code> 或 <code>uv_req_t</code> 等。這個資料表示這次 callback 綁定的資料 。

我們知道, C 語言是沒有原生 closure 支援的。若有的話,closure 應是 callback 機制最價解決方案。而 C 語言模拟 closure 的方法是用一個 C Function 并攜帶一個 void * ud 。此 ud 即原本應該在 closure 中綁定的資料塊。

這裡,libuv 用的 <code>uv_stream_t</code> 大緻上等同于這個 ud 。

問題出來了。使用者在用這類異步 IO 庫的時候,每次 IO 事件都需要綁定的行為需要的資料不僅僅是一個 stream 。還需要一些圍繞這個 stream 做的動作所需要的一些其它資料。

我在閱讀 <code>test/echo-server.c</code> 時看到這麼一段:

這裡用了一次強制轉換,把 <code>uv_write_t</code> 轉換為 <code>write_req_t</code> 。為什麼可以這樣幹,是因為 <code>write_req_t</code> 被定義成:

這裡根據 C 結構布局,req 是第一個域,是以排在最前面。

這樣做有點晦澀,我隻能說感覺不太好。因為如果約定了 <code>uv_write</code> 接口傳遞的是一個 <code>uv_write_t</code> 類型的資料,這就明顯是利用 C 語言特性來夾帶私貨了。

如果這是作者推薦的慣用法的話,我則這樣了解:

libuv 其實在 API 上有個隐含約定。即回調函數的參數指向的位址偏移量為某個數值以後的資料是使用者資料。這個數值為類型的尺寸。這類似 c++ 的繼承。資料類型尺寸數值是編譯時通過編譯器來約定的。

而且,單就現在的用法,我認為更嚴謹的做法應該是類似 socket API ,顯式的把傳遞的結構尺寸在函數接口表達出來(參考 socket connect 的接口定義中的第三個參數 addrlen)。 這樣對庫的接口穩定有好處。庫可以知道使用者有可能擴充資料,長度資訊提示了庫,傳入資料體的真實大小。

btw, C++ 在用繼承來完成類似設計時,則依靠了語言對 cast 的約定。C++ 語言的知識概念太多,很難完成簡潔的子產品接口約定。在我看來,這直接導緻了 C++ 很難設計通用庫,而隻能設計專有架構。

我着一些疑惑閱讀了不少 libuv 裡的實作代碼,尤其是 uv.h 的細節。我發現這樣一個結構定義。

注意這裡有一個 data 域。從我的經驗判斷,這個域應該就是用來在一個 handle 上夾帶使用者資料的。由于沒有文檔确認,我隻能從有限的代碼閱讀中來确認我的判斷。我很奇怪沒有定義一個明确的 api 出來綁定使用者資料。因為在庫的實作代碼中也确實庫自己用到過這個域,是以估計也不是庫的使用者可以自由使用的。

當然對應的還有幾處類似設計:

還有

這個 <code>struct uv_loop_s</code> 的 data 域倒是明确的注釋可以随便使用了。

話說回來,這個綁定使用者資料的需求我在早年閱讀 Windows 的 MFC 實作時倒是見過另外一種解決方案。

Windows 的窗體有一個 SetWindowLong 的 API 可以讓使用者去設定一個使用者資料。這樣可以友善使用者在用 C++ 封裝的時候把一個 C++ 對象指針綁定在窗體 Handle 上。這樣在視窗消息回調函數中就可以取回這個對象指針。

MFC 封裝這些系統 API 時,可能是為了更通用,沒有占用這個内置域,而是自己建立了一個全局的映射表。每次窗體消息回調時,查表來找到對應的窗體對象。這種非侵入式的方案,也湊合用吧。就是對于用 C/C++ 編寫代碼的追求性能的同學來說,或許有些小小不爽。

這就是我初步閱讀 libuv 代碼的一些簡單看法。當然,我覺得 libuv 是個很不錯的東西,不然我也不會饒有興緻的玩了一晚上。隻是由于在這塊投入時間精力不多,錯誤難免。有行家看到,一笑了之吧。

繼續閱讀