Thrift源碼剖析

因爲工做的關係,須要定位一個 bug 是否和 Thrift 有關, 因此用了一下午的時間研讀了 Thrift-0.9.0 代碼,雖然發現這個 bug 和 thrift 無關。 可是讀源碼仍是有所收穫,因此整理成這篇文章,不過不太適合 Thrift 剛入門的人。html

如下內容基於 thrift-0.9.0 。c++

總體脈絡

Thrift 幾乎支持全部的語言。在此針對 Thrift 的 C++ lib 來說。
也就是基於根目錄 thrift-0.9.0/lib/cpp/src/thriftapache

Thrift 最核心的幾個模塊目錄以下(按自底向上排序):編程

  • transport/
  • protocol/
  • processor/
  • server/

四大模塊

Transport服務器

實際上是網絡通訊,如今都是基於 TCP/IP , 而 TCP/IP 協議棧由 socket 來實現,也就是如今的網絡通訊服務器, 最底層都是經過 socket 的, Thrift 也不例外, 而在 Thrift 源碼中,則是經過將 socket 包裝成各類 Transport 來使用。 對應的源碼目錄就是 thrift-0.9.0/lib/cpp/src/thrift/transport 。 大部分和網絡數據通訊相關的代碼都是放在這個目錄之下。網絡

Protocol數據結構

Thrift 支持各類語言 ,是經過一個 x.thrift 的描述文件來通訊。 thrift 描述文件是各類語言通用的, 可是須要經過 thrift 的代碼生成器(好比 c++ 對應的是 thrift --gen cpp x.thrift)來生成對應的源代碼。多線程

那爲何不一樣的代碼能夠直接互相調用接口函數呢, 其實就是由於制定了同一套協議, 就像 HTTP 協議, 你根本不須要知道實現 HTTP 服務器的是什麼語言編寫的, 只須要遵照 HTTP 標註來調用便可。併發

因此其實 protocol 就是 transport 的上一層。 transport 負責數據傳輸, 可是要使得程序知道傳輸的數據具體是什麼, 還得靠 protocol 這個組件來對數據進行解析, 解析成對應的結構代碼,供程序直接調用。框架

Processor

經過  transport 和 protocol 這兩層以後, 程序已經能夠得到對應的數據結構,可是數據結構須要被使用纔有價值。 在 Thrift 裏面,就是被 processor ,好比咱們定義了一個描述文件叫 foo.thrift

內容以下:

service Foo {
  string GetName()
}

經過 thrift --gen cpp foo.thrift 以後就會生成 gen-cpp 目錄下面一堆代碼。 裏面最關鍵的兩個文件就是 Foo.h 和 Foo.cpp 。 裏面和 processor 相關的類是 : 

class FooProcessor : public ::apache::thrift::TDispatchProcessor {
 protected:
  boost::shared_ptr<FooIf> iface_;
  virtual bool dispatchCall(::apache::thrift::protocol::TProtocol* iprot, ::apache::thrift::protocol::TProtocol* oprot, const std::string& fname, int32_t seqid, void* callContext);
 private:
  typedef  void (FooProcessor::*ProcessFunction)(int32_t, ::apache::thrift::protocol::TProtocol*, ::apache::thrift::protocol::TProtocol*, void*);
  typedef std::map<std::string, ProcessFunction> ProcessMap;
  ProcessMap processMap_;
  void process_GetName(int32_t seqid, ::apache::thrift::protocol::TProtocol* iprot, ::apache::thrift::protocol::TProtocol* oprot, void* callContext);
 public:
  FooProcessor(boost::shared_ptr<FooIf> iface) :
    iface_(iface) {
    processMap_["GetName"] = &FooProcessor::process_GetName;
  }

  virtual ~FooProcessor() {}
};

能夠看出該 processor 繼承自 TDispatchProcessor 。

注意到如下幾點:

1. dispatchCall 這個函數是個虛函數,而看 TDispatchProcessor 源碼就能夠明白, 這個函數是被供 TDispatchProcessor 類中的虛函數 process 來調用的。 這裏是用了多態是進行動態調用的。 這樣的用法其實很常見,由於所需用到的場景很是多, 你在父類,也就是 TDispatchProcessor 這個類中暴漏給外界的接口是 process , 而具體的實現須要在不一樣子類裏面進行不一樣的實現, 因此定義出 dispatchCall 這個純虛函數 強制子類實現之。 而父類在 process 函數中適當的調用 dispatchCall 便可。

2. 

typedef std::map<std::string, ProcessFunction> ProcessMap;
ProcessMap processMap_;
...
processMap_["GetName"] = &FooProcessor::process_GetName;

注意到以上三行代碼是頗有意思的點發, 由於咱們在 foo.thrift 文件裏面定義了一個函數叫 GetName , 而在FooProcessor裏面的函數定義是process_GetName, 其中使用了一個 map<string, ProcessFunction> 將他們對應起來。

其實這就是反射,可讓外界經過字符串類型的函數名來指明調用的函數。 從而實現函數的動態調用。 可是這樣的反射其實很是低效,由於每次 map.find 須要對比字符串的大小。 能夠說這也註定 Thrift 不打算成爲一個高性能的 RPC 服務器框架。

到此爲止能夠大概猜出來每次 RPC 調用的過程是:

  1. 客戶端指定要調用的函數,好比是 GetName 。
  2. 將該函數名經過 protocol 進行編碼,編碼後的數據經過 transport 傳輸給服務端。
  3. 服務端接收到數據以後,經過和客戶端同樣的 protocol 解碼數據。
  4. 解碼後的數據得到須要調用的函數名,好比是 GetName,而後經過 FooProcessor 的 processMap_ 去找出對應 ProcessFunction 來調用。

不過接下來纔是重頭戲: server 模塊。

Server

衆所周知,當前最流程的兩種高性能服務器編程範式是 多線程 和 異步調用 。 這裏展開講又是很大的一個話題。這裏簡單的說一下: 根據高併發編程聞名已久的那篇文章 c10k 所倡導的來講, 多線程當併發數高的時候,內存會成爲併發數的瓶頸, 而異步編程而沒有相關的困擾,是是解決高併發服務的最佳實踐。 (注意這裏說的是解決高併發的最佳實踐) 我我的的觀點是異步編程是把雙刃劍。 它確實對高併發服務很友好,特別是對於IO密集型的服務。 可是對於業務邏輯的開發並不友好。 好比 Nginx 是將 異步IO 使用得登峯造極的做品, 而 Node.js 則由於異步常常把業務邏輯弄得支離破碎。

這二者 Thrift 都有對應的 server 類供開發人員使用。

在 Thrift 中,有如下四種 server :

  1. TSimpleServer.cpp
  2. TThreadedServer.cpp
  3. TThreadPoolServer.cpp
  4. TNonblockingServer.cpp

第一種 TSimpleServer 最簡單,就是單線程服務器,沒什麼可說的。

多線程服務器

第二種和第三種都是 多線程 模型。 這二者的不一樣點只是在於前者是每一個鏈接進來會新建一個線程去接受該鏈接。 直到對應的鏈接被關閉,該線程纔會被銷燬。 服務的線程數和鏈接數相等,有多少個鏈接就有多少個線程。 然後者是鏈接池的形式,在系統啓動的時候就設定好線程數的大小,好比線程數設置爲32 。 每次新的鏈接過來的時候,就向線程池 申請 一個線程來處理該鏈接。 直到該鏈接被釋放,該線程纔會被回收到線程池。 若是線程池被申請到空時,下一次申請則會阻塞, 阻塞直到線程池非空(也就是有線程被回收時)。

這二者各有利弊,前者的缺點主要是當鏈接數過大的時候, 會把內存撐爆,這就是以前 C10K 說的併發數太大內存不夠用的狀況。 後者的缺點則是當線程池的線程被用完時,下一次的鏈接請求則會失敗(阻塞住)。 因此當使用 TThreadPoolServer 的時候,若是發現客戶端鏈接失敗了, 十有八九都是由於線程池的線程供不該求了。 總之,開發者能夠針對不一樣的場景選用不一樣的服務模型。

TThreadedServer

說說源碼細節:

TThreadedServer 有一個成員變量叫 serverTransport_ ,做爲服務器的主 transport (其實就是主 socket) 。 監聽端口【listen】,和接受請求【accept】 。 這裏須要注意的是,這裏的 serverTransport_ 實際上是個非阻塞 socket 。 非阻塞的過程是藉助了 poll (不是 epoll ),來實現,將 serverTransport_ 在 poll 裏面註冊,不過呢,註冊的時候設置了 timeout 時間。 在 thrift-0.9.0 裏面的超時時間是 3 seconds 。 也就是能夠理解爲其實每次 serverTransport_->accept() 函數退出時不必定是接受到請求了。 也有多是超時時間到了。 具體能夠看 thrift-0.9.0/lib/cpp/src/thrift/transport/TServerSocket.cpp 文件裏 360行的函數 TServerSocket::acceptImpl() 的實現過程。 因此在 TThreadedServer 的實現裏面, 須要用 while(_stop) 輪詢進行 serverTransport_->accept()的調用。 這個輪詢在沒有任何鏈接請求的時候,每次循環一次的間隔是 3s, 也就是以前設置的 超時時間。

而當鏈接進來的時候,serverTransport_->accept() 就會當即返回接受到的新 client 。

而後接下來的過程就是 Task 上場了。 Task 就是將 transportprotocolprocessor 包裝起來而已。 就像上文說的,整個調用的過程從底層往高層就是以此調用 transportprotocolprocessor 來處理請求的。 因此直接使用包裝它們的 Task,將 Task 綁定到一個線程並啓動該線程, 再把 Task 插入任務集合中便可。 注意到,以前的 while(_stop) 輪詢退出時,會檢測該任務集合, 若是任務集合不爲空,則會阻塞直到任務集合爲空,TThreadedServer 的 server 函數纔會退出。 細節請看 thrift-0.9.0/lib/cpp/src/thrift/server/TThreadedServer.cpp 第40行的 class TThreadedServer::Task: public Runnable 函數實現。

當鏈接進來的時候,會新建一個 Task 扔進任務隊列。 當鏈接斷開的時候,該 Task 對應的線程會執行完畢,在退出以前會從任務隊列中刪除該任務。 可是當客戶端遲遲不主動斷開鏈接呢? 答案是線程就會遲遲不退出,任務隊列就會一直保持非空狀態。 緣由在 Task 的 run 函數裏面,會循環調用 thrift-0.9.0/lib/cpp/src/thrift/server/TThreadedServer.cpp 71行 裏面的 peek() 函數,這個 peek() 函數是阻塞型函數。 功能是窺探客戶端是否有新的函數調用請求,若是沒有, 則阻塞等待直到客戶端發送函數調用請求。

TThreadPoolServer

按照上文理解了 ThreadedServer 以後,ThreadPoolServer 就沒什麼好說的了。 基本上就是【同理可得】。

非阻塞型服務器

第四種 TNonblockingServer 就是傳說中的 異步服務器模型(非阻塞服務器模型)。 在 Thrift 中使用該模型須要依賴 libevent 。 這個比較複雜,之後有時間再單獨寫一篇解析吧。(補充:Thrift異步IO服務器源碼分析

相關文章
相關標籤/搜索