青青草原综合久久大伊人导航_色综合久久天天综合_日日噜噜夜夜狠狠久久丁香五月_热久久这里只有精品

陳碩的Blog

Muduo 網(wǎng)絡(luò)編程示例之七:“串并轉(zhuǎn)換”連接服務(wù)器及其自動化測試

Muduo 網(wǎng)絡(luò)編程示例之七:連接服務(wù)器及其自動化測試

陳碩 (giantchen_AT_gmail)

Blog.csdn.net/Solstice  t.sina.com.cn/giantchen

這是《Muduo 網(wǎng)絡(luò)編程示例》系列的第七篇文章。

Muduo 全系列文章列表: http://blog.csdn.net/Solstice/category/779646.aspx

本文介紹如何使用 test harness 來測試一個具有內(nèi)部邏輯的網(wǎng)絡(luò)服務(wù)程序。

本文的代碼見 http://code.google.com/p/muduo/source/browse/trunk/examples/multiplexer

下載地址:http://muduo.googlecode.com/files/muduo-0.2.0-alpha.tar.gz SHA1 checksum: 75a09a82f96b583004876e95105c679e64c95715

 

云風(fēng)在他的博客中提到了網(wǎng)游連接服務(wù)器的功能需求(搜“練手項目”),我用 C++ 初步實現(xiàn)了這些需求,并為之編寫了配套的自動化 test harness,作為 muduo 網(wǎng)絡(luò)庫的示例。

注意:本文呈現(xiàn)的代碼僅僅實現(xiàn)了基本的功能需求,沒有考慮安全性,也沒有特別優(yōu)化性能,不適合用作真正的放在公網(wǎng)上運行的網(wǎng)游連接服務(wù)器。

功能需求

這個連接服務(wù)器把多個客戶連接匯聚為一個內(nèi)部 TCP 連接,起到“數(shù)據(jù)串并轉(zhuǎn)換”的作用,讓 backend 的邏輯服務(wù)器專心處理業(yè)務(wù),而無需顧及多連接的并發(fā)性。以下是系統(tǒng)的框圖:

multiplexer

這個連接服務(wù)器的作用與數(shù)字電路中的數(shù)據(jù)選擇器 (multiplexer) 類似,所以我把它命名為 multiplexer。(其實 IO-Multiplexing 也是取的這個意思,讓一個 thread-of-control 能有選擇地處理多個 IO 文件描述符。)

mux

(上圖取自 wikipedia,是 public domain 版權(quán))

實現(xiàn)

Multiplexer 的功能需求不復(fù)雜,無非是在 backend connection 和 client connections 之間倒騰數(shù)據(jù)。具體來說,主要是處理四種事件:

由上可見,multiplexer 的功能與 proxy 頗為類似。multiplexer_simple.cc 是一個線程版的實現(xiàn),借助 muduo 的 io-multiplexing 特性,可以方便地處理多個并發(fā)連接。

在實現(xiàn)的時候有兩點值得注意:

  • TcpConnection 的 id 如何存放?當(dāng)從 backend 收到數(shù)據(jù),如何根據(jù) id 找到對應(yīng)的 client connection?當(dāng)從 client connection 收到數(shù)據(jù),如何得知其 id ?

第一個問題比較好解決,用 std::map〈int, TcpConnectionPtr〉 clientConns_; 保存從 id 到 client connection 的映射就行。

第二個問題固然可以用類似的辦法解決,但是我想借此介紹一下 muduo::net::TcpConnection 的 context 功能。每個 TcpConnection 都有一個 boost::any 成員,可由客戶代碼自由支配(get/set),代碼如下。這個 boost::any 是 TcpConnection 的 context,可以用于保存與 connection 綁定的任意數(shù)據(jù)(比方說 connection id、connection 的最后數(shù)據(jù)到達時間、connection 所代表的用戶的名字等等)。這樣客戶代碼不必繼承 TcpConnection 就能 attach 自己的狀態(tài),而且也用不著 TcpConnectionFactory 了(如果允許繼承,那么必然要向 TcpServer 注入此 factory)。

class TcpConnection : public boost::enable_shared_from_this<TcpConnection>,
                      boost::noncopyable
{
 public:

  void setContext(const boost::any& context)
  { context_ = context; }

  boost::any& getContext()
  { return context_; }

  const boost::any& getContext() const
  { return context_; }

  // ...

 private:
  // ...
  boost::any context_;
};

typedef boost::shared_ptr<TcpConnection> TcpConnectionPtr;

對于 Multiplexer,在 onClientConnection() 里調(diào)用 conn->setContext(id),把 id 存到 TcpConnection 對象中。onClientMessage() 從 TcpConnection 對象中取得 id,連同數(shù)據(jù)一起發(fā)送給 backend,完整實現(xiàn)如下:

  void onClientMessage(const TcpConnectionPtr& conn, Buffer* buf, Timestamp)
  {
    if (!conn->getContext().empty())
    {
      int id = boost::any_cast<int>(conn->getContext());
      sendBackendBuffer(id, buf);
    }
    else
    {
      buf->retrieveAll();
    }
  }
  • TcpConnection 的生命期如何管理?由于 Client Connection 是動態(tài)創(chuàng)建并銷毀,其生與滅完全由客戶決定,如何保證 backend 想向它發(fā)送數(shù)據(jù)的時候,這個 TcpConnection 對象還活著?解決思路是用 reference counting,當(dāng)然,不用自己寫,用 boost::shared_ptr 即可。TcpConnection 是 muduo 中唯一默認采用 shared_ptr 來管理生命期的對象,蓋由其動態(tài)生命期的本質(zhì)決定。更多內(nèi)容請參考陳碩《當(dāng)析構(gòu)函數(shù)遇到多線程──C++ 中線程安全的對象回調(diào)

multiplexer 是二進制協(xié)議,如何測試呢?

自動化測試

Multiplexer 是 muduo 網(wǎng)絡(luò)編程示例中第一個具有 non-trivial 業(yè)務(wù)邏輯的網(wǎng)絡(luò)程序,根據(jù)陳碩《分布式程序的自動化回歸測試》一文的思想,我為它編寫了 test harness。代碼見 http://code.google.com/p/muduo/source/browse/trunk/examples/multiplexer/harness/src/com/chenshuo/muduo/example/multiplexer

這個 Test harness 采用 Java 編寫,用的是 Netty 庫。這個 test harness 要扮演 clients 和 backend,也就是既要主動發(fā)起連接,也要被動接受連接。結(jié)構(gòu)如下:

harness

Test harness 會把各種 event 匯聚到一個 blocking queue 里邊,方便編寫 test case。Test case 則操縱 test harness,發(fā)起連接、發(fā)送數(shù)據(jù)、檢查收到的數(shù)據(jù),例如以下是其中一個 test case

http://code.google.com/p/muduo/source/browse/trunk/examples/multiplexer/harness/src/com/chenshuo/muduo/example/multiplexer/testcase/TestOneClientSend.java

這里的幾個 test cases 都以用 java 直接寫的,如果有必要,也可以采用 Groovy 來編寫,這樣可以在不重啟 test harness 的情況下隨時修改添加 test cases。具體做法見陳碩《“過家家”版的移動離線計費系統(tǒng)實現(xiàn)》。

將來的改進

有了這個自動化的 test harness,我們可以比較方便且安全地修改(甚至重新設(shè)計)multiplexer。例如
  • 增加“backend 發(fā)送指令斷開 client connection”的功能。有了自動化測試,這個新功能可以被單獨測試(指開發(fā)者測試),而不需要真正的 backend 參與進來。
  • 將 Multiplexer 改用多線程重寫。有了自動化回歸測試,我們不用擔(dān)心破壞原有的功能,可以放心大膽地重寫。而且由于 test harness 是從外部測試,不是單元測試,重寫 multiplexer 的時候不用動 test cases,這樣保證了測試的穩(wěn)定性。另外,這個 test harness 稍作改進還可以進行 stress testing,既可用于驗證多線程 multiplexer 的正確性,亦可對比其相對單線程版的效率提升。

posted @ 2011-05-02 19:47 陳碩 閱讀(2485) | 評論 (0)編輯 收藏

Muduo 網(wǎng)絡(luò)編程示例之六:限制服務(wù)器的最大并發(fā)連接數(shù)

Muduo 網(wǎng)絡(luò)編程示例之六:限制服務(wù)器的最大并發(fā)連接數(shù)

陳碩 (giantchen_AT_gmail)

Blog.csdn.net/Solstice  t.sina.com.cn/giantchen

這是《Muduo 網(wǎng)絡(luò)編程示例》系列的第六篇文章。

Muduo 全系列文章列表: http://blog.csdn.net/Solstice/category/779646.aspx

 

本文已以大家都熟悉的 EchoServer 介紹如何限制服務(wù)器的并發(fā)連接數(shù)。

本文的代碼見 http://code.google.com/p/muduo/source/browse/trunk/examples/maxconnection/

《Muduo 網(wǎng)絡(luò)編程示例 系列》計劃中的第六篇文章原本是“用于測試兩臺機器的帶寬的 pingpong 程序”,pingpong 協(xié)議的程序已經(jīng)在《muduo 與 boost asio 吞吐量對比》和《muduo 與 libevent2 吞吐量對比》兩篇文章中介紹過了,所以我改為寫另外一個有點意思的主題。

這篇文章中的“并發(fā)連接數(shù)”是指一個 server program 能同時支持的客戶端連接數(shù),連接系由客戶端主動發(fā)起,服務(wù)端被動接受(accept)連接。(如果要限制應(yīng)用程序主動發(fā)起的連接,則問題要簡單得多,畢竟主動權(quán)和決定權(quán)都在程序本身。)

為什么要限制并發(fā)連接數(shù)?

一方面,我們不希望服務(wù)程序超載,另一方面,更因為 file descriptor 是稀缺資源,如果出現(xiàn) file descriptor 耗盡,很棘手(跟 “malloc 失敗/new() 拋出 std::bad_alloc”差不多同樣棘手)。

我在《分布式系統(tǒng)的工程化開發(fā)方法》一文中曾談到 libev 作者建議的一種應(yīng)對“accept()ing 時 file descriptor 耗盡”的辦法。

 

幻燈片35

幻燈片36

Muduo 的 acceptor 正是這么實現(xiàn)的,但是,這個做法在多線程下不能保證正確,會有 race condition。(思考題:是什么 race condition?)

其實有另外一種比較簡單的辦法:file descriptor 是 hard limit,我們可以自己設(shè)一個稍低一點的 soft limit,如果超過 soft limit 就主動關(guān)閉新連接,這樣就避免觸及“file descriptor 耗盡”這種邊界條件。比方說當(dāng)前進程的 max file descriptor 是 1024,那么我們可以在連接數(shù)達到 1000 的時候進入“拒絕新連接”狀態(tài),這樣留給我們足夠的騰挪空間。

 

Muduo 中限制并發(fā)連接數(shù)


Muduo 中限制并發(fā)連接數(shù)的做法簡單得出奇。以在《Muduo 網(wǎng)絡(luò)編程示例之零:前言》中出場過的 EchoServer 為例,只需要為它增加一個 int 成員,表示當(dāng)前的活動連接數(shù)。(如果是多線程程序,應(yīng)該用 muduo::AtomicInt32。)

class EchoServer
{
 public:
  EchoServer(muduo::net::EventLoop* loop,
             const muduo::net::InetAddress& listenAddr,
             int maxConnections);

  void start();

 private:
  void onConnection(const muduo::net::TcpConnectionPtr& conn);

  void onMessage(const muduo::net::TcpConnectionPtr& conn,
                 muduo::net::Buffer* buf,
                 muduo::Timestamp time);

  muduo::net::EventLoop* loop_;
  muduo::net::TcpServer server_;
  int numConnected_; // should be atomic_int
  const int kMaxConnections;
};

然后,在 EchoServer::onConnection() 中判斷當(dāng)前活動連接數(shù),如果超過最大允許數(shù),則踢掉連接。

void EchoServer::onConnection(const TcpConnectionPtr& conn)
{
  LOG_INFO << "EchoServer - " << conn->peerAddress().toHostPort() << " -> "
    << conn->localAddress().toHostPort() << " is "
    << (conn->connected() ? "UP" : "DOWN");

  if (conn->connected())
  {
    ++numConnected_;
    if (numConnected_ > kMaxConnections)
    {
      conn->shutdown();
    }
  }
  else
  {
    --numConnected_;
  }
  LOG_INFO << "numConnected = " << numConnected_;
}

這種做法可以積極地防止耗盡 file descriptor。

另外,如果是有業(yè)務(wù)邏輯的服務(wù),可以在 shutdown() 之前發(fā)送一個簡單的響應(yīng),表明本服務(wù)程序的負載能力已經(jīng)飽和,提示客戶端嘗試下一個可用的 server(當(dāng)然,下一個可用的 server 地址不一定要在這個響應(yīng)里給出,客戶端可以自己去 name service 查詢),這樣方便客戶端快速 failover。

 

后文將介紹如何處理空閑連接的超時:如果一個連接長時間(若干秒)沒有輸入數(shù)據(jù),則踢掉此連接。辦法有很多種,我用 Time Wheel 解決。

posted @ 2011-04-27 00:03 陳碩 閱讀(4920) | 評論 (9)編輯 收藏

分布式程序的自動化回歸測試

陳碩 (giantchen_AT_gmail)

Blog.csdn.net/Solstice  t.sina.com.cn/giantchen

 

陳碩關(guān)于分布式系統(tǒng)的系列文章:http://blog.csdn.net/Solstice/category/802325.aspx

 

本作品采用“Creative Commons 署名-非商業(yè)性使用-禁止演繹 3.0 Unported 許可協(xié)議(cc by-nc-nd)”進行許可。
http://creativecommons.org/licenses/by-nc-nd/3.0/

 

本文所談的“測試”全部指的是“開發(fā)者測試/developer testing”,由程序員自己來做,不是由 QA 團隊進行的系統(tǒng)測試。這兩種測試各有各的用途,不能相互替代。

我在《樸實的C++設(shè)計》一文中談到“為了確保正確性,我們另外用Java寫了一個測試夾具(test harness)來測試我們這個C++程序。這個測試夾具模擬了所有與我們這個C++程序打交道的其他程序,能夠測試各種正常或異常的情況。基本上任何代碼改動和bug修復(fù)都在這個夾具中有體現(xiàn)。如果要新加一個功能,會有對應(yīng)的測試用例來驗證其行為。如果發(fā)現(xiàn)了一個bug,先往夾具里加一個或幾個能復(fù)現(xiàn)bug的測試用例,然后修復(fù)代碼,讓測試通過。我們積累了幾百個測試用例,這些用例表示了我們對程序行為的預(yù)期,是一份可以運行的文檔。每次代碼改動提交之前,我們都會執(zhí)行一遍測試,以防低級錯誤發(fā)生。

今天把 test harness 這個做法仔細說一說。

自動化測試的必要性

我想自動化測試的必要性無需贅言,自動化測試是 absolutely good stuff。

基本上,要是沒有自動化的測試,我是不敢改產(chǎn)品代碼的(“改”包括添加新功能和重構(gòu))。自動化測試的作用是把程序已經(jīng)實現(xiàn)的 features 以 test case 的形式固化下來,將來任何代碼改動如果破壞了現(xiàn)有的功能需求就會觸發(fā)測試 failure。好比 DNA 雙鏈的互補關(guān)系,這種互補結(jié)構(gòu)對保持生物遺傳的穩(wěn)定有重要作用。類似的,自動化測試與被測程序的互補結(jié)構(gòu)對保持系統(tǒng)的功能穩(wěn)定有重要作用。

單元測試的能與不能

一提到自動化測試,我猜很多人想到的是單元測試(unit testing)。單元測試確實有很大的用處,對于解決某一類型的問題很有幫助。粗略地說,單元測試主要用于測試一個函數(shù)、一個 class 或者相關(guān)的幾個 classes。

最典型的是測試純函數(shù),比如計算個人所得稅的函數(shù),輸出是“起征點、扣除五險一金之后的應(yīng)納稅所得額、稅率表”,輸出是應(yīng)該繳的個稅。又比如我在《〈程序中的日期與時間〉第一章 日期計算》中用單元測試來驗證 Julian day number 算法的正確性。再比如我在《“過家家”版的移動離線計費系統(tǒng)實現(xiàn)》和《模擬銀行窗口排隊叫號系統(tǒng)的運作》中用單元測試來檢查程序運行的結(jié)果是否符合預(yù)期。(最后這個或許不是嚴格意義上的單元測試,更像是驗收測試。)

為了能用單元測試,主代碼有時候需要做一些改動。這對 Java 通常不構(gòu)成問題(反正都編譯成 jar 文件,在運行的時候指定 entry point)。對于 C++,一個程序只能有一個 main() 入口點,要采用單元測試的話,需要把功能代碼(被測對象)做成一個 library,然后讓單元測試代碼(包含 main() 函數(shù))link 到這個 library 上;當(dāng)然,為了正常啟動程序,我們還需要寫一個普通的 main(),并 link 到這個 library 上。

單元測試的缺點

根據(jù)我的個人經(jīng)驗,我發(fā)現(xiàn)單元測試有以下缺點。

  • 阻礙大型重構(gòu)

單元測試是白盒測試,測試代碼直接調(diào)用被測代碼,測試代碼與被測代碼緊耦合。從理論上說,“測試”應(yīng)該只關(guān)心被測代碼實現(xiàn)的功能,不用管它是如何實現(xiàn)的(包括它提供什么樣的函數(shù)調(diào)用接口)。比方說,以前面的個稅計算器函數(shù)為例,作為使用者,我們只關(guān)心它算的結(jié)果是否正確。但是,如果要寫單元測試,測試代碼必須調(diào)用被測代碼,那么測試代碼必須要知道個稅計算器的 package、class、method name、parameter list、return type 等等信息,還要知道如何構(gòu)造這個 class。以上任何一點改動都會造成測試失敗(編譯就不通過)。

在添加新功能的時候,我們常會重構(gòu)已有的代碼,在保持原有功能的情況下讓代碼的“形狀”更適合實現(xiàn)新的需求。一旦修改原有的代碼,單元測試就可能編譯不過:比如給成員函數(shù)或構(gòu)造函數(shù)添加一個參數(shù),或者把成員函數(shù)從一個 class 移到另一個 class。對于 Java,這個問題還比較好解決,因為 IDE 的重構(gòu)功能很強,能自動找到 references,并修改之。

對于 C++,這個問題更為嚴重,因為一改功能代碼的接口,單元測試就編譯不過了,而 C++ 通常沒有自動重構(gòu)工具(語法太復(fù)雜,語意太微妙)可以幫我們,都得手動來。要么每改動一點功能代碼就修復(fù)單元測試,讓編譯通過;要么留著單元測試編譯不通過,先把功能代碼改成我們想要的樣子,再來統(tǒng)一修復(fù)單元測試。

這兩種做法都有困難,前者,C++ 編譯緩慢,如果每改動一點就修復(fù)單元測試,一天下來也前進不了幾步,很多時間浪費在等待編譯上;后者,問題更嚴重,單元測試與被測代碼的互補性是保證程序功能穩(wěn)定的關(guān)鍵,如果大幅修改功能代碼的同時又大幅修改了單元測試,那么如何保證前后的單元測試的效果(測試點)不變?如果單元測試自身的代碼發(fā)生了改動,如何保證它測試結(jié)果的有效性?會不會某個手誤讓功能代碼和單元測試犯了相同的錯誤,負負得正,測試還是綠的,但是實際功能已經(jīng)亮了紅燈?難道我們要為單元測試寫單元測試嗎?

有時候,我們需要重新設(shè)計并重寫某個程序(有可能換用另一種語言)。這時候舊代碼中的單元測試完全作廢了(代碼結(jié)構(gòu)發(fā)生巨大改變,甚至連編程語言都換了),其中包含的寶貴的業(yè)務(wù)知識也付之東流,豈不可惜?

  • 為了方便測試而施行依賴注入,破壞代碼的整體性

為了讓代碼具有“可測試性”,我們常會使用依賴注入技術(shù),這么做的好處據(jù)說是“解耦”(其實,有人一句話道破真相但凡你在某個地方切斷聯(lián)系,那么你必然會在另一個地方重新產(chǎn)生聯(lián)系),壞處就是割裂了代碼的邏輯:單看一塊代碼不知道它是干嘛的,它依賴的對象不知道在哪兒創(chuàng)建的,如果一個 interface 有多個實現(xiàn),不到運行的時候不知道用的是哪個實現(xiàn)。(動態(tài)綁定的初衷就是如此,想來讀過“以面向?qū)ο笏枷雽崿F(xiàn)”的代碼的人都明白我在說什么。)

以《Muduo 網(wǎng)絡(luò)編程示例之二:Boost.Asio 的聊天服務(wù)器》中出現(xiàn)的聊天服務(wù)器 ChatServer 為例,ChatServer 直接使用了 muduo::net::TcpServer 和 muduo::net::TcpConnection 來處理網(wǎng)絡(luò)連接并收發(fā)數(shù)據(jù),這個設(shè)計簡單直接。如果要為 ChatServer 寫單元測試,那么首先它肯定不能在構(gòu)造函數(shù)里初始化 TcpServer 了。

稍微復(fù)雜一點的測試要用 mock object。ChatServer 用 TcpServer 和 TcpConenction 來收發(fā)消息,為了能單元測試,我們要為 TcpServer 和 TcpConnection 提供 mock 實現(xiàn),原本一個具體類 TcpServer 就變成了一個 interface TcpServer 加兩個實現(xiàn) TcpServerImpl 和 TcpServerMock,同理 TcpConnection 也一化為三。ChatServer 本身的代碼也變得復(fù)雜,我們要設(shè)法把 TcpServer 和 TcpConnection 注入到其中,ChatServer 不能自己初始化 TcpServer 對象。

這恐怕是在 C++ 中使用單元測試的主要困難之一。Java 有動態(tài)代理,還可以用 cglib 來操作字節(jié)碼以實現(xiàn)注入。而 C++ 比較原始,只能自己手工實現(xiàn) interface 和 implementations。這樣原本緊湊的以 concrete class 構(gòu)成的代碼結(jié)構(gòu)因為單元測試的需要而變得松散(所謂“面向接口編程”嘛),而這么做的目的僅僅是為了滿足“源碼級的可測試性”,是不是有一點因小失大呢?(這里且暫時忽略虛函數(shù)和普通函數(shù)在性能上的些微差別。)對于不同的 test case,可能還需要不同的 mock 對象,比如 TcpServerMock 和 TcpServerFailureMock,這又增加了編碼的工作量。

此外,如果程序中用到的涉及 IO 的第三方庫沒有以 interface 方式暴露接口,而是直接提供的 concrete class (這是對的,因為C++中應(yīng)該《避免使用虛函數(shù)作為庫的接口》),這也讓編寫單元變得困難,因為總不能自己挨個 wrapper 一遍吧?難道用 link-time 的注入技術(shù)?

  • 某些 failure 場景難以測試,而考察這些場景對編寫穩(wěn)定的分布式系統(tǒng)有重要作用。比方說:網(wǎng)絡(luò)連不上、數(shù)據(jù)庫超時、系統(tǒng)資源不足。
  • 對多線程程序無能為力。如果一個程序的功能涉及多個線程合作,那么就比較難用單元測試來驗證其正確性。
  • 如果程序涉及比較多的交互(指和其他程序交互,不是指圖形用戶界面),用單元測試來構(gòu)造測試場景比較麻煩,每個場景要寫一堆無趣的代碼。而這正是分布式系統(tǒng)最需要測試的地方。

總的來說,單元測試是一個值得掌握的技術(shù),用在適當(dāng)?shù)牡胤酱_實能提高生產(chǎn)力。同時,在分布式系統(tǒng)中,我們還需要其他的自動化測試手段。

分布式系統(tǒng)測試的要點

在分布式系統(tǒng)中,class 與 function 級別的單元測試對整個系統(tǒng)的幫助不大,當(dāng)然,這種單元測試對單個程序的質(zhì)量有幫助;但是,一堆磚頭壘在一起是變不成大樓的。

分布式系統(tǒng)測試的要點是測試進程間的交互:一個進程收到客戶請求,該如何處理,然后轉(zhuǎn)發(fā)給其他進程;收到響應(yīng)之后,又修改并應(yīng)答客戶。測試這些多進程協(xié)作的場景才算測到了點子上。

假設(shè)一個分布式系統(tǒng)由四五種進程組成,每個程序有各自的開發(fā)人員。對于整個系統(tǒng),我們可以用腳本來模擬客戶,自動化地測試系統(tǒng)的整體運作情況,這種測試通常由 QA 團隊來執(zhí)行,也可以作為系統(tǒng)的冒煙測試。

對于其中每個程序的開發(fā)人員,上述測試方法對日常的開發(fā)幫助不大,因為測試要能通過必須整個系統(tǒng)都正常運轉(zhuǎn)才行,在開發(fā)階段,這一點不是時時刻刻都能滿足(有可能你用到的新功能對方還沒有實現(xiàn),這反過來影響了你的進度)。另一方面,如果出現(xiàn)測試失敗,開發(fā)人員不能立刻知道這是自己的程序出錯,有可能是環(huán)境原因造成的錯誤,這通常要去讀程序日志才能判定。還有,作為開發(fā)者測試,我們希望它無副作用,每天反復(fù)多次運行也不會增加整個環(huán)境的負擔(dān),以整個 QA 系統(tǒng)為測試平臺不可避免要留下一些垃圾數(shù)據(jù),而清理這些數(shù)據(jù)又會花一些寶貴的工作時間。(你得判斷數(shù)據(jù)是自己的測試生成的還是別人的測試留下的,不能誤刪了別人的測試數(shù)據(jù)。)

作為開發(fā)人員,我們需要一種單獨針對自己編寫的那個程序的自動化測試方案,一方面提高日常開發(fā)的效率,另一方面作為自己那個程序的功能驗證測試集(即回歸測試/regression tests)。

 

分布式系統(tǒng)的抽象觀點

 

一臺機器兩根線

形象地來看,一個分布式系統(tǒng)就是一堆機器,每臺機器的屁股上拖著兩根線:電源線網(wǎng)線(不考慮 SAN 等存儲設(shè)備),電源線插到電源插座上,網(wǎng)線插到交換機上。

 

harness_net_power

這個模型實際上說明,一臺機器的表現(xiàn)出來的行為完全由它接出來的兩根線展現(xiàn),今天不談電源線,只談網(wǎng)線。(“在乎服務(wù)器的功耗”在我看來就是公司利潤率很低的標(biāo)志,要從電費上摳成本。)

如果網(wǎng)絡(luò)是普通的千兆以太網(wǎng),那么吞吐量不大于 125MB/s。這個吞吐量比起現(xiàn)在的 CPU 運算速度和內(nèi)存帶寬簡直小得可憐。這里我想提的是,對于不特別在意 latency 的應(yīng)用,只要能讓千兆以太網(wǎng)的吞吐量飽和或接近飽和,用什么編程語言其實無所謂。Java 做網(wǎng)絡(luò)服務(wù)端開發(fā)也是很好的選擇(不是指 web 開發(fā),而是做一些基礎(chǔ)的分布式組件,例如 ZooKeeper 和 Hadoop 之類)。盡管可能 C++ 只用了 15% 的 CPU,而 Java 用了 30% 的 CPU,Java 還占用更多的內(nèi)存,但是千兆網(wǎng)卡帶寬都已經(jīng)跑滿,那些省下在資源也只能浪費了;對于外界(從網(wǎng)線上看過來)而言,兩種語言的效果是一樣的,而通常 Java 的開發(fā)效率更高。(Java 是比 C++ 慢一些,但是透過千兆網(wǎng)絡(luò)不一定還能看得出這個區(qū)別來。)

進程間通過 TCP 相互連接

陳碩在《多線程服務(wù)器的常用編程模型》第 5 節(jié)“進程間通信”中提倡僅使用 TCP 作為進程間通信的手段,今天這個觀點將再次得到驗證。

以下是 Hadoop 的分布式文件系統(tǒng) HDFS 的架構(gòu)簡圖。

harness_system

HDFS 有四個角色參與其中,NameNode(保存元數(shù)據(jù))、DataNode(存儲節(jié)點,多個)、Secondary NameNode(定期寫 check point)、Client(客戶,系統(tǒng)的使用者)。這些進程運行在多臺機器上,之間通過 TCP 協(xié)議互聯(lián)。程序的行為完全由它在 TCP 連接上的表現(xiàn)決定(TCP 就好比前面提到的“網(wǎng)線”)。

在這個系統(tǒng)中,一個程序其實不知道與自己打交道的到底是什么。比如,對于 DataNode,它其實不在乎自己連接的是真的 NameNode 還是某個調(diào)皮的小孩用 Telnet 模擬的 NameNode,它只管接受命令并執(zhí)行。對于 NameNode,它其實也不知道 DataNode 是不是真的把用戶數(shù)據(jù)存到磁盤上去了,它只需要根據(jù) DataNode 的反饋更新自己的元數(shù)據(jù)就行。這已經(jīng)為我們指明了方向。

一種自動化的回歸測試方案

假如我是 NameNode 的開發(fā)者,為了能自動化測試 NameNode,我可以為它寫一個 test harness (這是一個獨立的進程),這個 test harness 仿冒(mock)了與被測進程打交道的全部程序。如下圖所示,是不是有點像“缸中之腦”?

harness_namenode

對于 DataNode 的開發(fā)者,他們也可以寫一個專門的 test harness,模擬 Client 和 NameNode。

harness_datanode

Test harness 的優(yōu)點

  • 完全從外部觀察被測程序,對被測程序沒有侵入性,代碼該怎么寫就怎么寫,不需要為測試留路。
  • 能測試真實環(huán)境下的表現(xiàn),程序不是單獨為測試編譯的版本,而是將來真實運行的版本。數(shù)據(jù)也是從網(wǎng)絡(luò)上讀取,發(fā)送到網(wǎng)絡(luò)上。
  • 允許被測程序做大的重構(gòu),以優(yōu)化內(nèi)部代碼結(jié)構(gòu),只要其表現(xiàn)出來的行為不變,測試就不會失敗。(在重構(gòu)期間不用修改 test case。)
  • 能比較方便地測試 failure 場景。比如,若要測試 DataNode 出錯時 NameNode 的反應(yīng),只要讓 test harness 模擬的那個 mock DataNode 返回我們想要的出錯信息。要測試 NameNode 在某個 DataNode 失效之后的反應(yīng),只要讓 test harness 斷開對應(yīng)的網(wǎng)絡(luò)連接即可。要測量某請求超時的反應(yīng),只要讓 Test harness 不返回結(jié)果即可。這對構(gòu)建可靠的分布式系統(tǒng)尤為重要。
  • 幫助開發(fā)人員從使用者的角度理解程序,程序的哪些行為在外部是看得到的,哪些行為是看不到的。
  • 有了一套比較完整的 test cases 之后,甚至可以換種語言重寫被測程序(假設(shè)為了提高內(nèi)存利用率,換用 C++ 來重新實現(xiàn) NameNode),測試用例依舊可用。這時 test harness 起到知識傳承的作用。
  • 發(fā)現(xiàn) bug 之后,往 test harness 里添加能復(fù)現(xiàn) bug 的 test case,修復(fù) bug 之后,test case 繼續(xù)留在 harness 中,反正出現(xiàn)回歸(regression)。

實現(xiàn)要點

  • Test harness 的要點在于隔斷被測程序與其他程序的聯(lián)系,它冒充了全部其他程序。這樣被測程序就像被放到測試臺上觀察一樣,讓我們只關(guān)注它一個。
  • Test harness 要能發(fā)起或接受多個 TCP 連接,可能需要用某個現(xiàn)成的 NIO 網(wǎng)絡(luò)庫,如果不想寫成多線程程序的話。
  • Test harness 可以與被測程序運行在同一臺機器,也可以運行在兩臺機器上。在運行被測程序的時候,可能要用一個特殊的啟動腳本把它依賴的 host:port 指向 test harness。
  • Test harness 只需要表現(xiàn)得跟它要 mock 的程序一樣,不需要真的去實現(xiàn)復(fù)雜的邏輯。比如 mock DataNode 只需要對 NameNode 返回“Yes sir, 數(shù)據(jù)已存好”,而不需要真的把數(shù)據(jù)存到硬盤上。若要 mock 比較復(fù)雜的邏輯,可以用“記錄+回放”的方式,把預(yù)設(shè)的響應(yīng)放到 test case 里回放(replay)給被測程序。
  • 因為通信走 TCP 協(xié)議,test harness 不一定要和被測程序用相同的語言,只要符合協(xié)議就行。試想如果用共享內(nèi)存實現(xiàn) IPC,這是不可能的。陳碩在《在 muduo 中實現(xiàn) protobuf 編解碼器與消息分發(fā)器》中提到利用 protobuf 的跨語言特性,我們可以采用 Java 為 C++ 服務(wù)程序編寫 test harness。其他跨語言的協(xié)議格式也行,比如 XML 或 Json。
  • Test harness 運行起來之后,等待被測程序的連接,或者主動連接被測程序,或者兼而有之,取決于所用的通信方式。
  • 一切就緒之后,Test harness 依次執(zhí)行 test cases。一個 NameNode test case 的典型過程是:test harness 模仿 client 向被測 NameNode 發(fā)送一個請求(eg. 創(chuàng)建文件),NameNode 可能會聯(lián)絡(luò) mock DataNode,test harness 模仿 DataNode 應(yīng)有的響應(yīng),NameNode 收到 mock DataNode 的反饋之后發(fā)送響應(yīng)給 client,這時 test harness 檢查響應(yīng)是否符合預(yù)期。
  • Test harness 中的 test cases 以配置文件(每個 test case 有一個或多個文本配置文件,每個 test case 占一個目錄)方式指定。test harness 和 test cases 連同程序代碼一起用 version controlling 工具管理起來。這樣能復(fù)現(xiàn)以外任何一個版本的應(yīng)有行為。
  • 對于比較復(fù)雜的 test case,可以用嵌入式腳本語言來描述場景。如果 test harness 是 Java 寫的,那么可以嵌入 Groovy,就像陳碩在《“過家家”版的移動離線計費系統(tǒng)實現(xiàn)》中用 Groovy 實現(xiàn)計費邏輯一樣。Groovy 調(diào)用 test harness 模擬多個程序分別發(fā)送多份數(shù)據(jù)并驗證結(jié)果,groovy 本身就是程序代碼,可以有邏輯判斷甚至循環(huán)。這種動靜結(jié)合的做法在不增加 test harness 復(fù)雜度的情況下提供了相當(dāng)高的靈活性。
  • Test harness 可以有一個命令行界面,程序員輸入“run 10”就選擇執(zhí)行第 10 號 test case。

幾個實例

Test harness 這種測試方法適合測試有狀態(tài)的、與多個進程通信的分布式程序,除了 Hadoop 中的 NameNode 與 DataNode,我還能想到幾個例子。

1. chat 聊天服務(wù)器

聊天服務(wù)器會與多個客戶端打交道,我們可以用 test harness 模擬 5 個客戶端,模擬用戶上下線,發(fā)送消息等情況,自動檢測聊天服務(wù)器的工作情況。

2. 連接服務(wù)器、登錄服務(wù)器、邏輯服務(wù)器

這是云風(fēng)在他的 blog 中提到的三種網(wǎng)游服務(wù)器(http://blog.codingnow.com/2007/02/user_authenticate.htmlhttp://blog.codingnow.com/2006/04/iocp_kqueue_epoll.htmlhttp://blog.codingnow.com/2010/11/go_prime.html),我這里借用來舉例子。

如果要為連接服務(wù)器寫 test harness,那么需要模擬客戶(發(fā)起連接)、登錄服務(wù)器(驗證客戶資料)、邏輯服務(wù)器(收發(fā)網(wǎng)游數(shù)據(jù)),有了這樣的 test harness,可以方便地測試連接服務(wù)器的正確性,也可以方便地模擬其他各個服務(wù)器斷開連接的情況,看看連接服務(wù)器是否應(yīng)對自如。

同樣的思路,可以為登錄服務(wù)器寫 test harness。(我估計不用為邏輯服務(wù)器再寫了,因為肯定已經(jīng)有自動測試了。)

3. 多 master 之間的二段提交

這是分布式容錯的一個經(jīng)典做法。用 test harness 能把 primary master  和 secondary masters 單獨拎出來測試。在測試 primary master 的時候,test harness 扮演 name service 和 secondary masters。在測試 secondary master 的時候,test harness 扮演 name service、primary master、其他 secondary masters。可以比較容易地測試各種 failure 情況。如果不這么做,而直接部署多個 masters 來測試,恐怕很難做到自動化測試。

4. paxos 的實現(xiàn)

Paxos 協(xié)議的實現(xiàn)肯定離不了單元測試,因為涉及多個角色中比較復(fù)雜的狀態(tài)變遷。同時,如果我要寫 paxos 實現(xiàn),那么 test harness 也是少不了的,它能自動測試 paxos 節(jié)點在真實網(wǎng)絡(luò)環(huán)境下的表現(xiàn),并且輕易模擬各種 failure 場景。

局限性

如果被測程序有 TCP 之外的 IO,或者其 TCP 協(xié)議不易模擬(比如通過 TCP 連接數(shù)據(jù)庫),那么這種測試方案會受到干擾。

對于數(shù)據(jù)庫,如果被測程序只是簡單的從數(shù)據(jù)庫 select 一些配置信息,那么或許可以在 test harness 里內(nèi)嵌一個 in-memory H2 DB engine,然后讓被測程序從這里讀取數(shù)據(jù)。當(dāng)然,前提是被測程序的 DB driver 能連上 H2 (或許不是大問題,H2 支持 JDBC 和 部分 ODBC)。如果被測程序有比較復(fù)雜的 SQL 代碼,那么 H2 表現(xiàn)的行為不一定和生產(chǎn)環(huán)境的數(shù)據(jù)庫一致,這時候恐怕還是要部署測試數(shù)據(jù)庫(有可能為每個開發(fā)人員部署一個小的測試數(shù)據(jù)庫,以免相互干擾)。

如果被測程序有其他 IO (寫 log 不算),比如 DataNode 會訪問文件系統(tǒng),那么 test harness 沒有能把 DataNode 完整地包裹起來,有些 failure cases 不是那么容易測試。這是或許可以把 DataNode 指向 tmpfs,這樣能比較容易測試磁盤滿的情況。當(dāng)然,這樣也有局限性,因為 tmpfs 沒有真實磁盤那么大,也不能模擬磁盤讀寫錯誤。我不是分布式存儲方面的專家,這些問題留給分布式文件系統(tǒng)的實現(xiàn)者去考慮吧。(測試 paxos 節(jié)點似乎也可以用 tmpfs 來模擬 persist storage,由 test case 填充所需的初始數(shù)據(jù)。)

其他用處

Test harness 除了實現(xiàn) features 的回歸測試,它還有別的用處。

  • 加速開發(fā),提高生產(chǎn)力。

前面提到,如果有個新功能(增加一種新的 request type)需要改動兩個程序,有可能造成相互等待:客戶程序 A 說要先等服務(wù)程序 B 實現(xiàn)對應(yīng)的功能響應(yīng),這樣 A 才能發(fā)送新的請求,不然每次請求就會被拒絕,無法測試;服務(wù)程序 B 說要先等 A 能夠發(fā)送新的請求,這樣自己才能開始編碼與測試,不然都不知道請求長什么樣子,也觸發(fā)不了新寫的代碼。(當(dāng)然,這是我虛構(gòu)的例子。)

如果 A 和 B 都有各自的 test harness,事情就好辦了,雙方大致商量一個協(xié)議格式,然后分頭編碼。程序 A 的作者在自己的 harness 里邊添加一個 test case,模擬他認為 B 應(yīng)有的響應(yīng),這個響應(yīng)可以 hard code 某種最常見的響應(yīng),不必真的實現(xiàn)所需的判斷邏輯(畢竟這是程序 B 的作者該干的事情),然后程序 A 的作者就可以編碼并測試自己的程序了。同理,程序 B 的作者也不用等 A 拿出一個半成品來發(fā)送新請求,他往自己的 harness 添加一個 test case,模擬他認為 A 應(yīng)該發(fā)送的請求,然后就可以編碼并測試自己的新功能。雙方齊頭并進,減少扯皮。等功能實現(xiàn)得差不多了,兩個程序互相連一連,如果發(fā)現(xiàn)協(xié)議有不一致,檢查一下 harness 中的新 test cases(這代表了 A/B 程序?qū)Ψ降念A(yù)期),看看那邊改動比較方便,很快就能解決問題。

  • 壓力測試。

Test harness 稍作改進還可以變功能測試為壓力測試,供程序員 profiling 用。比如反復(fù)不間斷發(fā)送請求,向被測程序加壓。不過,如果被測程序是 C++ 寫的,而 test harness 是 Java 寫的,有可能出現(xiàn) test harness 占 100% CPU,而被測程序還跑得優(yōu)哉游哉的情況。這時候可以單獨用 C++ 寫一個負載生成器。

小結(jié)

以單獨的進程作為 test harness 對于開發(fā)分布式程序相當(dāng)有幫助,它能達到單元測試的自動化程度和細致程度,又避免了單元測試對功能代碼結(jié)構(gòu)的侵入與依賴。

posted @ 2011-04-25 00:27 陳碩 閱讀(1018) | 評論 (0)編輯 收藏

Muduo 網(wǎng)絡(luò)編程示例之五: 測量兩臺機器的網(wǎng)絡(luò)延遲

Muduo 網(wǎng)絡(luò)編程示例之五: 測量兩臺機器的網(wǎng)絡(luò)延遲

陳碩 (giantchen_AT_gmail)

Blog.csdn.net/Solstice  t.sina.com.cn/giantchen

這是《Muduo 網(wǎng)絡(luò)編程示例》系列的第五篇文章。

Muduo 全系列文章列表: http://blog.csdn.net/Solstice/category/779646.aspx

 

本文介紹一個簡單的網(wǎng)絡(luò)程序 roundtrip,用于測量兩臺機器之間的網(wǎng)絡(luò)延遲,即“往返時間 / round trip time / RTT”。這篇文章主要考察定長 TCP 消息的分包,TCP_NODELAY 的作用。

本文的代碼見 http://code.google.com/p/muduo/source/browse/trunk/examples/roundtrip/roundtrip.cc

測量 RTT 的辦法很簡單:

  • host A 發(fā)一條消息給 host B,其中包含 host A 發(fā)送消息的本地時間
  • host B 收到之后立刻把消息 echo 回 host A
  • host A 收到消息之后,用當(dāng)前時間減去消息中的時間就得到了 RTT。

NTP 協(xié)議的工作原理與之類似,不過,除了測量 RTT,NTP 還需要知道兩臺機器之間的時間差 (clock offset),這樣才能校準(zhǔn)時間。

roundtrip_ntp

以上是 NTP 協(xié)議收發(fā)消息的協(xié)議,RTT = (T4-T1) – (T3-T2),時間差 = ((T4+T1)-(T2+T3))/2。NTP 的要求是往返路徑上的單程延遲要盡量相等,這樣才能減少系統(tǒng)誤差。偶然誤差由單程延遲的不確定性決定。

在我設(shè)計的 roundtrip 示例程序中,協(xié)議有所簡化:

roundtrip_simple

簡化之后的協(xié)議少取一次時間,因為 server 收到消息之后立刻發(fā)送回 client,耗時很少(若干微秒),基本不影響最終結(jié)果。

我設(shè)計的消息格式是 16 字節(jié)定長消息:

roundtrip_msg

T1 和 T2 都是 muduo::Timestamp,一個 int64_t,表示從 Epoch 到現(xiàn)在的微秒數(shù)。

為了讓消息的單程往返時間接近,server 和 client 發(fā)送的消息都是 16 bytes,這樣做到對稱。

由于是定長消息,可以不必使用 codec,在 message callback 中直接用

while (buffer->readableBytes() >= frameLen) { ... } 就能 decode。

請讀者思考,如果把 while 換成 if 會有什么后果?

 

client 程序以 200ms 為間隔發(fā)送消息,在收到消息之后打印 RTT 和 clock offset。一次運作實例如下:

roundtrip_example

這個例子中,client 和 server 的時鐘不是完全對準(zhǔn)的,server 的時間快了 850 us,用 roundtrip 程序能測量出這個時間差。有了這個時間差就能校正分布式系統(tǒng)中測量得到的消息延遲。

比方說以上圖為例,server 在它本地 1.235000 時刻發(fā)送了一條消息,client 在它本地 1.234300 收到這條消息,直接計算的話延遲是 –700us。這個結(jié)果肯定是錯的,因為 server 和 client 不在一個時鐘域(這是數(shù)字電路中的概念),它們的時間直接相減無意義。如果我們已經(jīng)測量得到 server 比 client 快 850us,那么做用這個數(shù)據(jù)一次校正: -700+850 = 150us,這個結(jié)果就比較符合實際了。當(dāng)然,在實際應(yīng)用中,clock offset 要經(jīng)過一個低通濾波才能使用,不然偶然性太大。

請讀者思考,為什么不能直接以 RTT/2 作為兩天機器之間收發(fā)消息的單程延遲?

這個程序在局域網(wǎng)中使用沒有問題,如果在廣域網(wǎng)上使用,而且 RTT 大于 200ms,那么受 Nagle 算法影響,測量結(jié)果是錯誤的(具體分析留作練習(xí),這能測試對 Nagle 的理解),這時候我們需要設(shè)置 TCP_NODELAY 參數(shù),讓程序在廣域網(wǎng)上也能正常工作。

posted @ 2011-04-20 09:26 陳碩 閱讀(3164) | 評論 (7)編輯 收藏

Muduo 設(shè)計與實現(xiàn)之一:Buffer 類的設(shè)計

陳碩 (giantchen_AT_gmail)

Blog.csdn.net/Solstice  t.sina.com.cn/giantchen

Muduo 全系列文章列表: http://blog.csdn.net/Solstice/category/779646.aspx

 

本文介紹 Muduo 中輸入輸出緩沖區(qū)的設(shè)計與實現(xiàn)。

本文中 buffer 指一般的應(yīng)用層緩沖區(qū)、緩沖技術(shù),Buffer 特指 muduo::net::Buffer class。

本文前兩節(jié)的內(nèi)容已事先發(fā)表在 muduo 英文博客 http://muduo.chenshuo.com/2011/04/essentials-of-non-blocking-tcp-network.html

Muduo 的 IO 模型

UNPv1 第 6.2 節(jié)總結(jié)了 Unix/Linux 上的五種 IO 模型:阻塞(blocking)、非阻塞(non-blocking)、IO 復(fù)用(IO multiplexing)、信號驅(qū)動(signal-driven)、異步(asynchronous)。這些都是單線程下的 IO 模型。

C10k 問題的頁面介紹了五種 IO 策略,把線程也納入考量。(現(xiàn)在 C10k 已經(jīng)不是什么問題,C100k 也不是大問題,C1000k 才算得上挑戰(zhàn))。

在這個多核時代,線程是不可避免的。那么服務(wù)端網(wǎng)絡(luò)編程該如何選擇線程模型呢?我贊同 libev 作者的觀點:one loop per thread is usually a good model。之前我也不止一次表述過這個觀點,見《多線程服務(wù)器的常用編程模型》《多線程服務(wù)器的適用場合》。

如果采用 one loop per thread 的模型,多線程服務(wù)端編程的問題就簡化為如何設(shè)計一個高效且易于使用的 event loop,然后每個線程 run 一個 event loop 就行了(當(dāng)然、同步和互斥是不可或缺的)。在“高效”這方面已經(jīng)有了很多成熟的范例(libev、libevent、memcached、varnish、lighttpd、nginx),在“易于使用”方面我希望 muduo 能有所作為。(muduo 可算是用現(xiàn)代 C++ 實現(xiàn)了 Reactor 模式,比起原始的 Reactor 來說要好用得多。)

event loop 是 non-blocking 網(wǎng)絡(luò)編程的核心,在現(xiàn)實生活中,non-blocking 幾乎總是和 IO-multiplexing 一起使用,原因有兩點:

  • 沒有人真的會用輪詢 (busy-pooling) 來檢查某個 non-blocking IO 操作是否完成,這樣太浪費 CPU cycles。
  • IO-multiplex 一般不能和 blocking IO 用在一起,因為 blocking IO 中 read()/write()/accept()/connect() 都有可能阻塞當(dāng)前線程,這樣線程就沒辦法處理其他 socket 上的 IO 事件了。見 UNPv1 第 16.6 節(jié)“nonblocking accept”的例子。

所以,當(dāng)我提到 non-blocking 的時候,實際上指的是 non-blocking + IO-muleiplexing,單用其中任何一個是不現(xiàn)實的。另外,本文所有的“連接”均指 TCP 連接,socket 和 connection 在文中可互換使用。

當(dāng)然,non-blocking 編程比 blocking 難得多,見陳碩在《Muduo 網(wǎng)絡(luò)編程示例之零:前言》中“TCP 網(wǎng)絡(luò)編程本質(zhì)論”一節(jié)列舉的難點。基于 event loop 的網(wǎng)絡(luò)編程跟直接用 C/C++ 編寫單線程 Windows 程序頗為相像:程序不能阻塞,否則窗口就失去響應(yīng)了;在 event handler 中,程序要盡快交出控制權(quán),返回窗口的事件循環(huán)。

為什么 non-blocking 網(wǎng)絡(luò)編程中應(yīng)用層 buffer 是必須的?

Non-blocking IO 的核心思想是避免阻塞在 read() 或 write() 或其他 IO 系統(tǒng)調(diào)用上,這樣可以最大限度地復(fù)用 thread-of-control,讓一個線程能服務(wù)于多個 socket 連接。IO 線程只能阻塞在 IO-multiplexing 函數(shù)上,如 select()/poll()/epoll_wait()。這樣一來,應(yīng)用層的緩沖是必須的,每個 TCP socket 都要有 stateful 的 input buffer 和 output buffer。

TcpConnection 必須要有 output buffer

考慮一個常見場景:程序想通過 TCP 連接發(fā)送 100k 字節(jié)的數(shù)據(jù),但是在 write() 調(diào)用中,操作系統(tǒng)只接受了 80k 字節(jié)(受 TCP advertised window 的控制,細節(jié)見 TCPv1),你肯定不想在原地等待,因為不知道會等多久(取決于對方什么時候接受數(shù)據(jù),然后滑動 TCP 窗口)。程序應(yīng)該盡快交出控制權(quán),返回 event loop。在這種情況下,剩余的 20k 字節(jié)數(shù)據(jù)怎么辦?

對于應(yīng)用程序而言,它只管生成數(shù)據(jù),它不應(yīng)該關(guān)心到底數(shù)據(jù)是一次性發(fā)送還是分成幾次發(fā)送,這些應(yīng)該由網(wǎng)絡(luò)庫來操心,程序只要調(diào)用 TcpConnection::send() 就行了,網(wǎng)絡(luò)庫會負責(zé)到底。網(wǎng)絡(luò)庫應(yīng)該接管這剩余的 20k 字節(jié)數(shù)據(jù),把它保存在該 TCP connection 的 output buffer 里,然后注冊 POLLOUT 事件,一旦 socket 變得可寫就立刻發(fā)送數(shù)據(jù)。當(dāng)然,這第二次 write() 也不一定能完全寫入 20k 字節(jié),如果還有剩余,網(wǎng)絡(luò)庫應(yīng)該繼續(xù)關(guān)注 POLLOUT 事件;如果寫完了 20k 字節(jié),網(wǎng)絡(luò)庫應(yīng)該停止關(guān)注 POLLOUT,以免造成 busy loop。(Muduo EventLoop 采用的是 epoll level trigger,這么做的具體原因我以后再說。)

如果程序又寫入了 50k 字節(jié),而這時候 output buffer 里還有待發(fā)送的 20k 數(shù)據(jù),那么網(wǎng)絡(luò)庫不應(yīng)該直接調(diào)用 write(),而應(yīng)該把這 50k 數(shù)據(jù) append 在那 20k 數(shù)據(jù)之后,等 socket 變得可寫的時候再一并寫入。

如果 output buffer 里還有待發(fā)送的數(shù)據(jù),而程序又想關(guān)閉連接(對程序而言,調(diào)用 TcpConnection::send() 之后他就認為數(shù)據(jù)遲早會發(fā)出去),那么這時候網(wǎng)絡(luò)庫不能立刻關(guān)閉連接,而要等數(shù)據(jù)發(fā)送完畢,見我在《為什么 muduo 的 shutdown() 沒有直接關(guān)閉 TCP 連接?》一文中的講解。

綜上,要讓程序在 write 操作上不阻塞,網(wǎng)絡(luò)庫必須要給每個 tcp connection 配置 output buffer。

TcpConnection 必須要有 input buffer

TCP 是一個無邊界的字節(jié)流協(xié)議,接收方必須要處理“收到的數(shù)據(jù)尚不構(gòu)成一條完整的消息”和“一次收到兩條消息的數(shù)據(jù)”等等情況。一個常見的場景是,發(fā)送方 send 了兩條 10k 字節(jié)的消息(共 20k),接收方收到數(shù)據(jù)的情況可能是:

  • 一次性收到 20k 數(shù)據(jù)
  • 分兩次收到,第一次 5k,第二次 15k
  • 分兩次收到,第一次 15k,第二次 5k
  • 分兩次收到,第一次 10k,第二次 10k
  • 分三次收到,第一次 6k,第二次 8k,第三次 6k
  • 其他任何可能

網(wǎng)絡(luò)庫在處理“socket 可讀”事件的時候,必須一次性把 socket 里的數(shù)據(jù)讀完(從操作系統(tǒng) buffer 搬到應(yīng)用層 buffer),否則會反復(fù)觸發(fā) POLLIN 事件,造成 busy-loop。(Again, Muduo EventLoop 采用的是 epoll level trigger,這么做的具體原因我以后再說。)

那么網(wǎng)絡(luò)庫必然要應(yīng)對“數(shù)據(jù)不完整”的情況,收到的數(shù)據(jù)先放到 input buffer 里,等構(gòu)成一條完整的消息再通知程序的業(yè)務(wù)邏輯。這通常是 codec 的職責(zé),見陳碩《Muduo 網(wǎng)絡(luò)編程示例之二:Boost.Asio 的聊天服務(wù)器》一文中的“TCP 分包”的論述與代碼。

所以,在 tcp 網(wǎng)絡(luò)編程中,網(wǎng)絡(luò)庫必須要給每個 tcp connection 配置 input buffer。

 

所有 muduo 中的 IO 都是帶緩沖的 IO (buffered IO),你不會自己去 read() 或 write() 某個 socket,只會操作 TcpConnection 的 input buffer 和 output buffer。更確切的說,是在 onMessage() 回調(diào)里讀取 input buffer;調(diào)用 TcpConnection::send() 來間接操作 output buffer,一般不會直接操作 output buffer。

btw, muduo 的 onMessage() 的原型如下,它既可以是 free function,也可以是 member function,反正 muduo TcpConnection 只認 boost::function<>。

void onMessage(const TcpConnectionPtr& conn, Buffer* buf, Timestamp receiveTime);

對于網(wǎng)絡(luò)程序來說,一個簡單的驗收測試是:輸入數(shù)據(jù)每次收到一個字節(jié)(200 字節(jié)的輸入數(shù)據(jù)會分 200 次收到,每次間隔 10 ms),程序的功能不受影響。對于 Muduo 程序,通常可以用 codec 來分離“消息接收”與“消息處理”,見陳碩《在 muduo 中實現(xiàn) protobuf 編解碼器與消息分發(fā)器》一文中對“編解碼器 codec”的介紹。

如果某個網(wǎng)絡(luò)庫只提供相當(dāng)于 char buf[8192] 的緩沖,或者根本不提供緩沖區(qū),而僅僅通知程序“某 socket 可讀/某 socket 可寫”,要程序自己操心 IO buffering,這樣的網(wǎng)絡(luò)庫用起來就很不方便了。(我有所指,你懂得。)

 

Buffer 的要求

http://code.google.com/p/muduo/source/browse/trunk/muduo/net/Buffer.h

Muduo Buffer 的設(shè)計考慮了常見的網(wǎng)絡(luò)編程需求,我試圖在易用性和性能之間找一個平衡點,目前這個平衡點更偏向于易用性。

Muduo Buffer 的設(shè)計要點:

  • 對外表現(xiàn)為一塊連續(xù)的內(nèi)存(char*, len),以方便客戶代碼的編寫。
  • 其 size() 可以自動增長,以適應(yīng)不同大小的消息。它不是一個 fixed size array (即 char buf[8192])。
  • 內(nèi)部以 vector of char 來保存數(shù)據(jù),并提供相應(yīng)的訪問函數(shù)。

Buffer 其實像是一個 queue,從末尾寫入數(shù)據(jù),從頭部讀出數(shù)據(jù)。

誰會用 Buffer?誰寫誰讀?根據(jù)前文分析,TcpConnection 會有兩個 Buffer 成員,input buffer 與 output buffer。

  • input buffer,TcpConnection 會從 socket 讀取數(shù)據(jù),然后寫入 input buffer(其實這一步是用 Buffer::readFd() 完成的);客戶代碼從 input buffer 讀取數(shù)據(jù)。
  • output buffer,客戶代碼會把數(shù)據(jù)寫入 output buffer(其實這一步是用 TcpConnection::send() 完成的);TcpConnection 從 output buffer 讀取數(shù)據(jù)并寫入 socket。

其實,input 和 output 是針對客戶代碼而言,客戶代碼從 input 讀,往 output 寫。TcpConnection 的讀寫正好相反。

以下是 muduo::net::Buffer 的類圖。請注意,為了后面畫圖方便,這個類圖跟實際代碼略有出入,但不影響我要表達的觀點。

bc

這里不介紹每個成員函數(shù)的作用,留給《Muduo 網(wǎng)絡(luò)編程示例》系列。下文會仔細介紹 readIndex 和 writeIndex 的作用。

Buffer::readFd()

我在《Muduo 網(wǎng)絡(luò)編程示例之零:前言》中寫道

  • 在非阻塞網(wǎng)絡(luò)編程中,如何設(shè)計并使用緩沖區(qū)?一方面我們希望減少系統(tǒng)調(diào)用,一次讀的數(shù)據(jù)越多越劃算,那么似乎應(yīng)該準(zhǔn)備一個大的緩沖區(qū)。另一方面,我們系統(tǒng)減少內(nèi)存占用。如果有 10k 個連接,每個連接一建立就分配 64k 的讀緩沖的話,將占用 640M 內(nèi)存,而大多數(shù)時候這些緩沖區(qū)的使用率很低。muduo 用 readv 結(jié)合棧上空間巧妙地解決了這個問題。

具體做法是,在棧上準(zhǔn)備一個 65536 字節(jié)的 stackbuf,然后利用 readv() 來讀取數(shù)據(jù),iovec 有兩塊,第一塊指向 muduo Buffer 中的 writable 字節(jié),另一塊指向棧上的 stackbuf。這樣如果讀入的數(shù)據(jù)不多,那么全部都讀到 Buffer 中去了;如果長度超過 Buffer 的 writable 字節(jié)數(shù),就會讀到棧上的 stackbuf 里,然后程序再把 stackbuf 里的數(shù)據(jù) append 到 Buffer 中。

代碼見 http://code.google.com/p/muduo/source/browse/trunk/muduo/net/Buffer.cc#36

這么做利用了臨時棧上空間,避免開巨大 Buffer 造成的內(nèi)存浪費,也避免反復(fù)調(diào)用 read() 的系統(tǒng)開銷(通常一次 readv() 系統(tǒng)調(diào)用就能讀完全部數(shù)據(jù))。

這算是一個小小的創(chuàng)新吧。

線程安全?

muduo::net::Buffer 不是線程安全的,這么做是有意的,原因如下:

  • 對于 input buffer,onMessage() 回調(diào)始終發(fā)生在該 TcpConnection 所屬的那個 IO 線程,應(yīng)用程序應(yīng)該在 onMessage() 完成對 input buffer 的操作,并且不要把 input buffer 暴露給其他線程。這樣所有對 input buffer 的操作都在同一個線程,Buffer class 不必是線程安全的。
  • 對于 output buffer,應(yīng)用程序不會直接操作它,而是調(diào)用 TcpConnection::send() 來發(fā)送數(shù)據(jù),后者是線程安全的。

如果 TcpConnection::send() 調(diào)用發(fā)生在該 TcpConnection 所屬的那個 IO 線程,那么它會轉(zhuǎn)而調(diào)用 TcpConnection::sendInLoop(),sendInLoop() 會在當(dāng)前線程(也就是 IO 線程)操作 output buffer;如果 TcpConnection::send() 調(diào)用發(fā)生在別的線程,它不會在當(dāng)前線程調(diào)用 sendInLoop() ,而是通過 EventLoop::runInLoop() 把 sendInLoop() 函數(shù)調(diào)用轉(zhuǎn)移到 IO 線程(聽上去頗為神奇?),這樣 sendInLoop() 還是會在 IO 線程操作 output buffer,不會有線程安全問題。當(dāng)然,跨線程的函數(shù)轉(zhuǎn)移調(diào)用涉及函數(shù)參數(shù)的跨線程傳遞,一種簡單的做法是把數(shù)據(jù)拷一份,絕對安全(不明白的同學(xué)請閱讀代碼)。

另一種更為高效做法是用 swap()。這就是為什么 TcpConnection::send() 的某個重載以 Buffer* 為參數(shù),而不是 const Buffer&,這樣可以避免拷貝,而用 Buffer::swap() 實現(xiàn)高效的線程間數(shù)據(jù)轉(zhuǎn)移。(最后這點,僅為設(shè)想,暫未實現(xiàn)。目前仍然以數(shù)據(jù)拷貝方式在線程間傳遞,略微有些性能損失。)

 

Muduo Buffer 的數(shù)據(jù)結(jié)構(gòu)

Buffer 的內(nèi)部是一個 vector of char,它是一塊連續(xù)的內(nèi)存。此外,Buffer 有兩個 data members,指向該 vector 中的元素。這兩個 indices 的類型是 int,不是 char*,目的是應(yīng)對迭代器失效。muduo Buffer 的設(shè)計參考了 Netty 的 ChannelBuffer 和 libevent 1.4.x 的 evbuffer。不過,其 prependable 可算是一點“微創(chuàng)新”。

Muduo Buffer 的數(shù)據(jù)結(jié)構(gòu)如下:

圖 1buffer0

兩個 indices 把 vector 的內(nèi)容分為三塊:prependable、readable、writable,各塊的大小是(公式一):

prependable = readIndex

readable = writeIndex - readIndex

writable = size() - writeIndex

(prependable 的作用留到后面討論。)

readIndex 和 writeIndex 滿足以下不變式(invariant):

0 ≤ readIndex ≤ writeIndex ≤ data.size()

Muduo Buffer 里有兩個常數(shù) kCheapPrepend 和 kInitialSize,定義了 prependable 的初始大小和 writable 的初始大小。(readable 的初始大小為 0。)在初始化之后,Buffer 的數(shù)據(jù)結(jié)構(gòu)如下:括號里的數(shù)字是該變量或常量的值。

圖 2buffer1

根據(jù)以上(公式一)可算出各塊的大小,剛剛初始化的 Buffer 里沒有 payload 數(shù)據(jù),所以 readable == 0。

Muduo Buffer 的操作

1. 基本的 read-write cycle

Buffer 初始化后的情況見圖 1,如果有人向 Buffer 寫入了 200 字節(jié),那么其布局是:

圖 3buffer2

圖 3 中 writeIndex 向后移動了 200 字節(jié),readIndex 保持不變,readable 和 writable 的值也有變化。

 

如果有人從 Buffer read() & retrieve() (下稱“讀入”)了 50 字節(jié),結(jié)果見圖 4。與上圖相比,readIndex 向后移動 50 字節(jié),writeIndex 保持不變,readable 和 writable 的值也有變化(這句話往后從略)。

圖 4buffer3

 

然后又寫入了 200 字節(jié),writeIndex 向后移動了 200 字節(jié),readIndex 保持不變,見圖 5

圖 5buffer4

 

接下來,一次性讀入 350 字節(jié),請注意,由于全部數(shù)據(jù)讀完了,readIndex 和 writeIndex 返回原位以備新一輪使用,見圖 6,這和圖 2 是一樣的。

圖 6buffer5

 

以上過程可以看作是發(fā)送方發(fā)送了兩條消息,長度分別為 50 字節(jié)和 350 字節(jié),接收方分兩次收到數(shù)據(jù),每次 200 字節(jié),然后進行分包,再分兩次回調(diào)客戶代碼。

 

自動增長

Muduo Buffer 不是固定長度的,它可以自動增長,這是使用 vector 的直接好處。

假設(shè)當(dāng)前的狀態(tài)如圖 7 所示。(這和前面圖 5 是一樣的。)

圖 7buffer4

 

客戶代碼一次性寫入 1000 字節(jié),而當(dāng)前可寫的字節(jié)數(shù)只有 624,那么 buffer 會自動增長以容納全部數(shù)據(jù),得到的結(jié)果是圖 8。注意 readIndex 返回到了前面,以保持 prependable 等于 kCheapPrependable。由于 vector 重新分配了內(nèi)存,原來指向它元素的指針會失效,這就是為什么 readIndex 和 writeIndex 是整數(shù)下標(biāo)而不是指針。

圖 8buffer6

 

然后讀入 350 字節(jié),readIndex 前移,見圖 9

圖 9buffer7

 

最后,讀完剩下的 1000 字節(jié),readIndex 和 writeIndex 返回 kCheapPrependable,見圖 10。

圖 10buffer8

注意 buffer 并沒有縮小大小,下次寫入 1350 字節(jié)就不會重新分配內(nèi)存了。換句話說,Muduo Buffer 的 size() 是自適應(yīng)的,它一開始的初始值是 1k,如果程序里邊經(jīng)常收發(fā) 10k 的數(shù)據(jù),那么用幾次之后它的 size() 會自動增長到 10k,然后就保持不變。這樣一方面避免浪費內(nèi)存(有的程序可能只需要 4k 的緩沖),另一方面避免反復(fù)分配內(nèi)存。當(dāng)然,客戶代碼可以手動 shrink() buffer size()。

size() 與 capacity()

使用 vector 的另一個好處是它的 capcity() 機制減少了內(nèi)存分配的次數(shù)。比方說程序反復(fù)寫入 1 字節(jié),muduo Buffer 不會每次都分配內(nèi)存,vector 的 capacity() 以指數(shù)方式增長,讓 push_back() 的平均復(fù)雜度是常數(shù)。比方說經(jīng)過第一次增長,size() 剛好滿足寫入的需求,如圖 11。但這個時候 vector 的 capacity() 已經(jīng)大于 size(),在接下來寫入 capacity()-size() 字節(jié)的數(shù)據(jù)時,都不會重新分配內(nèi)存,見圖 12

圖 11buffer6

圖 12buffer9

 

細心的讀者可能會發(fā)現(xiàn)用 capacity() 也不是完美的,它有優(yōu)化的余地。具體來說,vector::resize() 會初始化(memset/bzero)內(nèi)存,而我們不需要它初始化,因為反正立刻就要填入數(shù)據(jù)。比如,在圖 12 的基礎(chǔ)上寫入 200 字節(jié),由于 capacity() 足夠大,不會重新分配內(nèi)存,這是好事;但是 vector::resize() 會先把那 200 字節(jié)設(shè)為 0 (圖 13),然后 muduo buffer 再填入數(shù)據(jù)(圖 14)。這么做稍微有點浪費,不過我不打算優(yōu)化它,除非它確實造成了性能瓶頸。(精通 STL 的讀者可能會說用 vector::append() 以避免浪費,但是 writeIndex 和 size() 不一定是對齊的,會有別的麻煩。)

 

圖 13buffer9a

圖 14buffer9b

google protobuf 中有一個 STLStringResizeUninitialized 函數(shù),干的就是這個事情。

內(nèi)部騰挪

有時候,經(jīng)過若干次讀寫,readIndex 移到了比較靠后的位置,留下了巨大的 prependable 空間,見圖 14

圖 14buffer10

 

這時候,如果我們想寫入 300 字節(jié),而 writable 只有 200 字節(jié),怎么辦?muduo Buffer 在這種情況下不會重新分配內(nèi)存,而是先把已有的數(shù)據(jù)移到前面去,騰出 writable 空間,見圖 15

圖 15buffer11

 

然后,就可以寫入 300 字節(jié)了,見圖 16

圖 16buffer12

這么做的原因是,如果重新分配內(nèi)存,反正也是要把數(shù)據(jù)拷到新分配的內(nèi)存區(qū)域,代價只會更大。

prepend

前面說 muduo Buffer 有個小小的創(chuàng)新(或許不是創(chuàng)新,我記得在哪兒看到過類似的做法,忘了出處),即提供 prependable 空間,讓程序能以很低的代價在數(shù)據(jù)前面添加幾個字節(jié)。

比方說,程序以固定的4個字節(jié)表示消息的長度(即《Muduo 網(wǎng)絡(luò)編程示例之二:Boost.Asio 的聊天服務(wù)器》中的 LengthHeaderCodec),我要序列化一個消息,但是不知道它有多長,那么我可以一直 append() 直到序列化完成(圖 17,寫入了 200 字節(jié)),然后再在序列化數(shù)據(jù)的前面添加消息的長度(圖 18,把 200 這個數(shù) prepend 到首部)。

 

圖 17buffer13

 

圖 18buffer14

通過預(yù)留 kCheapPrependable 空間,可以簡化客戶代碼,一個簡單的空間換時間思路。

其他設(shè)計方案

這里簡單談?wù)勂渌赡艿膽?yīng)用層 buffer 設(shè)計方案。

不用 vector<char>?

如果有 STL 潔癖,那么可以自己管理內(nèi)存,以 4 個指針為 buffer 的成員,數(shù)據(jù)結(jié)構(gòu)見圖 19。

圖 19alternative

說實話我不覺得這種方案比 vector 好。代碼變復(fù)雜,性能也未見得有 noticeable 的改觀。

如果放棄“連續(xù)性”要求,可以用 circular buffer,這樣可以減少一點內(nèi)存拷貝(沒有“內(nèi)部騰挪”)。

Zero copy ?

如果對性能有極高的要求,受不了 copy() 與 resize(),那么可以考慮實現(xiàn)分段連續(xù)的 zero copy buffer 再配合 gather scatter IO,數(shù)據(jù)結(jié)構(gòu)如圖 20,這是 libevent 2.0.x 的設(shè)計方案。TCPv2介紹的 BSD TCP/IP 實現(xiàn)中的 mbuf 也是類似的方案,Linux 的 sk_buff 估計也差不多。細節(jié)有出入,但基本思路都是不要求數(shù)據(jù)在內(nèi)存中連續(xù),而是用鏈表把數(shù)據(jù)塊鏈接到一起。

圖 20evbuf0

當(dāng)然,高性能的代價是代碼變得晦澀難讀,buffer 不再是連續(xù)的,parse 消息會稍微麻煩。如果你的程序只處理 protobuf Message,這不是問題,因為 protobuf 有 ZeroCopyInputStream 接口,只要實現(xiàn)這個接口,parsing 的事情就交給 protobuf Message 去操心了。

性能是不是問題?看跟誰比

看到這里,有的讀者可能會嘀咕,muduo Buffer 有那么多可以優(yōu)化的地方,其性能會不會太低?對此,我的回應(yīng)是“可以優(yōu)化,不一定值得優(yōu)化。”

Muduo 的設(shè)計目標(biāo)是用于開發(fā)公司內(nèi)部的分布式程序。換句話說,它是用來寫專用的 Sudoku server 或者游戲服務(wù)器,不是用來寫通用的 httpd 或 ftpd 或 www proxy。前者通常有業(yè)務(wù)邏輯,后者更強調(diào)高并發(fā)與高吞吐。

以 Sudoku 為例,假設(shè)求解一個 Sudoku 問題需要 0.2ms,服務(wù)器有 8 個核,那么理想情況下每秒最多能求解 40,000 個問題。每次 Sudoku 請求的數(shù)據(jù)大小低于 100 字節(jié)(一個 9x9 的數(shù)獨只要 81 字節(jié),加上 header 也可以控制在 100 bytes 以下),就是說 100 x 40000 = 4 MB per second 的吞吐量就足以讓服務(wù)器的 CPU 飽和。在這種情況下,去優(yōu)化 Buffer 的內(nèi)存拷貝次數(shù)似乎沒有意義。

再舉一個例子,目前最常用的千兆以太網(wǎng)的裸吞吐量是 125MB/s,扣除以太網(wǎng) header、IP header、TCP header之后,應(yīng)用層的吞吐率大約在 115 MB/s 上下。而現(xiàn)在服務(wù)器上最常用的 DDR2/DDR3 內(nèi)存的帶寬至少是 4GB/s,比千兆以太網(wǎng)高 40 倍以上。就是說,對于幾 k 或幾十 k 大小的數(shù)據(jù),在內(nèi)存里邊拷幾次根本不是問題,因為受以太網(wǎng)延遲和帶寬的限制,跟這個程序通信的其他機器上的程序不會覺察到性能差異。

最后舉一個例子,如果你實現(xiàn)的服務(wù)程序要跟數(shù)據(jù)庫打交道,那么瓶頸常常在 DB 上,優(yōu)化服務(wù)程序本身不見得能提高性能(從 DB 讀一次數(shù)據(jù)往往就抵消了你做的全部 low-level 優(yōu)化),這時不如把精力投入在 DB 調(diào)優(yōu)上。

專用服務(wù)程序與通用服務(wù)程序的另外一點區(qū)別是 benchmark 的對象不同。如果你打算寫一個 httpd,自然有人會拿來和目前最好的 nginx 對比,立馬就能比出性能高低。然而,如果你寫一個實現(xiàn)公司內(nèi)部業(yè)務(wù)的服務(wù)程序(比如分布式存儲或者搜索或者微博或者短網(wǎng)址),由于市面上沒有同等功能的開源實現(xiàn),你不需要在優(yōu)化上投入全部精力,只要一版做得比一版好就行。先正確實現(xiàn)所需的功能,投入生產(chǎn)應(yīng)用,然后再根據(jù)真實的負載情況來做優(yōu)化,這恐怕比在編碼階段就盲目調(diào)優(yōu)要更 effective 一些。

Muduo 的設(shè)計目標(biāo)之一是吞吐量能讓千兆以太網(wǎng)飽和,也就是每秒收發(fā) 120 兆字節(jié)的數(shù)據(jù)。這個很容易就達到,不用任何特別的努力。

如果確實在內(nèi)存帶寬方面遇到問題,說明你做的應(yīng)用實在太 critical,或許應(yīng)該考慮放到 Linux kernel 里邊去,而不是在用戶態(tài)嘗試各種優(yōu)化。畢竟只有把程序做到 kernel 里才能真正實現(xiàn) zero copy,否則,核心態(tài)和用戶態(tài)之間始終是有一次內(nèi)存拷貝的。如果放到 kernel 里還不能滿足需求,那么要么自己寫新的 kernel,或者直接用 FPGA 或 ASIC 操作 network adapter 來實現(xiàn)你的高性能服務(wù)器。

(待續(xù))

posted @ 2011-04-17 12:24 陳碩 閱讀(9883) | 評論 (28)編輯 收藏

在 muduo 中實現(xiàn) protobuf 編解碼器與消息分發(fā)器

陳碩 (giantchen_AT_gmail)

Blog.csdn.net/Solstice  t.sina.com.cn/giantchen

Muduo 全系列文章列表: http://blog.csdn.net/Solstice/category/779646.aspx

本文是《一種自動反射消息類型的 Google Protobuf 網(wǎng)絡(luò)傳輸方案》的延續(xù),介紹如何將前文介紹的打包方案與 muduo::net::Buffer 結(jié)合,實現(xiàn)了 protobuf codec 和 dispatcher。

Muduo 的下載地址: http://muduo.googlecode.com/files/muduo-0.1.9-alpha.tar.gz ,SHA1 dc0bb5f7becdfc0277fb35f6dfaafee8209213bc ,本文的完整代碼可在線閱讀 http://code.google.com/p/muduo/source/browse/trunk/examples/protobuf/codec/

考慮到不是每個人都安裝了 Google Protobuf,muduo 中的 protobuf 相關(guān)示例默認是不 build 的,如果你的機器上安裝了 protobuf 2.3.0 或 2.4.0a,那么可以用 ./build.sh protobuf_all 來構(gòu)建 protobuf 相關(guān)的 examples。

 

在介紹 codec 和 dispatcher 之前,先講講前文的一個未決問題。

為什么 Protobuf 的默認序列化格式?jīng)]有包含消息的長度與類型?

Protobuf 是經(jīng)過深思熟慮的消息打包方案,它的默認序列化格式?jīng)]有包含消息的長度與類型,自然有其道理。哪些情況下不需要在 protobuf 序列化得到的字節(jié)流中包含消息的長度和(或)類型?我能想到的答案有:

  • 如果把消息寫入文件,一個文件存一個消息,那么序列化結(jié)果中不需要包含長度和類型,因為從文件名和文件長度中可以得知消息的類型與長度。
  • 如果把消息寫入文件,一個文件存多個消息,那么序列化結(jié)果中不需要包含類型,因為文件名就代表了消息的類型。
  • 如果把消息存入數(shù)據(jù)庫(或者 NoSQL),以 VARBINARY 字段保存,那么序列化結(jié)果中不需要包含長度和類型,因為從字段名和字段長度中可以得知消息的類型與長度。
  • 如果把消息以 UDP 方式發(fā)生給對方,而且對方一個 UDP port 只接收一種消息類型,那么序列化結(jié)果中不需要包含長度和類型,因為從 port 和 UDP packet 長度中可以得知消息的類型與長度。
  • 如果把消息以 TCP 短連接方式發(fā)給對方,而且對方一個 TCP port 只接收一種消息類型,那么序列化結(jié)果中不需要包含長度和類型,因為從 port 和 TCP 字節(jié)流長度中可以得知消息的類型與長度。
  • 如果把消息以 TCP 長連接方式發(fā)給對方,但是對方一個 TCP port 只接收一種消息類型,那么序列化結(jié)果中不需要包含類型,因為 port 代表了消息的類型。
  • 如果采用 RPC 方式通信,那么只需要告訴對方 method name,對方自然能推斷出 Request 和 Response 的消息類型,這些可以由 protoc 生成的 RPC stubs 自動搞定。

對于最后一點,比方說 sudoku.proto 定義為:

service SudokuService {
  rpc Solve (SudokuRequest) returns (SudokuResponse);
}

那么 RPC method Sudoku.Solve 對應(yīng)的請求和響應(yīng)分別是 SudokuRequest 和 SudokuResponse。在發(fā)送 RPC 請求的時候,不需要包含 SudokuRequest 的類型,只需要發(fā)送 method name Sudoku.Solve,對方自知道應(yīng)該按照 SudokuRequest 來解析(parse)請求。這個例子來自我的半成品項目 evproto,見 http://blog.csdn.net/Solstice/archive/2010/04/17/5497699.aspx

對于上述這些情況,如果 protobuf 無條件地把長度和類型放到序列化的字節(jié)串中,只會浪費網(wǎng)絡(luò)帶寬和存儲。可見 protobuf 默認不發(fā)送長度和類型是正確的決定。Protobuf 為消息格式的設(shè)計樹立了典范,哪些該自己搞定,哪些留給外部系統(tǒng)去解決,這些都考慮得很清楚。

只有在使用 TCP 長連接,且在一個連接上傳遞不止一種消息的情況下(比方同時發(fā) Heartbeat 和 Request/Response),才需要我前文提到的那種打包方案。(為什么要在一個連接上同時發(fā) Heartbeat 和業(yè)務(wù)消息?請見陳碩《分布式系統(tǒng)的工程化開發(fā)方法》 p.51 心跳協(xié)議的設(shè)計。)這時候我們需要一個分發(fā)器 dispatcher,把不同類型的消息分給各個消息處理函數(shù),這正是本文的主題之一。

以下均只考慮 TCP 長連接這一應(yīng)用場景。

先談?wù)劸幗獯a器。

什么是編解碼器 codec?

Codec 是 encoder 和 decoder 的縮寫,這是一個到軟硬件都在使用的術(shù)語,這里我借指“把網(wǎng)絡(luò)數(shù)據(jù)和業(yè)務(wù)消息之間互相轉(zhuǎn)換”的代碼。

在最簡單的網(wǎng)絡(luò)編程中,沒有消息 message 只有字節(jié)流數(shù)據(jù),這時候是用不到 codec 的。比如我們前面講過的 echo server,它只需要把收到的數(shù)據(jù)原封不動地發(fā)送回去,它不必關(guān)心消息的邊界(也沒有“消息”的概念),收多少就發(fā)多少,這種情況下它干脆直接使用 muduo::net::Buffer,取到數(shù)據(jù)再交給 TcpConnection 發(fā)送回去,見下圖。

codec_echo

non-trivial 的網(wǎng)絡(luò)服務(wù)程序通常會以消息為單位來通信,每條消息有明確的長度與界限。程序每次收到一個完整的消息的時候才開始處理,發(fā)送的時候也是把一個完整的消息交給網(wǎng)絡(luò)庫。比如我們前面講過的 asio chat 服務(wù),它的一條聊天記錄就是一條消息,我們設(shè)計了一個簡單的消息格式,即在聊天記錄前面加上 4 字節(jié)的 length header,LengthHeaderCodec 代碼及解說見《Muduo 網(wǎng)絡(luò)編程示例之二:Boost.Asio 的聊天服務(wù)器》一文。

codec 的基本功能之一是做 TCP 分包:確定每條消息的長度,為消息劃分界限。在 non-blocking 網(wǎng)絡(luò)編程中,codec 幾乎是必不可少的。如果只收到了半條消息,那么不會觸發(fā)消息回調(diào),數(shù)據(jù)會停留在 Buffer 里(數(shù)據(jù)已經(jīng)讀到 Buffer 中了),等待收到一個完整的消息再通知處理函數(shù)。既然這個任務(wù)太常見,我們干脆做一個 utility class,避免服務(wù)端和客戶端程序都要自己處理分包,這就有了 LengthHeaderCodec。這個 codec 的使用有點奇怪,不需要繼承,它也沒有基類,只要把它當(dāng)成普通 data member 來用,把 TcpConnection 的數(shù)據(jù)喂給它,然后向它注冊 onXXXMessage() 回調(diào),代碼見 asio chat 示例。muduo 里的 codec 都是這樣的風(fēng)格,通過 boost::function 粘合到一起。

codec 是一層間接性,它位于 TcpConnection 和 ChatServer 之間,攔截處理收到的數(shù)據(jù),在收到完整的消息之后再調(diào)用 CharServer 對應(yīng)的處理函數(shù),注意 CharServer::onStringMessage() 的參數(shù)是 std::string,不再是 muduo::net::Buffer,也就是說 LengthHeaderCodec 把 Buffer 解碼成了 string。另外,在發(fā)送消息的時候,ChatServer 通過 LengthHeaderCodec::send() 來發(fā)送 string,LengthHeaderCodec 負責(zé)把它編碼成 Buffer。這正是“編解碼器”名字的由來。

codec_chat

Protobuf codec 與此非常類似,只不過消息類型從 std::string 變成了 protobuf::Message。對于只接收處理 Query 消息的 QueryServer 來說,用 ProtobufCodec 非常方便,收到 protobuf::Message 之后 down cast 成 Query 來用就行。如果要接收處理不止一種消息,ProtobufCodec 恐怕還不能單獨完成工作,請繼續(xù)閱讀下文。

codec_protobuf

 

實現(xiàn) ProtobufCodec

Protobuf 的打包方案我已經(jīng)在《一種自動反射消息類型的 Google Protobuf 網(wǎng)絡(luò)傳輸方案》中講過,并以 string 為載體演示了 encode 和 decode 操作。在 muduo 里,我們有專門的 Buffer class,編碼更輕松。

編碼算法很直截了當(dāng),按照前文定義的消息格式一路打包下來,最后更新一下首部的長度即可。

解碼算法有幾個要點:

  • protobuf::Message 是 new 出來的對象,它的生命期如何管理?muduo 采用 shared_ptr<Message> 來自動管理對象生命期,這與其他地方的做法是一致的。
  • 出錯如何處理?比方說長度超出范圍、check sum 不正確、message type name 不能識別、message parse 出錯等等。ProtobufCodec 定義了 ErrorCallback,用戶代碼可以注冊這個回調(diào)。如果不注冊,默認的處理是斷開連接,讓客戶重連重試。codec 的單元測試里模擬了各種出錯情況。
  • 如何處理一次收到半條消息、一條消息、一條半消息、兩條消息等等情況?這是每個 non-blocking 網(wǎng)絡(luò)程序中的 codec 都要面對的問題。

ProtobufCodec 在實際使用中有明顯的不足:它只負責(zé)把 muduo::net::Buffer 轉(zhuǎn)換為具體類型的 protobuf::Message,應(yīng)用程序拿到 Message 之后還有再根據(jù)其具體類型做一次分發(fā)。我們可以考慮做一個簡單通用的分發(fā)器 dispatcher,以簡化客戶代碼。

此外,目前 ProtobufCodec 的實現(xiàn)非常初級,它沒有充分利用 ZeroCopyInputStream 和 ZeroCopyOutputStream,而是把收到的數(shù)據(jù)作為 byte array 交給 protobuf Message 去解析,這給性能優(yōu)化留下了空間。protobuf Message 不要求數(shù)據(jù)連續(xù)(像 vector 那樣),只要求數(shù)據(jù)分段連續(xù)(像 deque 那樣),這給 buffer 管理帶來性能上的好處(避免重新分配內(nèi)存,減少內(nèi)存碎片),當(dāng)然也使得代碼變復(fù)雜。muduo::net::Buffer 非常簡單,它內(nèi)部是 vector<char>,我目前不想讓 protobuf 影響 muduo 本身的設(shè)計,畢竟 muduo 是個通用的網(wǎng)絡(luò)庫,不是為實現(xiàn) protobuf RPC 而特制的。

消息分發(fā)器 dispatcher 有什么用?

前面提到,在使用 TCP 長連接,且在一個連接上傳遞不止一種 protobuf 消息的情況下,客戶代碼需要對收到的消息按類型做分發(fā)。比方說,收到 Logon 消息就交給 QueryServer::onLogon() 去處理,收到 Query 消息就交給 QueryServer::onQuery() 去處理。這個消息分派機制可以做得稍微有點通用性,讓所有 muduo+protobuf 程序收益,而且不增加復(fù)雜性。

換句話說,又是一層間接性,ProtobufCodec 攔截了 TcpConnection 的數(shù)據(jù),把它轉(zhuǎn)換為 Message,ProtobufDispatcher 攔截了 ProtobufCodec 的 callback,按消息具體類型把它分派給多個 callbacks。

codec_dispatcher

ProtobufCodec 與 ProtobufDispatcher 的綜合運用

我寫了兩個示例代碼,client 和 server,把 ProtobufCodec 和 ProtobufDispatcher 串聯(lián)起來使用。server 響應(yīng) Query 消息,發(fā)生回 Answer 消息,如果收到未知消息類型,則斷開連接。client 可以選擇發(fā)送 Query 或 Empty 消息,由命令行控制。這樣可以測試 unknown message callback。

為節(jié)省篇幅,這里就不列出代碼了,請移步閱讀

http://code.google.com/p/muduo/source/browse/trunk/examples/protobuf/codec/client.cc 

http://code.google.com/p/muduo/source/browse/trunk/examples/protobuf/codec/server.cc

在構(gòu)造函數(shù)中,通過注冊回調(diào)函數(shù)把四方 (TcpConnection、codec、dispatcher、QueryServer) 結(jié)合起來。

ProtobufDispatcher 的兩種實現(xiàn)

要完成消息分發(fā),那么就是對消息做 type-switch,這似乎是一個 bad smell,但是 protobuf Message 的 Descriptor 沒有留下定制點(比如暴露一個 boost::any 成員),我們只好硬來了。

先定義

typedef boost::function<void (Message*)> ProtobufMessageCallback;

注意,本節(jié)出現(xiàn)的不是 muduo dispatcher 真實的代碼,僅為示意,突出重點,便于畫圖。

ProtobufDispatcherLite 的結(jié)構(gòu)非常簡單,它有一個 map<Descriptor*, ProtobufMessageCallback> 成員,客戶代碼可以以 Descriptor* 為 key 注冊回調(diào)(recall: 每個具體消息類型都有一個全局的 Descriptor 對象,其地址是不變的,可以用來當(dāng) key)。在收到 protobuf Message 之后,在 map 中找到對應(yīng)的 ProtobufMessageCallback,然后調(diào)用之。如果找不到,就調(diào)用 defaultCallback。

codec_dispatcher_lite

當(dāng)然,它的設(shè)計也有小小的缺陷,那就是 ProtobufMessageCallback 限制了客戶代碼只能接受基類 Message,客戶代碼需要自己做向下轉(zhuǎn)型,比如:

codec_query_server1

 

如果我希望 QueryServer 這么設(shè)計:不想每個消息處理函數(shù)自己做 down casting,而是交給 dispatcher 去處理,客戶代碼拿到的就已經(jīng)是想要的具體類型。如下:

codec_query_server2

那么該該如何實現(xiàn) ProtobufDispatcher 呢?它如何與多個未知的消息類型合作?做 down cast 需要知道目標(biāo)類型,難道我們要用一長串模板類型參數(shù)嗎?

有一個辦法,把多態(tài)與模板結(jié)合,利用 templated derived class 來提供類型上的靈活性。設(shè)計如下。

codec_dispatcher_class

ProtobufDispatcher 有一個模板成員函數(shù),可以接受注冊任意消息類型 T 的回調(diào),然后它創(chuàng)建一個模板化的派生類 CallbackT<T>,這樣消息的類新信息就保存在了 CallbackT<T> 中,做 down casting 就簡單了。

 

比方說,我們有兩個具體消息類型 Query 和 Answer。

codec_query

然后我們這樣注冊回調(diào):

dispatcher_.registerMessageCallback<muduo::Query>(
    boost::bind(&QueryServer::onQuery, this, _1, _2, _3));
dispatcher_.registerMessageCallback<muduo::Answer>(
    boost::bind(&QueryServer::onAnswer, this, _1, _2, _3));

這樣會具現(xiàn)化 (instantiation) 出兩個 CallbackT 實體,如下:

codec_query_callback

以上設(shè)計參考了 shared_ptr 的 deleter,Scott Meyers 也談到過

ProtobufCodec 和 ProtobufDispatcher 有何意義?

ProtobufCodec 和 ProtobufDispatcher 把每個直接收發(fā) protobuf Message 的網(wǎng)絡(luò)程序都會用到的功能提煉出來做成了公用的 utility,這樣以后新寫 protobuf 網(wǎng)絡(luò)程序就不必為打包分包和消息分發(fā)勞神了。它倆以庫的形式存在,是兩個可以拿來就當(dāng) data member 用的 class,它們沒有基類,也沒有用到虛函數(shù)或者別的什么面向?qū)ο筇卣鳎磺秩?muduo::net 或者你的代碼。

 

下一篇文章講《分布式程序的自動回歸測試》會介紹利用 protobuf 的跨語言特性,采用 Java 為 C++ 服務(wù)程序編寫 test harness。

posted @ 2011-04-13 07:47 陳碩 閱讀(4490) | 評論 (1)編輯 收藏

一種自動反射消息類型的 Google Protobuf 網(wǎng)絡(luò)傳輸方案

陳碩 (giantchen_AT_gmail)

Blog.csdn.net/Solstice  t.sina.com.cn/giantchen

 

這篇文章要解決的問題是:在接收到 protobuf 數(shù)據(jù)之后,如何自動創(chuàng)建具體的 Protobuf Message 對象,再做的反序列化。“自動”的意思是:當(dāng)程序中新增一個 protobuf Message 類型時,這部分代碼不需要修改,不需要自己去注冊消息類型。其實,Google Protobuf 本身具有很強的反射(reflection)功能,可以根據(jù) type name 創(chuàng)建具體類型的 Message 對象,我們直接利用即可。

本文假定讀者了解 Google Protocol Buffers 是什么,這不是一篇 protobuf 入門教程。

本文以 C++ 語言舉例,其他語言估計有類似的解法,歡迎補充。

本文的示例代碼在: https://github.com/chenshuo/recipes/tree/master/protobuf

網(wǎng)絡(luò)編程中使用 protobuf 的兩個問題

Google Protocol Buffers (Protobuf) 是一款非常優(yōu)秀的庫,它定義了一種緊湊的可擴展二進制消息格式,特別適合網(wǎng)絡(luò)數(shù)據(jù)傳輸。它為多種語言提供 binding,大大方便了分布式程序的開發(fā),讓系統(tǒng)不再局限于用某一種語言來編寫。

在網(wǎng)絡(luò)編程中使用 protobuf 需要解決兩個問題:

  • 長度,protobuf 打包的數(shù)據(jù)沒有自帶長度信息或終結(jié)符,需要由應(yīng)用程序自己在發(fā)生和接收的時候做正確的切分;
  • 類型,protobuf 打包的數(shù)據(jù)沒有自帶類型信息,需要由發(fā)送方把類型信息傳給給接收方,接收方創(chuàng)建具體的 Protobuf Message 對象,再做的反序列化。

第一個很好解決,通常的做法是在每個消息前面加個固定長度的 length header,例如我在 《Muduo 網(wǎng)絡(luò)編程示例之二: Boost.Asio 的聊天服務(wù)器》 中實現(xiàn)的 LengthHeaderCodec,代碼見 http://code.google.com/p/muduo/source/browse/trunk/examples/asio/chat/codec.h

第二個問題其實也很好解決,Protobuf 對此有內(nèi)建的支持。但是奇怪的是,從網(wǎng)上簡單搜索的情況看,我發(fā)現(xiàn)了很多山寨的做法。

 

山寨做法

以下均為在 protobuf data 之前加上 header,header 中包含 int length 和類型信息。類型信息的山寨做法主要有兩種:

  • 在 header 中放 int typeId,接收方用 switch-case 來選擇對應(yīng)的消息類型和處理函數(shù);
  • 在 header 中放 string typeName,接收方用 look-up table 來選擇對應(yīng)的消息類型和處理函數(shù)。

這兩種做法都有問題。

第一種做法要求保持 typeId 的唯一性,它和 protobuf message type 一一對應(yīng)。如果 protobuf message 的使用范圍不廣,比如接收方和發(fā)送方都是自己維護的程序,那么 typeId 的唯一性不難保證,用版本管理工具即可。如果 protobuf message 的使用范圍很大,比如全公司都在用,而且不同部門開發(fā)的分布式程序可能相互通信,那么就需要一個公司內(nèi)部的全局機構(gòu)來分配 typeId,每次增加新 message type 都要去注冊一下,比較麻煩。

第二種做法稍好一點。typeName 的唯一性比較好辦,因為可以加上 package name(也就是用 message 的 fully qualified type name),各個部門事先分好 namespace,不會沖突與重復(fù)。但是每次新增消息類型的時候都要去手工修改 look-up table 的初始化代碼,比較麻煩。

其實,不需要自己重新發(fā)明輪子,protobuf 本身已經(jīng)自帶了解決方案。

 

根據(jù) type name 反射自動創(chuàng)建 Message 對象

Google Protobuf 本身具有很強的反射(reflection)功能,可以根據(jù) type name 創(chuàng)建具體類型的 Message 對象。但是奇怪的是,其官方教程里沒有明確提及這個用法,我估計還有很多人不知道這個用法,所以覺得值得寫這篇 blog 談一談。

 

以下是陳碩繪制的 Protobuf  class diagram,點擊查看原圖

protobuf_classdiagram

我估計大家通常關(guān)心和使用的是圖的左半部分:MessageLite、Message、Generated Message Types (Person, AddressBook) 等,而較少注意到圖的右半部分:Descriptor, DescriptorPool, MessageFactory。

上圖中,其關(guān)鍵作用的是 Descriptor class,每個具體 Message Type 對應(yīng)一個 Descriptor 對象。盡管我們沒有直接調(diào)用它的函數(shù),但是Descriptor在“根據(jù) type name 創(chuàng)建具體類型的 Message 對象”中扮演了重要的角色,起了橋梁作用。上圖的紅色箭頭描述了根據(jù) type name 創(chuàng)建具體 Message 對象的過程,后文會詳細介紹。

原理簡述

Protobuf Message class 采用了 prototype pattern,Message class 定義了 New() 虛函數(shù),用以返回本對象的一份新實例,類型與本對象的真實類型相同。也就是說,拿到 Message* 指針,不用知道它的具體類型,就能創(chuàng)建和它類型一樣的具體 Message Type 的對象。

每個具體 Message Type 都有一個 default instance,可以通過 ConcreteMessage::default_instance() 獲得,也可以通過 MessageFactory::GetPrototype(const Descriptor*) 來獲得。所以,現(xiàn)在問題轉(zhuǎn)變?yōu)?1. 如何拿到 MessageFactory;2. 如何拿到 Descriptor*。

當(dāng)然,ConcreteMessage::descriptor() 返回了我們想要的 Descriptor*,但是,在不知道 ConcreteMessage 的時候,如何調(diào)用它的靜態(tài)成員函數(shù)呢?這似乎是個雞與蛋的問題。

我們的英雄是 DescriptorPool,它可以根據(jù) type name 查到 Descriptor*,只要找到合適的 DescriptorPool,再調(diào)用 DescriptorPool::FindMessageTypeByName(const string& type_name) 即可。眼前一亮?

在最終解決問題之前,先簡單測試一下,看看我上面說的對不對。

簡單測試

本文用于舉例的 proto 文件:query.proto,見 https://github.com/chenshuo/recipes/blob/master/protobuf/query.proto

package muduo;
message Query {
required int64 id = 1;
required string questioner = 2;
repeated string question = 3;
}
message Answer {
required int64 id = 1;
required string questioner = 2;
required string answerer = 3;
repeated string solution = 4;
}
message Empty {
optional int32 id = 1;
}
其中的 Query.questioner 和 Answer.answerer 是我在前一篇文章這提到的《分布式系統(tǒng)中的進程標(biāo)識》。

以下代碼驗證 ConcreteMessage::default_instance()、ConcreteMessage::descriptor()、 MessageFactory::GetPrototype()、DescriptorPool::FindMessageTypeByName() 之間的不變式 (invariant):

https://github.com/chenshuo/recipes/blob/master/protobuf/descriptor_test.cc#L15

  typedef muduo::Query T;
std::string type_name = T::descriptor()->full_name();
cout << type_name << endl;
const Descriptor* descriptor = DescriptorPool::generated_pool()->FindMessageTypeByName(type_name);
assert(descriptor == T::descriptor());
cout << "FindMessageTypeByName() = " << descriptor << endl;
cout << "T::descriptor()         = " << T::descriptor() << endl;
cout << endl;
const Message* prototype = MessageFactory::generated_factory()->GetPrototype(descriptor);
assert(prototype == &T::default_instance());
cout << "GetPrototype()        = " << prototype << endl;
cout << "T::default_instance() = " << &T::default_instance() << endl;
cout << endl;
T* new_obj = dynamic_cast<T*>(prototype->New());
assert(new_obj != NULL);
assert(new_obj != prototype);
assert(typeid(*new_obj) == typeid(T::default_instance()));
cout << "prototype->New() = " << new_obj << endl;
cout << endl;
delete new_obj;

根據(jù) type name 自動創(chuàng)建 Message 的關(guān)鍵代碼

好了,萬事具備,開始行動:

  1. 用 DescriptorPool::generated_pool() 找到一個 DescriptorPool 對象,它包含了程序編譯的時候所鏈接的全部 protobuf Message types
  2. 用 DescriptorPool::FindMessageTypeByName() 根據(jù) type name 查找 Descriptor。
  3. 再用 MessageFactory::generated_factory() 找到 MessageFactory 對象,它能創(chuàng)建程序編譯的時候所鏈接的全部 protobuf Message types。
  4. 然后,用 MessageFactory::GetPrototype() 找到具體 Message Type 的 default instance。
  5. 最后,用 prototype->New() 創(chuàng)建對象。

示例代碼見 https://github.com/chenshuo/recipes/blob/master/protobuf/codec.h#L69

Message* createMessage(const std::string& typeName)
{
Message* message = NULL;
const Descriptor* descriptor = DescriptorPool::generated_pool()->FindMessageTypeByName(typeName);
if (descriptor)
{
const Message* prototype = MessageFactory::generated_factory()->GetPrototype(descriptor);
if (prototype)
{
message = prototype->New();
}
}
return message;
}

調(diào)用方式:https://github.com/chenshuo/recipes/blob/master/protobuf/descriptor_test.cc#L49

  Message* newQuery = createMessage("muduo.Query");
assert(newQuery != NULL);
assert(typeid(*newQuery) == typeid(muduo::Query::default_instance()));
cout << "createMessage(\"muduo.Query\") = " << newQuery << endl;

古之人不余欺也 :-)

注意,createMessage() 返回的是動態(tài)創(chuàng)建的對象的指針,調(diào)用方有責(zé)任釋放它,不然就會內(nèi)存泄露。在 muduo 里,我用 shared_ptr<Message> 來自動管理 Message 對象的生命期。

線程安全性

Google 的文檔說,我們用到的那幾個 MessageFactory 和 DescriptorPool 都是線程安全的,Message::New() 也是線程安全的。并且它們都是 const member function。

 

關(guān)鍵問題解決了,那么剩下工作就是設(shè)計一種包含長度和消息類型的 protobuf 傳輸格式

Protobuf 傳輸格式

陳碩設(shè)計了一個簡單的格式,包含 protobuf data 和它對應(yīng)的長度與類型信息,消息的末尾還有一個 check sum。格式如下圖,圖中方塊的寬度是 32-bit。

protobuf_wireformat1

用 C struct 偽代碼描述:

 struct ProtobufTransportFormat __attribute__ ((__packed__))
{
int32_t  len;
int32_t  nameLen;
char     typeName[nameLen];
char     protobufData[len-nameLen-8];
int32_t  checkSum; // adler32 of nameLen, typeName and protobufData
};
注意,這個格式不要求 32-bit 對齊,我們的 decoder 會自動處理非對齊的消息。

例子

用這個格式打包一個 muduo.Query 對象的結(jié)果是:

protobuf_wireexample

設(shè)計決策

以下是我在設(shè)計這個傳輸格式時的考慮:

  • signed int。消息中的長度字段只使用了 signed 32-bit int,而沒有使用 unsigned int,這是為了移植性,因為 Java 語言沒有 unsigned 類型。另外 Protobuf 一般用于打包小于 1M 的數(shù)據(jù),unsigned int 也沒用。
  • check sum。雖然 TCP 是可靠傳輸協(xié)議,雖然 Ethernet 有 CRC-32 校驗,但是網(wǎng)絡(luò)傳輸必須要考慮數(shù)據(jù)損壞的情況,對于關(guān)鍵的網(wǎng)絡(luò)應(yīng)用,check sum 是必不可少的。對于 protobuf 這種緊湊的二進制格式而言,肉眼看不出數(shù)據(jù)有沒有問題,需要用 check sum。
  • adler32 算法。我沒有選用常見的 CRC-32,而是選用 adler32,因為它計算量小、速度比較快,強度和 CRC-32差不多。另外,zlib 和 java.unit.zip 都直接支持這個算法,不用我們自己實現(xiàn)。
  • type name 以 '\0' 結(jié)束。這是為了方便 troubleshooting,比如通過 tcpdump 抓下來的包可以用肉眼很容易看出 type name,而不用根據(jù) nameLen 去一個個數(shù)字節(jié)。同時,為了方便接收方處理,加入了 nameLen,節(jié)省 strlen(),空間換時間。
  • 沒有版本號。Protobuf Message 的一個突出優(yōu)點是用 optional fields 來避免協(xié)議的版本號(凡是在 protobuf Message 里放版本號的人都沒有理解 protobuf 的設(shè)計),讓通信雙方的程序能各自升級,便于系統(tǒng)演化。如果我設(shè)計的這個傳輸格式又把版本號加進去,那就畫蛇添足了。具體請見本人《分布式系統(tǒng)的工程化開發(fā)方法》第 57 頁:消息格式的選擇。

 

示例代碼

為了簡單起見,采用 std::string 來作為打包的產(chǎn)物,僅為示例。

打包 encode 的代碼:https://github.com/chenshuo/recipes/blob/master/protobuf/codec.h#L35

解包 decode 的代碼:https://github.com/chenshuo/recipes/blob/master/protobuf/codec.h#L99

測試代碼: https://github.com/chenshuo/recipes/blob/master/protobuf/codec_test.cc

如果以上代碼編譯通過,但是在運行時出現(xiàn)“cannot open shared object file”錯誤,一般可以用 sudo ldconfig 解決,前提是 libprotobuf.so 位于 /usr/local/lib,且 /etc/ld.so.conf 列出了這個目錄。

$ make all # 如果你安裝了 boost,可以 make whole

$ ./codec_test
./codec_test: error while loading shared libraries: libprotobuf.so.6: cannot open shared object file: No such file or directory

$ sudo ldconfig

 

與 muduo 集成

muduo 網(wǎng)絡(luò)庫將會集成對本文所述傳輸格式的支持(預(yù)計 0.1.9 版本),我會另外寫一篇短文介紹 Protobuf Message <=> muduo::net::Buffer 的相互轉(zhuǎn)化,使用 muduo::net::Buffer 來打包比上面 std::string 的代碼還簡單,它是專門為 non-blocking 網(wǎng)絡(luò)庫設(shè)計的 buffer class。

此外,我們可以寫一個 codec 來自動完成轉(zhuǎn)換,就行 asio/char/codec.h 那樣。這樣客戶代碼直接收到的就是 Message 對象,發(fā)送的時候也直接發(fā)送 Message 對象,而不需要和 Buffer 對象打交道。

消息的分發(fā) (dispatching)

目前我們已經(jīng)解決了消息的自動創(chuàng)建,在網(wǎng)絡(luò)編程中,還有一個常見任務(wù)是把不同類型的 Message 分發(fā)給不同的處理函數(shù),這同樣可以借助 Descriptor 來完成。我在 muduo 里實現(xiàn)了 ProtobufDispatcherLite 和 ProtobufDispatcher 兩個分發(fā)器,用戶可以自己注冊針對不同消息類型的處理函數(shù)。預(yù)計將會在 0.1.9 版本發(fā)布,您可以先睹為快:

初級版,用戶需要自己做 down casting: https://github.com/chenshuo/recipes/blob/master/protobuf/dispatcher_lite.cc

高級版,使用模板技巧,節(jié)省用戶打字: https://github.com/chenshuo/recipes/blob/master/protobuf/dispatcher.cc

基于 muduo 的 Protobuf RPC?

Google Protobuf 還支持 RPC,可惜它只提供了一個框架,沒有開源網(wǎng)絡(luò)相關(guān)的代碼,muduo 正好可以填補這一空白。我目前還沒有決定是不是讓 muduo 也支持以 protobuf message 為消息格式的 RPC,muduo 還有很多事情要做,我也有很多博客文章打算寫,RPC 這件事情以后再說吧。

注:Remote Procedure Call (RPC) 有廣義和狹義兩種意思。狹義的講,一般特指 ONC RPC,就是用來實現(xiàn) NFS 的那個東西;廣義的講,“以函數(shù)調(diào)用之名,行網(wǎng)絡(luò)通信之實”都可以叫 RPC,比如 Java RMI,.Net Remoting,Apache Thriftlibevent RPC,XML-RPC 等等。

 

(待續(xù))

posted @ 2011-04-03 15:56 陳碩 閱讀(5578) | 評論 (1)編輯 收藏

C++ 工程實踐(5):避免使用虛函數(shù)作為庫的接口

陳碩 (giantchen_AT_gmail)

Blog.csdn.net/Solstice

 

摘要:作為 C++ 動態(tài)庫的作者,應(yīng)當(dāng)避免使用虛函數(shù)作為庫的接口。這么做會給保持二進制兼容性帶來很大麻煩,不得不增加很多不必要的 interfaces,最終重蹈 COM 的覆轍。

本文主要討論 Linux x86 平臺,會繼續(xù)舉 Windows/COM 作為反面教材。

本文是上一篇《C++ 工程實踐(4):二進制兼容性》的延續(xù),在寫這篇文章的時候,我原本以外大家都對“以 C++ 虛函數(shù)作為接口”的害處達成共識,我就寫得比較簡略,看來情況不是這樣,我還得展開談一談。

“接口”有廣義和狹義之分,本文用中文“接口”表示廣義的接口,即一個庫的代碼界面;用英文 interface 表示狹義的接口,即只包含 virtual function 的 class,這種 class 通常沒有 data member,在 Java 里有一個專門的關(guān)鍵字 interface 來表示它。

C++ 程序庫的作者的生存環(huán)境

假設(shè)你是一個 shared library 的維護者,你的 library 被公司另外兩三個團隊使用了。你發(fā)現(xiàn)了一個安全漏洞,或者某個會導(dǎo)致 crash 的 bug 需要緊急修復(fù),那么你修復(fù)之后,能不能直接部署 library 的二進制文件?有沒有破壞二進制兼容性?會不會破壞別人團隊已經(jīng)編譯好的投入生成環(huán)境的可執(zhí)行文件?是不是要強迫別的團隊重新編譯鏈接,把可執(zhí)行文件也發(fā)布新版本?會不會打亂別人的 release cycle?這些都是工程開發(fā)中經(jīng)常要遇到的問題。

如果你打算新寫一個 C++ library,那么通常要做以下幾個決策:

  • 以什么方式發(fā)布?動態(tài)庫還是靜態(tài)庫?(本文不考慮源代碼發(fā)布這種情況,這其實和靜態(tài)庫類似。)
  • 以什么方式暴露庫的接口?可選的做法有:以全局(含 namespace 級別)函數(shù)為接口、以 class 的 non-virtual 成員函數(shù)為接口、以 virtual 函數(shù)為接口(interface)。

(Java 程序員沒有這么多需要考慮的,直接寫 class 成員函數(shù)就行,最多考慮一下要不要給 method 或 class 標(biāo)上 final。也不必考慮動態(tài)庫靜態(tài)庫,都是 .jar 文件。)

在作出上面兩個決策之前,我們考慮兩個基本假設(shè):

  • 代碼會有 bug,庫也不例外。將來可能會發(fā)布 bug fixes。
  • 會有新的功能需求。寫代碼不是一錘子買賣,總是會有新的需求冒出來,需要程序員往庫里增加?xùn)|西。這是好事情,讓程序員不丟飯碗。

(如果你的代碼第一次發(fā)布的時候就已經(jīng)做到完美,將來不需要任何修改,那么怎么做都行,也就不必繼續(xù)閱讀本文。)

也就是說,在設(shè)計庫的時候必須要考慮將來如何升級

基于以上兩個基本假設(shè)來做決定。第一個決定很好做,如果需要 hot fix,那么只能用動態(tài)庫;否則,在分布式系統(tǒng)中使用靜態(tài)庫更容易部署,這在前文中已經(jīng)談過。(“動態(tài)庫比靜態(tài)庫節(jié)約內(nèi)存”這種優(yōu)勢在今天看來已不太重要。)

以下本文假定你或者你的老板選擇以動態(tài)庫方式發(fā)布,即發(fā)布 .so 或 .dll 文件,來看看第二個決定怎么做。(再說一句,如果你能夠以靜態(tài)庫方式發(fā)布,后面的麻煩都不會遇到。)

第二個決定不是那么容易做,關(guān)鍵問題是,要選擇一種可擴展的 (extensible) 接口風(fēng)格,讓庫的升級變得更輕松。“升級”有兩層意思:

  • 對于 bug fix only 的升級,二進制庫文件的替換應(yīng)該兼容現(xiàn)有的二進制可執(zhí)行文件,二進制兼容性方面的問題已經(jīng)在前文談過,這里從略。
  • 對于新增功能的升級,應(yīng)該對客戶代碼的友好。升級庫之后,客戶端使用新功能的代價應(yīng)該比較小。只需要包含新的頭文件(這一步都可以省略,如果新功能已經(jīng)加入原有的頭文件中),然后編寫新代碼即可。而且,不要在客戶代碼中留下垃圾,后文我們會談到什么是垃圾。

在討論虛函數(shù)接口的弊端之前,我們先看看虛函數(shù)做接口的常見用法。

虛函數(shù)作為庫的接口的兩大用途

虛函數(shù)為接口大致有這么兩種用法:

  1. 調(diào)用,也就是庫提供一個什么功能(比如繪圖 Graphics),以虛函數(shù)為接口方式暴露給客戶端代碼。客戶端代碼一般不需要繼承這個 interface,而是直接調(diào)用其 member function。這么做據(jù)說是有利于接口和實現(xiàn)分離,我認為純屬脫了褲子放屁。
  2. 回調(diào),也就是事件通知,比如網(wǎng)絡(luò)庫的“連接建立”、“數(shù)據(jù)到達”、“連接斷開”等等。客戶端代碼一般會繼承這個 interface,然后把對象實例注冊到庫里邊,等庫來回調(diào)自己。一般來說客戶端不會自己去調(diào)用這些 member function,除非是為了寫單元測試模擬庫的行為。
  3. 混合,一個 class 既可以被客戶端代碼繼承用作回調(diào),又可以被客戶端直接調(diào)用。說實話我沒看出這么做的好處,但實際中某些面向?qū)ο蟮?C++ 庫就是這么設(shè)計的。

對于“回調(diào)”方式,現(xiàn)代 C++ 有更好的做法,即 boost::function + boost::bind,見參考文獻[4],muduo 的回調(diào)全部采用這種新方法,見《Muduo 網(wǎng)絡(luò)編程示例之零:前言》。本文以下不考慮以虛函數(shù)為回調(diào)的過時做法。

對于“調(diào)用”方式,這里舉一個虛構(gòu)的圖形庫,這個庫的功能是畫線、畫矩形、畫圓弧:

   1: struct Point
   2: {
   3:   int x;
   4:   int y;
   5: };
   6:  
   7: class Graphics
   8: {
   9:   virtual void drawLine(int x0, int y0, int x1, int y1);
  10:   virtual void drawLine(Point p0, Point p1);
  11:  
  12:   virtual void drawRectangle(int x0, int y0, int x1, int y1);
  13:   virtual void drawRectangle(Point p0, Point p1);
  14:  
  15:   virtual void drawArc(int x, int y, int r);
  16:   virtual void drawArc(Point p, int r);
  17: };

這里略去了很多與本文主題無關(guān)細節(jié),比如 Graphics 的構(gòu)造與析構(gòu)、draw*() 函數(shù)應(yīng)該是 public、Graphics 應(yīng)該不允許復(fù)制,還比如 Graphics 可能會用 pure virtual functions 等等,這些都不影響本文的討論。

這個 Graphics 庫的使用很簡單,客戶端看起來是這個樣子。

Graphics* g = getGraphics();

g->drawLine(0, 0, 100, 200);

releaseGraphics(g); g = NULL;

似乎一切都很好,陽光明媚,符合“面向?qū)ο蟮脑瓌t”,但是一旦考慮升級,前景立刻變得昏暗。

虛函數(shù)作為接口的弊端

以虛函數(shù)作為接口在二進制兼容性方面有本質(zhì)困難:“一旦發(fā)布,不能修改”。

假如我需要給 Graphics 增加幾個繪圖函數(shù),同時保持二進制兼容性。這幾個新函數(shù)的坐標(biāo)以浮點數(shù)表示,我理想中的新接口是:

--- old/graphics.h  2011-03-12 13:12:44.000000000 +0800
+++ new/graphics.h 2011-03-12 13:13:30.000000000 +0800
@@ -7,11 +7,14 @@
 class Graphics
 {
   virtual void drawLine(int x0, int y0, int x1, int y1);
+  virtual void drawLine(double x0, double y0, double x1, double y1);
   virtual void drawLine(Point p0, Point p1);

   virtual void drawRectangle(int x0, int y0, int x1, int y1);
+  virtual void drawRectangle(double x0, double y0, double x1, double y1);
   virtual void drawRectangle(Point p0, Point p1);

   virtual void drawArc(int x, int y, int r);
+  virtual void drawArc(double x, double y, double r);
   virtual void drawArc(Point p, int r);
 };

受 C++ 二進制兼容性方面的限制,我們不能這么做。其本質(zhì)問題在于 C++ 以 vtable[offset] 方式實現(xiàn)虛函數(shù)調(diào)用,而 offset 又是根據(jù)虛函數(shù)聲明的位置隱式確定的,這造成了脆弱性。我增加了 drawLine(double x0, double y0, double x1, double y1),造成 vtable 的排列發(fā)生了變化,現(xiàn)有的二進制可執(zhí)行文件無法再用舊的 offset 調(diào)用到正確的函數(shù)。

怎么辦呢?有一種危險且丑陋的做法:把新的虛函數(shù)放到 interface 的末尾,例如:

--- old/graphics.h  2011-03-12 13:12:44.000000000 +0800
+++ new/graphics.h 2011-03-12 13:58:22.000000000 +0800
@@ -7,11 +7,15 @@
 class Graphics
 {
   virtual void drawLine(int x0, int y0, int x1, int y1);
   virtual void drawLine(Point p0, Point p1);

   virtual void drawRectangle(int x0, int y0, int x1, int y1);
   virtual void drawRectangle(Point p0, Point p1);

   virtual void drawArc(int x, int y, int r);
   virtual void drawArc(Point p, int r);
+
+  virtual void drawLine(double x0, double y0, double x1, double y1);
+  virtual void drawRectangle(double x0, double y0, double x1, double y1);
+  virtual void drawArc(double x, double y, double r);
 };

這么做很丑陋,因為新的 drawLine(double x0, double y0, double x1, double y1) 函數(shù)沒有和原來的 drawLine() 函數(shù)呆在一起,造成閱讀上的不便。這么做同時很危險,因為 Graphics 如果被繼承,那么新增虛函數(shù)會改變派生類中的 vtable offset 變化,同樣不是二進制兼容的。

另外有兩種似乎安全的做法,這也是 COM 采用的辦法:

1. 通過鏈?zhǔn)嚼^承來擴展現(xiàn)有 interface,例如從 Graphics 派生出 Graphics2。

--- graphics.h  2011-03-12 13:12:44.000000000 +0800
+++ graphics2.h 2011-03-12 13:58:35.000000000 +0800
@@ -7,11 +7,19 @@
 class Graphics
 {
   virtual void drawLine(int x0, int y0, int x1, int y1);
   virtual void drawLine(Point p0, Point p1);

   virtual void drawRectangle(int x0, int y0, int x1, int y1);
   virtual void drawRectangle(Point p0, Point p1);

   virtual void drawArc(int x, int y, int r);
   virtual void drawArc(Point p, int r);
 };
+
+class Graphics2 : public Graphics
+{
+  using Graphics::drawLine;
+  using Graphics::drawRectangle;
+  using Graphics::drawArc;
+
+  // added in version 2
+  virtual void drawLine(double x0, double y0, double x1, double y1);
+  virtual void drawRectangle(double x0, double y0, double x1, double y1);
+  virtual void drawArc(double x, double y, double r);
+};

將來如果繼續(xù)增加功能,那么還會有 class Graphics3 : public Graphics2;以及 class Graphics4 : public Graphics3 等等。這么做和前面的做法一樣丑陋,因為新的 drawLine(double x0, double y0, double x1, double y1) 函數(shù)位于派生 Graphics2 interace 中,沒有和原來的 drawLine() 函數(shù)呆在一起,造成割裂。

2. 通過多重繼承來擴展現(xiàn)有 interface,例如定義一個與 Graphics class 有同樣成員的 Graphics2,再讓實現(xiàn)同時繼承這兩個 interfaces。

--- graphics.h  2011-03-12 13:12:44.000000000 +0800
+++ graphics2.h 2011-03-12 13:16:45.000000000 +0800
@@ -7,11 +7,32 @@
 class Graphics
 {
   virtual void drawLine(int x0, int y0, int x1, int y1);
   virtual void drawLine(Point p0, Point p1);

   virtual void drawRectangle(int x0, int y0, int x1, int y1);
   virtual void drawRectangle(Point p0, Point p1);

   virtual void drawArc(int x, int y, int r);
   virtual void drawArc(Point p, int r);
 };
+
+class Graphics2
+{
+  virtual void drawLine(int x0, int y0, int x1, int y1);
+  virtual void drawLine(double x0, double y0, double x1, double y1);
+  virtual void drawLine(Point p0, Point p1);
+
+  virtual void drawRectangle(int x0, int y0, int x1, int y1);
+  virtual void drawRectangle(double x0, double y0, double x1, double y1);
+  virtual void drawRectangle(Point p0, Point p1);
+
+  virtual void drawArc(int x, int y, int r);
+  virtual void drawArc(double x, double y, double r);
+  virtual void drawArc(Point p, int r);
+};
+
+// 在實現(xiàn)中采用多重接口繼承
+class GraphicsImpl : public Graphics,  // version 1
+                     public Graphics2, // version 2
+{
+  // ...
+};

這種帶版本的 interface 的做法在 COM 使用者的眼中看起來是很正常的,解決了二進制兼容性的問題,客戶端源代碼也不受影響。

在我看來帶版本的 interface 實在是很丑陋,因為每次改動都引入了新的 interface class,會造成日后客戶端代碼難以管理。比如,如果代碼使用了 Graphics3 的功能,要不要把現(xiàn)有的 Graphics2 都替換掉?

  • 如果不替換,一個程序同時依賴多個版本的 Graphics,一直背著歷史包袱。依賴的 Graphics 版本越積越多,將來如何管理得過來?
  • 如果要替換,為什么不相干的代碼(現(xiàn)有的運行得好好的使用 Graphics2 的代碼)也會因為別處用到了 Graphics3 而被修改?

這種二難境地純粹是“以虛函數(shù)為庫的接口”造成的。如果我們能直接原地擴充 class Graphics,就不會有這些屁事,見本文“推薦做法”一節(jié)。

假如 Linux 系統(tǒng)調(diào)用以 COM 接口方式實現(xiàn)

或許上面這個 Graphics 的例子太簡單,沒有讓“以虛函數(shù)為接口”的缺點充分暴露出來,讓我們看一個真實的案例:Linux Kernel。

Linux kernel 從 0.10 的 67 個系統(tǒng)調(diào)用發(fā)展到 2.6.37 的 340 個,kernel interface 一直在擴充,而且保持良好的兼容性,它保持兼容性的辦法很土,就是給每個 system call 賦予一個終身不變的數(shù)字代號,等于把虛函數(shù)表的排列固定下來。點開本段開頭的兩個鏈接,你就能看到 fork() 在 Linux 0.10 和 Linux 2.6.37 里的代號都是 2。(系統(tǒng)調(diào)用的編號跟硬件平臺有關(guān),這里我們看的是 x86 32-bit 平臺。)

試想假如 Linus 當(dāng)初選擇用 COM 接口的鏈?zhǔn)嚼^承風(fēng)格來描述,將會是怎樣一種壯觀的景象?為了避免擾亂視線,請移步觀看近百層繼承的代碼。(先后關(guān)系與版本號不一定 100% 準(zhǔn)確,我是用 git blame 去查的,現(xiàn)在列出的代碼只從 0.01 到 2.5.31,相信已經(jīng)足以展現(xiàn) COM 接口方式的弊端。)

 

不要誤認為“接口一旦發(fā)布就不能更改”是天經(jīng)地義的,那不過是“以 C++ 虛函數(shù)為接口”的固有弊端,如果跳出這個框框去思考,其實 C++ 庫的接口很容易做得更好。

為什么不能改?還不是因為用了C++ 虛函數(shù)作為接口。Java 的 interface 可以添加新函數(shù),C 語言的庫也可以添加新的全局函數(shù),C++ class 也可以添加新 non-virtual 成員函數(shù)和 namespace 級別的 non-member 函數(shù),這些都不需要繼承出新 interface 就能擴充原有接口。偏偏 COM 的 interface 不能原地擴充,只能通過繼承來 workaround,產(chǎn)生一堆帶版本的 interfaces。有人說 COM 是二進制兼容性的正面例子,某深不以為然。COM 確實以一種最丑陋的方式做到了“二進制兼容”。脆弱與僵硬就是以 C++ 虛函數(shù)為接口的宿命。

相反,Linux 系統(tǒng)調(diào)用以編譯期常數(shù)方式固定下來,萬年不變,輕而易舉地解決了這個問題。在其他面向?qū)ο笳Z言(Java/C#)中,我也沒有見過每改動一次就給 interface 遞增版本號的詭異做法。

還是應(yīng)了《The Zen of Python》中的那句話,Explicit is better than implicit, Flat is better than nested.

 

動態(tài)庫的接口的推薦做法

取決于動態(tài)庫的使用范圍,有兩類做法。

如果,動態(tài)庫的使用范圍比較窄,比如本團隊內(nèi)部的兩三個程序在用,用戶都是受控的,要發(fā)布新版本也比較容易協(xié)調(diào),那么不用太費事,只要做好發(fā)布的版本管理就行了。再在可執(zhí)行文件中使用 rpath 把庫的完整路徑確定下來。

比如現(xiàn)在 Graphics 庫發(fā)布了 1.1.0 和 1.2.0 兩個版本,這兩個版本可以不必是二進制兼容。用戶的代碼從 1.1.0 升級到 1.2.0 的時候要重新編譯一下,反正他們要用新功能都是要重新編譯代碼的。如果要原地打補丁,那么 1.1.1 應(yīng)該和 1.1.0 二進制兼容,而 1.2.1 應(yīng)該和 1.2.0 兼容。如果要加入新的功能,而新的功能與 1.2.0 不兼容,那么應(yīng)該發(fā)布到 1.3.0 版本。

為了便于檢查二進制兼容性,可考慮把庫的代碼的暴露情況分辨清楚。muduo 的頭文件和 class 就有意識地分為用戶可見和用戶不可見兩部分,見 http://blog.csdn.net/Solstice/archive/2010/08/29/5848547.aspx#_Toc32039。對于用戶可見的部分,升級時要注意二進制兼容性,選用合理的版本號;對于用戶不可見的部分,在升級庫的時候就不必在意。另外 muduo 本身設(shè)計來是以靜態(tài)庫方式發(fā)布,在二進制兼容性方面沒有做太多的考慮。

 

如果庫的使用范圍很廣,用戶很多,各家的 release cycle 不盡相同,那么推薦 pimpl 技法[2, item 43],并考慮多采用 non-member non-friend function in namespace [1, item 23] [2, item 44 abd 57] 作為接口。這里以前面的 Graphics 為例,說明 pimpl 的基本手法。

1. 暴露的接口里邊不要有虛函數(shù),而且 sizeof(Graphics) == sizeof(Graphics::Impl*)。

class Graphics
{
 public:
  Graphics(); // outline ctor
  ~Graphics(); // outline dtor

  void drawLine(int x0, int y0, int x1, int y1);
  void drawLine(Point p0, Point p1);

  void drawRectangle(int x0, int y0, int x1, int y1);
  void drawRectangle(Point p0, Point p1);

  void drawArc(int x, int y, int r);
  void drawArc(Point p, int r);

 private:
  class Impl;
  boost::scoped_ptr<Impl> impl;
};

2. 在庫的實現(xiàn)中把調(diào)用轉(zhuǎn)發(fā) (forward) 給實現(xiàn) Graphics::Impl ,這部分代碼位于 .so/.dll 中,隨庫的升級一起變化。

#include <graphics.h>

class Graphics::Impl
{
 public:
  void drawLine(int x0, int y0, int x1, int y1);
  void drawLine(Point p0, Point p1);

  void drawRectangle(int x0, int y0, int x1, int y1);
  void drawRectangle(Point p0, Point p1);

  void drawArc(int x, int y, int r);
  void drawArc(Point p, int r);
};

Graphics::Graphics()
  : impl(new Impl)
{
}

Graphics::~Graphics()
{
}

void Graphics::drawLine(int x0, int y0, int x1, int y1)
{
  impl->drawLine(x0, y0, x1, y1);
}

void Graphics::drawLine(Point p0, Point p1)
{
  impl->drawLine(p0, p1);
}

// ...

3. 如果要加入新的功能,不必通過繼承來擴展,可以原地修改,且很容易保持二進制兼容性。先動頭文件:

--- old/graphics.h     2011-03-12 15:34:06.000000000 +0800
+++ new/graphics.h    2011-03-12 15:14:12.000000000 +0800
@@ -7,19 +7,22 @@
 class Graphics
 {
  public:
   Graphics(); // outline ctor
   ~Graphics(); // outline dtor

   void drawLine(int x0, int y0, int x1, int y1);
+  void drawLine(double x0, double y0, double x1, double y1);
   void drawLine(Point p0, Point p1);

   void drawRectangle(int x0, int y0, int x1, int y1);
+  void drawRectangle(double x0, double y0, double x1, double y1);
   void drawRectangle(Point p0, Point p1);

   void drawArc(int x, int y, int r);
+  void drawArc(double x, double y, double r);
   void drawArc(Point p, int r);

  private:
   class Impl;
   boost::scoped_ptr<Impl> impl;
 };

然后在實現(xiàn)文件里增加 forward,這么做不會破壞二進制兼容性,因為增加 non-virtual 函數(shù)不影響現(xiàn)有的可執(zhí)行文件。

--- old/graphics.cc    2011-03-12 15:15:20.000000000 +0800
+++ new/graphics.cc   2011-03-12 15:15:26.000000000 +0800
@@ -1,35 +1,43 @@
 #include <graphics.h>

 class Graphics::Impl
 {
  public:
   void drawLine(int x0, int y0, int x1, int y1);
+  void drawLine(double x0, double y0, double x1, double y1);
   void drawLine(Point p0, Point p1);

   void drawRectangle(int x0, int y0, int x1, int y1);
+  void drawRectangle(double x0, double y0, double x1, double y1);
   void drawRectangle(Point p0, Point p1);

   void drawArc(int x, int y, int r);
+  void drawArc(double x, double y, double r);
   void drawArc(Point p, int r);
 };

 Graphics::Graphics()
   : impl(new Impl)
 {
 }

 Graphics::~Graphics()
 {
 }

 void Graphics::drawLine(int x0, int y0, int x1, int y1)
 {
   impl->drawLine(x0, y0, x1, y1);
 }

+void Graphics::drawLine(double x0, double y0, double x1, double y1)
+{
+  impl->drawLine(x0, y0, x1, y1);
+}
+
 void Graphics::drawLine(Point p0, Point p1)
 {
   impl->drawLine(p0, p1);
 }

采用 pimpl 多了一道 explicit forward 的手續(xù),帶來的好處是可擴展性與二進制兼容性,通常是劃算的。pimpl 扮演了編譯器防火墻的作用。

pimpl 不僅 C++ 語言可以用,C 語言的庫同樣可以用,一樣帶來二進制兼容性的好處,比如 libevent2 里邊的 struct event_base 是個 opaque pointer,客戶端看不到其成員,都是通過 libevent 的函數(shù)和它打交道,這樣庫的版本升級比較容易做到二進制兼容。

 

為什么 non-virtual 函數(shù)比 virtual 函數(shù)更健壯?因為 virtual function 是 bind-by-vtable-offset,而 non-virtual function 是 bind-by-name。加載器 (loader) 會在程序啟動時做決議(resolution),通過 mangled name 把可執(zhí)行文件和動態(tài)庫鏈接到一起。就像使用 Internet 域名比使用 IP 地址更能適應(yīng)變化一樣。

 

萬一要跨語言怎么辦?很簡單,暴露 C 語言的接口。Java 有 JNI 可以調(diào)用 C 語言的代碼;Python/Perl/Ruby 等等的解釋器都是 C 語言編寫的,使用 C 函數(shù)也不在話下。C 函數(shù)是 Linux 下的萬能接口。

本文只談了使用 class 為接口,其實用 free function 有時候更好(比如 muduo/base/Timestamp.h 除了定義 class Timestamp,還定義了 muduo::timeDifference() 等 free function),這也是 C++ 比 Java 等純面向?qū)ο笳Z言優(yōu)越的地方。留給將來再細談吧。

參考文獻

[1] Scott Meyers, 《Effective C++》 第 3 版,條款 35:考慮 virtual 函數(shù)以外的其他選擇;條款 23:寧以 non-member、non-friend 替換 member 函數(shù)

[2] Herb Sutter and Andrei Alexandrescu, 《C++ 編程規(guī)范》,條款 39:考慮將 virtual 函數(shù)做成 non-public,將 public 函數(shù)做成 non-virtual;條款 43:明智地使用 pimpl;條款 44:盡可能編寫 nonmember, nonfriend 函數(shù);條款 57:將 class 和其非成員函數(shù)接口放入同一個 namespace

[3] 孟巖,《function/bind的救贖(上)》,《回復(fù)幾個問題》中的“四個半抽象”。

[4] 陳碩,《以 boost::function 和 boost:bind 取代虛函數(shù)》,《樸實的 C++ 設(shè)計》。

知識共享許可協(xié)議
作品采用知識共享署名-非商業(yè)性使用-相同方式共享 3.0 Unported許可協(xié)議進行許可。

posted @ 2011-03-13 09:04 陳碩 閱讀(12318) | 評論 (8)編輯 收藏

C++ 工程實踐(4):二進制兼容性

陳碩 (giantchen_AT_gmail)

Blog.csdn.net/Solstice

本文主要討論 Linux x86/x86-64 平臺,偶爾會舉 Windows 作為反面教材。

C/C++ 的二進制兼容性 (binary compatibility) 有多重含義,本文主要在“頭文件和庫文件分別升級,可執(zhí)行文件是否受影響”這個意義下討論,我稱之為 library (主要是 shared library,即動態(tài)鏈接庫)的 ABI (application binary interface)。至于編譯器與操作系統(tǒng)的 ABI 留給下一篇談 C++ 標(biāo)準(zhǔn)與實踐的文章。

什么是二進制兼容性

在解釋這個定義之前,先看看 Unix/C 語言的一個歷史問題:open() 的 flags 參數(shù)的取值。open(2) 函數(shù)的原型是

int open(const char *pathname, int flags);

其中 flags 的取值有三個: O_RDONLY,  O_WRONLY,  O_RDWR。

與一般人的直覺相反,這幾個值不是按位或 (bitwise-OR) 的關(guān)系,即 O_RDONLY | O_WRONLY != O_RDWR。如果你想以讀寫方式打開文件,必須用 O_RDWR,而不能用 (O_RDONLY | O_WRONLY)。為什么?因為 O_RDONLY, O_WRONLY, O_RDWR 的值分別是 0, 1, 2。它們不滿足按位或

那么為什么 C 語言從誕生到現(xiàn)在一直沒有糾正這個不足之處?比方說把 O_RDONLY, O_WRONLY, O_RDWR 分別定義為 1, 2, 3,這樣 O_RDONLY | O_WRONLY == O_RDWR,符合直覺。而且這三個值都是宏定義,也不需要修改現(xiàn)有的源代碼,只需要改改系統(tǒng)的頭文件就行了。

因為這么做會破壞二進制兼容性。對于已經(jīng)編譯好的可執(zhí)行文件,它調(diào)用 open(2) 的參數(shù)是寫死的,更改頭文件并不能影響已經(jīng)編譯好的可執(zhí)行文件。比方說這個可執(zhí)行文件會調(diào)用 open(path, 1) 來文件,而在新規(guī)定中,這表示文件,程序就錯亂了。

以上這個例子說明,如果以 shared library 方式提供函數(shù)庫,那么頭文件和庫文件不能輕易修改,否則容易破壞已有的二進制可執(zhí)行文件,或者其他用到這個 shared library 的 library。操作系統(tǒng)的 system call 可以看成 Kernel 與 User space 的 interface,kernel 在這個意義下也可以當(dāng)成 shared library,你可以把內(nèi)核從 2.6.30 升級到 2.6.35,而不需要重新編譯所有用戶態(tài)的程序。

所謂“二進制兼容性”指的就是在升級(也可能是 bug fix)庫文件的時候,不必重新編譯使用這個庫的可執(zhí)行文件或使用這個庫的其他庫文件,程序的功能不被破壞。

見 QT FAQ 的有關(guān)條款:http://developer.qt.nokia.com/faq/answer/you_frequently_say_that_you_cannot_add_this_or_that_feature_because_it_woul

在 Windows 下有惡名叫 DLL Hell,比如 MFC 有一堆 DLL,mfc40.dll, mfc42.dll, mfc71.dll, mfc80.dll, mfc90.dll,這是動態(tài)鏈接庫的本質(zhì)問題,怪不到 MFC 頭上。

有哪些情況會破壞庫的 ABI

到底如何判斷一個改動是不是二進制兼容呢?這跟 C++ 的實現(xiàn)方式直接相關(guān),雖然 C++ 標(biāo)準(zhǔn)沒有規(guī)定 C++ 的 ABI,但是幾乎所有主流平臺都有明文或事實上的 ABI 標(biāo)準(zhǔn)。比方說 ARM 有 EABI,Intel Itanium 有 http://www.codesourcery.com/public/cxx-abi/abi.html,x86-64 有仿 Itanium 的 ABI,SPARC 和 MIPS 也都有明文規(guī)定的 ABI,等等。x86 是個例外,它只有事實上的 ABI,比如 Windows 就是 Visual C++,Linux 是 G++(G++ 的 ABI 還有多個版本,目前最新的是 G++ 3.4 的版本),Intel 的 C++ 編譯器也得按照 Visual C++ 或 G++ 的 ABI 來生成代碼,否則就不能與系統(tǒng)其它部件兼容。

C++ ABI 的主要內(nèi)容:

  • 函數(shù)參數(shù)傳遞的方式,比如 x86-64 用寄存器來傳函數(shù)的前 4 個整數(shù)參數(shù)
  • 虛函數(shù)的調(diào)用方式,通常是 vptr/vtbl 然后用 vtbl[offset] 來調(diào)用
  • struct 和 class 的內(nèi)存布局,通過偏移量來訪問數(shù)據(jù)成員
  • name mangling
  • RTTI 和異常處理的實現(xiàn)(以下本文不考慮異常處理)

C/C++ 通過頭文件暴露出動態(tài)庫的使用方法,這個“使用方法”主要是給編譯器看的,編譯器會據(jù)此生成二進制代碼,然后在運行的時候通過裝載器(loader)把可執(zhí)行文件和動態(tài)庫綁到一起。如何判斷一個改動是不是二進制兼容,主要就是看頭文件暴露的這份“使用說明”能否與新版本的動態(tài)庫的實際使用方法兼容。因為新的庫必然有新的頭文件,但是現(xiàn)有的二進制可執(zhí)行文件還是按舊的頭文件來調(diào)用動態(tài)庫。

這里舉一些源代碼兼容但是二進制代碼不兼容例子

  • 給函數(shù)增加默認參數(shù),現(xiàn)有的可執(zhí)行文件無法傳這個額外的參數(shù)。
  • 增加虛函數(shù),會造成 vtbl 里的排列變化。(不要考慮“只在末尾增加”這種取巧行為,因為你的 class 可能已被繼承。)
  • 增加默認模板類型參數(shù),比方說 Foo<T> 改為 Foo<T, Alloc=alloc<T> >,這會改變 name mangling
  • 改變 enum 的值,把 enum Color { Red = 3 }; 改為 Red = 4。這會造成錯位。當(dāng)然,由于 enum 自動排列取值,添加 enum 項也是不安全的,除非是在末尾添加。

給 class Bar 增加數(shù)據(jù)成員,造成 sizeof(Bar) 變大,以及內(nèi)部數(shù)據(jù)成員的 offset 變化,這是不是安全的?通常不是安全的,但也有例外。

  • 如果客戶代碼里有 new Bar,那么肯定不安全,因為 new 的字節(jié)數(shù)不夠裝下新 Bar。相反,如果 library 通過 factory 返回 Bar* (并通過 factory 來銷毀對象)或者直接返回 shared_ptr<Bar>,客戶端不需要用到 sizeof(Bar),那么可能是安全的。
  • 如果客戶代碼里有 Bar* pBar; pBar->memberA = xx;,那么肯定不安全,因為 memberA 的新 Bar 的偏移可能會變。相反,如果只通過成員函數(shù)來訪問對象的數(shù)據(jù)成員,客戶端不需要用到 data member 的 offsets,那么可能是安全的。
  • 如果客戶調(diào)用 pBar->setMemberA(xx); 而 Bar::setMemberA() 是個 inline function,那么肯定不安全,因為偏移量已經(jīng)被 inline 到客戶的二進制代碼里了。如果 setMemberA() 是 outline function,其實現(xiàn)位于 shared library 中,會隨著 Bar 的更新而更新,那么可能是安全的。

那么只使用 header-only 的庫文件是不是安全呢?不一定。如果你的程序用了 boost 1.36.0,而你依賴的某個 library 在編譯的時候用的是 1.33.1,那么你的程序和這個 library 就不能正常工作。因為 1.36.0 和 1.33.1 的 boost::function 的模板參數(shù)類型的個數(shù)不一樣,其中一個多了 allocator。

這里有一份黑名單,列在這里的肯定是二級制不兼容,沒有列出的也可能二進制不兼容,見 KDE 的文檔:http://techbase.kde.org/Policies/Binary_Compatibility_Issues_With_C%2B%2B

 

哪些做法多半是安全的

前面我說“不能輕易修改”,暗示有些改動多半是安全的,這里有一份白名單,歡迎添加更多內(nèi)容。

只要庫改動不影響現(xiàn)有的可執(zhí)行文件的二進制代碼的正確性,那么就是安全的,我們可以先部署新的庫,讓現(xiàn)有的二進制程序受益。

  • 增加新的 class
  • 增加 non-virtual 成員函數(shù)
  • 修改數(shù)據(jù)成員的名稱,因為生產(chǎn)的二進制代碼是按偏移量來訪問的,當(dāng)然,這會造成源碼級的不兼容。
  • 還有很多,不一一列舉了。

歡迎補充

反面教材:COM

在 C++ 中以虛函數(shù)作為接口基本上就跟二進制兼容性說拜拜了。具體地說,以只包含虛函數(shù)的 class (稱為 interface class)作為程序庫的接口,這樣的接口是僵硬的,一旦發(fā)布,無法修改。

比方說 M$ 的 COM,其 DirectX 和 MSXML 都以 COM 組件方式發(fā)布,我們來看看它的帶版本接口 (versioned interfaces):

  • IDirect3D7, IDirect3D8, IDirect3D9, ID3D10*, ID3D11*
  • IXMLDOMDocument, IXMLDOMDocument2, IXMLDOMDocument3

換話句話說,每次發(fā)布新版本都引入新的 interface class,而不是在現(xiàn)有的 interface 上做擴充。這樣一樣不能兼容現(xiàn)有的代碼,強迫客戶端代碼也要改寫。

回過頭來看看 C 語言,C/Posix 這些年逐漸加入了很多新函數(shù),同時,現(xiàn)有的代碼不用修改也能運行得很好。如果要用這些新函數(shù),直接用就行了,也基本不會修改已有的代碼。相反,COM 里邊要想用 IXMLDOMDocument3 的功能,就得把現(xiàn)有的代碼從 IXMLDOMDocument 全部升級到 IXMLDOMDocument3,很諷刺吧。

tip:如果遇到鼓吹在 C++ 里使用面向接口編程的人,可以拿二進制兼容性考考他。

解決辦法

采用靜態(tài)鏈接

這個是王道。在分布式系統(tǒng)這,采用靜態(tài)鏈接也帶來部署上的好處,只要把可執(zhí)行文件放到機器上就行運行,不用考慮它依賴的 libraries。目前 muduo 就是采用靜態(tài)鏈接。

通過動態(tài)庫的版本管理來控制兼容性

這需要非常小心檢查每次改動的二進制兼容性并做好發(fā)布計劃,比如 1.0.x 系列做到二進制兼容,1.1.x 系列做到二進制兼容,而 1.0.x 和 1.1.x 二進制不兼容。《程序員的自我修養(yǎng)》里邊講過 .so 文件的命名與二進制兼容性相關(guān)的話題,值得一讀。 

用 pimpl 技法,編譯器防火墻

在頭文件中只暴露 non-virtual 接口,并且 class 的大小固定為 sizeof(Impl*),這樣可以隨意更新庫文件而不影響可執(zhí)行文件。當(dāng)然,這么做有多了一道間接性,可能有一定的性能損失。見 Exceptional C++ 有關(guān)條款和 C++ Coding Standards 101.

Java 是如何應(yīng)對的

Java 實際上把 C/C++ 的 linking 這一步驟推遲到 class loading 的時候來做。就不存在“不能增加虛函數(shù)”,“不能修改 data member” 等問題。在 Java 里邊用面向 interface 編程遠比 C++ 更通用和自然,也沒有上面提到的“僵硬的接口”問題。

(待續(xù))

posted @ 2011-03-09 10:48 陳碩 閱讀(13366) | 評論 (6)編輯 收藏

C++ 工程實踐(3):采用有利于版本管理的代碼格式

     摘要: 版本管理(version controlling)是每個程序員的基本技能,C++ 程序員也不例外。版本管理的基本功能之一是追蹤代碼變化,讓你能清楚地知道代碼是如何一步步變成現(xiàn)在的這個樣子,以及每次 check-in 都具體改動了哪些內(nèi)部。所謂“有利于版本管理”的代碼格式,就是指在代碼中合理使用換行符,對 diff 工具友好,讓 diff 的結(jié)果清晰明了地表達代碼的改動。  閱讀全文

posted @ 2011-03-05 15:16 陳碩 閱讀(3360) | 評論 (7)編輯 收藏

僅列出標(biāo)題
共6頁: 1 2 3 4 5 6 
<2015年10月>
27282930123
45678910
11121314151617
18192021222324
25262728293031
1234567

導(dǎo)航

統(tǒng)計

常用鏈接

隨筆分類

隨筆檔案

相冊

搜索

最新評論

閱讀排行榜

評論排行榜

青青草原综合久久大伊人导航_色综合久久天天综合_日日噜噜夜夜狠狠久久丁香五月_热久久这里只有精品
  • <ins id="pjuwb"></ins>
    <blockquote id="pjuwb"><pre id="pjuwb"></pre></blockquote>
    <noscript id="pjuwb"></noscript>
          <sup id="pjuwb"><pre id="pjuwb"></pre></sup>
            <dd id="pjuwb"></dd>
            <abbr id="pjuwb"></abbr>
            欧美一区2区视频在线观看| 欧美极品aⅴ影院| 欧美高清在线精品一区| 久久在线播放| 亚洲高清中文字幕| 玖玖玖国产精品| 国产精品视频一区二区三区| 久久嫩草精品久久久精品一| 久久精品在线播放| 久久影音先锋| 亚洲国产精品va在线看黑人动漫 | 一区二区三区**美女毛片| 亚洲欧洲精品一区| 中文亚洲视频在线| 午夜激情一区| 欧美国产一区二区| 亚洲精品久久7777| 午夜视黄欧洲亚洲| 另类图片国产| 国产精品视频网| 亚洲精品国产精品乱码不99| 亚洲欧美日本另类| 欧美国产精品中文字幕| 日韩视频欧美视频| 久久精品亚洲乱码伦伦中文| 欧美日韩国产限制| 国语精品一区| 亚洲欧美日韩国产一区| 欧美电影在线观看| 久久成人免费网| 国产精品伦一区| 99精品国产高清一区二区| 久久久久久亚洲精品不卡4k岛国| 亚洲精选国产| 久久一区亚洲| 国内精品国产成人| 亚洲一区免费在线观看| 亚洲成人自拍视频| 久久www免费人成看片高清 | 欧美α欧美αv大片| 国产日韩欧美日韩大片| 中日韩美女免费视频网站在线观看| 久久综合久色欧美综合狠狠| 这里只有精品在线播放| 欧美美女操人视频| 在线日韩中文| 美女91精品| 欧美中文在线视频| 国产美女扒开尿口久久久| 亚洲综合电影| 一本色道88久久加勒比精品| 欧美国产在线电影| 亚洲精品黄色| 亚洲国产精品va在看黑人| 久久久欧美精品sm网站| 黄色成人av在线| 麻豆成人综合网| 久久免费高清视频| 永久域名在线精品| 欧美成人免费大片| 免费成人激情视频| 亚洲高清久久网| 欧美精品久久99| 99精品欧美一区二区三区| 亚洲大片在线观看| 欧美理论电影在线播放| 一本大道久久a久久精品综合| 亚洲国产欧美不卡在线观看| 欧美成人免费网| 一本一本久久| 亚洲香蕉成视频在线观看| 国产精品免费一区二区三区观看| 午夜精彩视频在线观看不卡| 亚洲欧美日韩综合国产aⅴ| 国产深夜精品福利| 乱中年女人伦av一区二区| 玖玖视频精品| 中文国产成人精品| 亚洲一区二区三区精品视频| 国产婷婷成人久久av免费高清| 久久中文字幕一区| 欧美成人情趣视频| 亚洲欧美国产va在线影院| 欧美专区福利在线| 亚洲人成网站在线观看播放| 日韩视频免费观看| 国产在线精品二区| 亚洲精品在线电影| 国产日韩欧美不卡| 亚洲国产精品va在看黑人| 国产精品剧情在线亚洲| 欧美不卡福利| 国产欧美日韩精品一区| 亚洲国产精品嫩草影院| 国产欧美日本| 99精品热视频| 在线观看亚洲一区| 亚洲一区二区日本| 亚洲精品老司机| 欧美在线黄色| 99视频在线精品国自产拍免费观看| 亚洲综合国产| 99精品视频免费观看视频| 久久er精品视频| 性一交一乱一区二区洋洋av| 欧美成黄导航| 另类av一区二区| 国产女人精品视频| 亚洲日本成人| 亚洲第一在线视频| 亚洲欧美日本视频在线观看| 亚洲乱码精品一二三四区日韩在线 | 久久综合狠狠综合久久综合88| 亚洲性感激情| 欧美成人免费观看| 久久久久久久久久久久久女国产乱| 欧美人与性动交a欧美精品| 老鸭窝91久久精品色噜噜导演| 国产精品亚洲激情| 一区二区毛片| 亚洲伊人色欲综合网| 欧美国产日韩在线观看| 美国十次成人| 国产精品一区视频| 国产欧美午夜| 亚洲一区二区三区在线观看视频| 亚洲精品女人| 欧美va亚洲va香蕉在线| 你懂的国产精品永久在线| 激情懂色av一区av二区av| 亚洲欧美一级二级三级| 午夜一区不卡| 国产欧美精品在线| 亚洲欧美视频一区| 久久精品二区三区| 国内精品久久久久久影视8 | 亚洲在线播放| 国产精品实拍| 欧美一级在线视频| 久久精品中文| 在线免费观看日本欧美| 卡一卡二国产精品| 欧美二区在线观看| 亚洲乱码国产乱码精品精可以看| 欧美国产日本韩| 日韩一级片网址| 新狼窝色av性久久久久久| 国产精品福利久久久| 亚洲综合色丁香婷婷六月图片| 久久精品99| 亚洲欧洲精品一区二区三区不卡| 欧美理论电影网| 欧美在线播放视频| 欧美国产丝袜视频| 亚洲视频视频在线| 国产亚洲人成a一在线v站| 美女久久网站| 亚洲图中文字幕| 免费亚洲电影在线| 日韩亚洲欧美在线观看| 国产精品黄页免费高清在线观看| 在线亚洲美日韩| 美女主播一区| 亚洲一本视频| 精品动漫一区| 欧美色欧美亚洲另类二区| 午夜精品久久久久久| 欧美激情二区三区| 欧美一级专区免费大片| 亚洲精品视频啊美女在线直播| 国产精品久久久久久模特| 久久久久国产一区二区三区| 亚洲国产综合91精品麻豆| 亚洲制服丝袜在线| 亚洲高清久久久| 国产精品亚洲激情| 欧美黄色大片网站| 欧美一区成人| 亚洲最新合集| 亚洲二区在线观看| 久久狠狠亚洲综合| 亚洲一级二级| 91久久精品一区| 国产一区二区三区久久| 欧美人与禽猛交乱配视频| 久久青草欧美一区二区三区| 在线视频亚洲欧美| 亚洲国产精品va| 久久一区亚洲| 久久国产精品久久国产精品| aa成人免费视频| 亚洲国产日韩美| 国产中文一区二区三区| 午夜在线成人av| 欧美亚洲视频一区二区| 亚洲国产日韩在线一区模特| 国产女人水真多18毛片18精品视频| 欧美日韩国产探花| 欧美精品二区三区四区免费看视频| 久久国产精品一区二区三区四区 |