為什麼我希望用C而不是C++來實現ZeroMQ(第二篇)

bigship發表於2012-09-03

譯註:這篇文章可能又會引起 C++ 程式設計師的諸多不適,就作者本文所描述的問題來看,某些“C++的問題”其實是可以有C++的解決方案的。請參閱侵入式和非侵入式容器。但是考慮到ZeroMQ是一個很底層的高效能網路庫(ZeroMQ的目標是納入Linux核心中,這也應該是改用C的一大原因,畢竟目前的ZeroMQ是用C++實現的),對錯誤處理、記憶體分配次數、併發效率等有著極高的要求,這些特定的限制往往不是所有的C++程式設計師所常見的應用場景。因此希望各位在閱讀時能多從作者的角度來考慮這些問題,而不是一味地批判作者的C++程式設計實踐能力。

上一篇博文中,我已經討論過了在需要進行嚴格錯誤處理的系統底層基礎架構的開發中需要避免使用一些C++特性(異常、建構函式、解構函式)。我的結論是,當為C++加上了這樣的使用限制後,用C來實現的話會使得程式碼更簡短也更容易閱讀。這麼做的副作用是消除了對C++執行時庫的依賴,而這不應該輕易地去掉,尤其是在嵌入式環境下。

在這一篇博文中,我想從另一個不同的角度來探究這個問題。即:使用C++和C相比,在效能上有什麼區別?理論上,這兩種語言產生的程式效能應該是相同的。物件導向只不過是在過程式語言之上的語法糖而已,這使得程式碼對人類而言更加容易理解。人類大腦似乎已經進化為一種自然的能力來處理以流程,關係等這類實體為主的物件。

每個C++程式都能自動轉換為等同的C程式——儘管這種說法理論上成立——但物件導向的觀念使得開發者以特定的方式來思考並相應地以物件導向的方式來設計他們的演算法和資料結構,而這反過來會對程式效能帶來影響。

讓我們來比較一下,C++程式設計師要如何實現物件連結串列:

注:假設包含在連結串列中的物件是不可賦值(non-assignable)的,因為這種情況下任何非簡單的物件,比如持有大量記憶體緩衝區,檔案描述符,控制程式碼等這樣的物件,如果物件是可賦值的,那麼簡單地使用std::list<person>就夠用了,不會有任何問題。

C程式設計師更傾向於按照如下的方式解決同樣的問題:

現在,讓我們比較一下兩種解決方案的記憶體模型:

首先注意到的是C++的解決方案對比C來說多分配了2倍的記憶體塊。針對連結串列中的每個元素,都要建立一個小的幫助物件。當程式中有許多容器時,這些幫助物件的總數就會擴散開來。比如,在ZeroMQ中建立和連線一個socket將導致數十次記憶體分配。而在我當前正在做的C版本中,建立一個socket只需要一次記憶體分配,連線時會再需要一次。

很明顯,記憶體分配的次數會引起效能問題。分配記憶體所花費的時間可能是無關緊要的——在ZeroMQ中,這並不是關鍵路徑(請參閱關於ZeroMQ中關鍵路徑的分析)——但是,記憶體使用量以及記憶體碎片帶來的問題就非常重要了。這直接影響到CPU快取是如何填充的,以及由此帶來的快取miss率。回顧一下,到目前為止對實體記憶體的訪問是現代計算機上最慢的操作,這樣就知道這種效能影響會有多嚴重了。

當然,這還沒完呢。

實現方案的選擇對演算法的複雜度有著直接的影響。在C++版中,從連結串列中移除一個物件是O(n)的複雜度:

在C版本中,可以確保在常數時間內完成(簡化版):

C++版本效率的低下是由於std::list的實現所致還是由於物件導向的程式設計正規化所致呢?讓我們深入的探究這個問題。

C++程式設計師不會以C的方式來設計連結串列的真正原因是因為這種設計破壞了封裝的原則:“person”類的實現者必須要知道person的例項最終會儲存到“people”連結串列中。此外,如果第三方開發者決定將其儲存到另外一個連結串列中時,就必須修改person的實現。這正是奉行物件導向程式設計的程式設計師所極力避免的。

但是,如果我們不把prev和next指標放在person類內部,我們就必須把它們放置在別的地方。所以,除了多分配一個幫助物件外沒有別的辦法了,這正是std::list<>所採用的做法。

此外,雖然幫助物件中包含有指向“person”物件的指標,但“person”物件卻不能包含有指向幫助物件的指標。如果這麼做了,那就破壞了封裝的原則——“person”就必須知道包含自己的容器。結果就是,我們可以將指向幫助物件(迭代器iterator)的指標轉型為指向“person”,但反過來卻不可以。這就是為什麼從std::list<>中移除一個元素需要遍歷整個連結串列,換句話說,這就是為什麼需要O(n)的複雜度。

簡單來說,如果我們遵從物件導向的程式設計正規化,我們就無法實現一個所有操作都是O(1)的連結串列。如果要那麼做就必須破壞封裝的原則。

注:很多人都指出應該使用迭代器而不是指標。但是,假設某個物件需要被包含在10個不同的連結串列中。你將不得不傳遞一個包含10個迭代器的結構體,而不是隻傳一個指標。此外,這並沒有解決封裝的問題,只是把問題移到了別處而已。當你希望將物件新增到一個新的容器型別中時,雖然你不用修改“person”的實現了,但你仍然不得不去修改包含迭代器的結構體。

這應該就是本文的結論了。但是這個主題實在太有意思了,我還想再問一個問題:這種低效到底是源於物件導向的設計還是說只是特定於C++語言呢?我們能否設想以一種物件導向的程式語言來實現所有相關操作都為O(1)複雜度的連結串列呢?

要回答這個問題我們必須理解問題的根本。而這個問題來自對術語“物件”的定義。在C++中“class”只是對C語言中“struct”的代名詞,這兩個關鍵字幾乎可以互換使用。言下之意是指“物件”是一系列儲存在連續記憶體空間中的資料集合。

這對於C++程式設計師來說是想都不用想的問題。但是讓我們從不同的角度來分析“物件”。

我們說物件是一系列邏輯上相關聯的資料的集合,在多執行緒程式中應該處於同一個臨界區中受到保護。這一定義從根本上改變了我們對程式架構的理解。下面這張圖展示了C語言版的person/people程式,並標識出了資料域應該由一個連結串列級的臨界區(黃色部分),還是由元素級的臨界區(綠色部分)來保護。

從物件導向的角度來看,這張圖實在太詭異。“people”物件不僅包含有“people”結構體內的欄位,還包含有“person”結構體中的一些域(“prev”和“next”指標)。

但是出人意料的是,從技術角度來看這種分解卻十分有道理:

1.  連結串列級的臨界區保護著黃色部分的欄位,這確保了連結串列的一致性。另一方面,連結串列級的臨界區並沒有對綠色部分的欄位進行保護(“age”和“weight”),因此      允許對單獨的資料進行修改而不必鎖住整個連結串列。

2.  黃色部分的欄位應該只能由“people”類的方法來訪問,儘管從記憶體佈局上來看它們都是屬於“person”結構體的。

3.  如果程式語言允許我們在“people”類的內部宣告黃色部分的欄位,那麼封裝的原則就不會被打破。換句話說,將“person”新增到其它連結串列中時就不需要         對“person”類的定義進行修改。

最後,讓我們做一個概念性的實驗,採用上述思想來擴充套件C++。請注意,我們的目標不是為了提供一種完美的語言擴充套件設計,更多的是為了展示在C++中實現這種思想的可能性。

也就是說,讓我引入一種“private in X”的語法結構。它可以使用在類定義中,遵循“private in X”形式的資料成員在物理上(作者指的是按記憶體佈局來看)屬於結構體X的一部分,但是它們只能由被定義的類來訪問:

我的結論是,如果ZeroMQ用C來實現的話,記憶體分配將更少,產生的記憶體碎片也更少。一些演算法的複雜度將達到O(1),而不是O(n)或者O(logn)。

效率低下的問題不在於ZeroMQ的程式碼本身,也不是物件導向程式設計的固有缺陷,更多的是在於C++語言的設計上。當然,公平的說C++並不是唯一,同樣的問題也存在於大多數——如果不是全部的話——物件導向程式語言中。

 

英文原文:martin_sustrik      編譯:伯樂線上— 陳舸

【如需轉載,請標註並保留原文連結、譯文連結和譯者等資訊,謝謝合作!】

 

相關文章