輕量級I/O事件驅動的高性能C++網絡庫wethands——muduo項目拆解及實現

一、背景

去年夏天開始看了陳碩前輩的書,疫情在家期間零零碎碎花了近兩個月時間終於把碩神的muduo庫代碼讀完了。學習一項技能最高效的方法就是實踐,出於學習前輩工程經驗的目的,本人仿照muduo庫實現一個簡易版的高性能網絡庫。此帖記錄了我的實現過程和一些思考。

讀源碼的過程中我把學習過程的理解寫到了代碼註釋中,便於反覆回看時整理思路。

這裏是本人實現的muduo庫簡易版本 wethands,全部代碼量5685行(muduo庫有11414行),幾乎實現了muduo庫中所有的類,編碼過程花費了34天的時間。項目的名字來源於 Minecraft 裏一首讓我印象深刻的 BGM,每當主角辛勤勞作時它總會不經意地響起。

muduo代碼註釋地址: https://github.com/GGGGITFKBJG/lab/tree/master/mdl
wethands代碼庫地址: https://github.com/GGGGITFKBJG/wethands

二、開始前的準備

muduo產生於2010年,到如今已有10年時間。現在的muduo庫代碼經過了許多更新和優化,已經略顯複雜了。出於學習的目的,我想我要實現的網絡庫,應該是一個忽略邊緣細枝末節的輕量版本,所以要抓住重點部分、忽略次要部分。下面是我的思路:

  1. 首要原則是保留核心思想的同時儘可能地簡化工作量。
  2. 對於muduo代碼中一些不影響大局的細節(例如時間處理中有關曆法、時區的部分,日誌類中有關異常及demangle部分,以及對第三方庫gzip和protobuffer等支持部分),簡化或者忽略;
  3. 對於作者出於某些考慮而自建的一些工具,如果性能不是大問題且在標準庫中有替代品,那就用標準庫替代;
  4. 編譯及運行環境只關注linux、gcc和CMake,不考慮多平臺移植問題;
  5. 不依賴 boost 庫。

編碼儘量按Google代碼規範來編寫。

設計思路參考muudo庫。


學習過程中的參考資料:

  1. 首先當然是碩神本人的書《Linux多線程服務端編程》,許多設計思想上的問題,都能從這裏找到答案;
  2. 史蒂文斯前輩的經典著作《UNIX高級編程》和《UNIX網絡編程 卷1》。翻得最多的兩本書,重要性不必說了;
  3. 作爲補充,還有Michael Kerrisk前輩的《Linux/UNIX系統編程手冊》上、下兩冊,以及他的參考網站;
  4. C++語言鉅著《C++ Primer》第五版,可以用 cppreference.com 作爲替代;
  5. 一些資料網站,B站上有“大併發服務器開發”系列視頻,講解了muduo庫的實現及應用。這個比較費時間且低效,可以作爲剛開始的入門資料;
  6. 最後也是最重要的 muduo 代碼本身,至少 80% 的知識和經驗都從直接閱讀代碼獲得。

三、實現過程記錄

1. 項目框架規劃

閱讀源代碼第一步要先搞清楚它的目錄結構及編譯過程。本項目代碼的整體目錄結構如下:

wethands
    |---src
    |    |---net
    |    |     |---tests
    |    |---reactor
    |    |     |---tests
    |    |---thread
    |    |     |---tests
    |    |---logger
    |    |     |---tests
    |    |---utils
    |    |     |---tests

爲了把工作任務細分,我決定把代碼按功能分爲五個文件夾,對應整個項目的五大組成部分:

  1. utils: (時間、文件、緩存等)輔助工具類;
  2. thread: 線程及同步工具封裝;
  3. logger: 異步日誌記錄系統實現;
  4. net: 網絡基礎組件封裝;
  5. reactor: Reactor 模式實現。

編譯採用 CMake,編譯生成的文件全部放到 wethands/build/debug/ (或 wethands/build/release/) 目錄下,其中 bin/ 目錄存放各個tests的可執行程序,lib/ 存放編譯生成的靜態鏈接庫(目前就只有一個libwetlhands.a)。

編譯方法很簡單,切換到wethands目錄下,執行 ./build.sh 即可。

如果需要 release 版本,執行 BUILD_TYPE=release ./build.sh

這個腳本只是創建了 build 目錄並執行 cmake、make命令:

#! /bin/sh

PROJECT_DIR=$(pwd)
BUILD_DIR=${PROJECT_DIR}/build
BUILD_TYPE=${BUILD_TYPE:-debug} # release

mkdir -p ${BUILD_DIR}/${BUILD_TYPE} \
  && cd ${BUILD_DIR}/${BUILD_TYPE} \
  && cmake \
           -DCMAKE_BUILD_TYPE=${BUILD_TYPE} \
           -DCMAKE_EXPORT_COMPILE_COMMANDS=ON \
           ${PROJECT_DIR} \
  && make $*

細節請參考各級 CMakeLists.txt 文件。

後面簡要介紹一下各部分的大體思路,細節建議直接閱讀代碼會更直觀些。


2. 輔助工具類 ———— utils 文件夾

utils 文件夾中的代碼文件及簡單描述如下:

utils
    |---BlockingQueue.h          # 無界阻塞隊列
    |---BoundedBlockingQueue.h   # 有界阻塞隊列
    |---Copyable.h               # 可拷貝對象標記類
    |---FileUtil.cc              # 文件相關工具
    |---FileUtil.h               #
    |---operators.h              # boost 中的 operators.hpp 部分實現
    |---Timestamp.cc             # 時間戳類
    |---Timestamp.h              #
    |---Uncopyable.h             # 不可拷貝對象標記類
    |---WeakCallback.h           # 弱回調函數對象
    |---Singleton.h              # 單例類模板
  • Timestamp.cc
    本項目第一個要實現的文件大概就是 Timestamp 類了,它的使用貫穿整個項目。
    它是一個可拷貝對象(標記類實現在Copyable.h中,見《Effective C++ 3th》條款06),其內部僅有的數據成員是一個64位整型值,保存了從1970年1月1日起始時刻至現在的微秒數。可以用於定時器、日誌記錄器等任何需要記錄時間的地方。
    爲了實現可比較大小,需要重載 “<” 和 “==” 運算符,然後簡單地繼承 LessThanComparableEqualityComparable 這兩個類(實現在 operators.h 中)。
    其可進行的操作包括:將時間轉換爲字符串形式(本地時間)、按天向下圓整(用於日誌系統定期寫入文件)、求時間差等等。
  • FileUtil.cc
    該文件目前只有 AppendFile 類,用於日誌文件寫入。
  • BlockingQueue.h 和 BoundedBlockingQueue.h
    線程安全的無界阻塞隊列和有界阻塞隊列。可用於實現 “生產者-消費者” 模式。
    BlockingQueue 較簡單,使用 deque 實現。
    爲了擺脫對 boost 的依賴,BoundedBlockingQueue 內部首先使用 vector 實現了一個 CircularQueue,用以代替 boost 中的 circular_buffer。
    這兩個類在本項目中唯一可能會被用到的地方是在 ThreadPool.cc 中,但是出於一些對實現細節的考慮(因爲其內部的互斥鎖限制了靈活性),它並沒有被採用。所以就本項目而言,這兩個類並非必要,可提供給用戶使用。
  • WeakCallback.h
    WeakCallback 其實就是一個函數對象的包裝,持有一個對象的 weak_ptr,僅當在調用時刻對象存活時,才調用函數對象。它的使用非常少,本項目中用來處理與TCP連接有關的回調。實現使用了模版及變長模板參數,從語法或從其用途看起來可能都不那麼直觀,初次閱讀代碼可以跳過該文件,建議閱讀到其使用時再返回來看該文件。
  • Singleton.h
    一個單例類模板。內部使用 linux 的 pthread_once 接口實現。

3. 線程及同步工具類 ———— thread 文件夾

thread 文件夾中的代碼文件及簡單描述如下:

thread
    |---Atomic.h           # 原子整型
    |---Condition.cc       # 對 linux 原生條件變量的封裝
    |---Condition.h        #
    |---CountDownLatch.cc  # 用 Condition 實現的倒計門閂
    |---CountDownLatch.h   #
    |---CurrentThread.cc   # 當前線程信息和方法的集合
    |---CurrentThread.h    #
    |---Mutex.h            # 對 linux 原生互斥量的封裝
    |---Thread.cc          # 對 pthread 線程的封裝
    |---Thread.h           #
    |---ThreadPool.cc      # 線程池的實現
    |---ThreadPool.h       #
  • Atomic.h
    這個原子整型使用了 GCC 提供的 CAS 接口,使用 C++11 的 std::atomic 一樣能達到目的。目的就是在某些場合儘可能減少或避免互斥量的使用。
  • CountDownLatch.cc
    以條件變量實現。條件變量使用時要和互斥量配合,而 CountDownLatch 做了更進一步地封裝,簡化了繁瑣的使用過程。其內部有一個互斥量和一個條件變量,初始時給定一個計數值,當計數值減爲0時喚醒所有阻塞在等待條件上的線程。
  • CurrentThread.cc
    CurrentThread 是一個集合了當前線程信息和方法的命名空間。保存了線程id,線程名等信息。
  • ThreadPool.cc
    內部使用 deque 維護了一個待處理任務隊列,以及一個 vector 存放子線程指針。經典的 “生產者-消費者” 模式。

線程部分沒有進行異常處理, 如果一個線程失敗了可能會導致整個進程退出。這是目前存在的不足。


4. 異步日誌系統 ———— logger 文件夾

logger 文件夾中的代碼文件及簡單描述如下:

logger
    |---AsyncLogging.cc   # 異步日誌系統後端組件
    |---AsyncLogging.h    #
    |---LogFile.cc        # 帶有定期滾動刷新功能的日誌文件
    |---LogFile.h         #
    |---Logger.cc         # 日誌系統的前端組件
    |---Logger.h          #
  • Logger.cc
    日誌系統的前端。Logger 內部使用了輸出字符串流, 當一個 Logger 對象被構造時, 它會將傳遞給自身的字符串參數格式化(時間、線程號、錯誤碼等等)後輸入到字符串流中。
    在其析構時會調用日誌系統後端的接口, 這個後端接口可以由用戶設置, 比如定向到文件或者其他地方。如果沒有提供後端的輸出函數, 默認是標準輸出。
  • LogFile.cc
    日誌系統的後端組件。內部使用了utils/FileUtil.h中的AppendFile, 將追加寫文件作了封裝, 支持定期刷新並按指定文件大小分批次保存。
  • AsyncLogging.cc
    異步日誌系統後端。AsyncLogging 維護了一個緩衝區隊列, 用於前臺程序同步地放置待輸出日誌; 另外還有一個後臺線程專門負責從隊列中取出日誌寫入文件中。

5. reactor 模式實現 ———— reactor 文件夾

reactor 文件夾中的代碼文件及簡單描述如下:

reactor
    |---Channel.cc                 # 
    |---Channel.h                  #
    |---EventLoop.cc               # 
    |---EventLoop.h                #
    |---EventLoopThread.cc         # 
    |---EventLoopThread.h          #
    |---EventLoopThreadPool.cc     #
    |---EventLoopThreadPool.h      #
    |---Poller.cc                  #
    |---Poller.h                   #
    |---Timer.cc                   #
    |---Timer.h                    #
    |---TimerQueue.cc              #
    |---TimerQueue.h               #
  • Channel.cc
    描述符及相應事件的句柄。每一個 Channel 負責管理一個文件描述符, 其包含了有關事件(可讀, 可寫, 關閉, 錯誤等)的回調信息。
  • Poller.cc
    多路複用器。EventLoop 通過 Channel 向它註冊一些感興趣的事件, 由 Poller 來完成多路描述符的監控。其內部使用 epoll 接口。
  • Timer.cc
    定時器。它並不具有定時的功能, 只是對超時時間及回調函數的封裝。
  • TimerQueue.cc
    定時器隊列。它內部使用了 timerfd 接口, 維護了一個定時器隊列, 由 EventLoop 使用。
  • EventLoop.cc
    事件循環。它是一個事件分發器, 負責定時器事件的註冊及對已發生事件的處理。其內部包含了一個 TimerQueue 以實現定時執行任務的功能。
  • EventLoopThread.cc
    loop 線程的封裝。用以實現 EventLoopThreadPool。
  • EventLoopThreadPool.cc
    loop 線程池的封裝。每個線程含有一個 EventLoop, 在 TcpServer 中用作處理新連接的 I/O 線程。

這個文件夾中的幾個類耦合度較高, 要注意正確處理其依賴關係。爲了避免互相引用的問題, 讓 EventLoop 依賴 Channel, Poller, Timer, TimerQueue, 而後者均使用了 EventLoop 的前置聲明。由於 Reactor 幾個組件間的高耦合度, 這個不可避免, 編碼過程也稍微複雜一些。


6. 網絡基礎組件封裝 ———— net 文件夾

net 文件夾中的代碼文件及簡單描述如下:

  net
    |---Acceptor.cc          # 
    |---Acceptor.h           #
    |---Buffer.cc            # TCP 的輸入/輸出緩衝區, 內部使用 vector<char>。
    |---Buffer.h             #
    |---Connector.cc         # 
    |---Connector.h          #
    |---InetAddress.cc       # Ipv4 地址結構的封裝。
    |---InetAddress.h        #
    |---Socket.cc            # 非阻塞套接字的封裝。
    |---Socket.h             #
    |---TcpClient.cc         #
    |---TcpClient.h          #
    |---TcpConnection.cc     # TCP 連接的封裝。
    |---TcpConnection.h      #
    |---TcpServer.h          #
    |---TcpServer.h          #
  • Acceptor.cc
    連接接受器。維護了一個監聽套接字, 每當有新連接到來時, 調用指定的回調函數處理新連接。
  • Connector.cc
    連接發起器, 可以自動重試。當連接成功時調用指定回調轉移連接的管理權。
  • TcpConnection.cc
    TCP 連接的封裝。內部維護了本端套接字, 兩端地址及輸入和輸出緩衝區。
  • TcpClient.cc
    Tcp 主動端。內部維護一個 Connector 和一個 TcpConnection。
  • TcpServer.cc
    Tcp 被動端。內部維護一個 Acceptor 和一個 TcpConnection 列表, 以及一個 EventLoopThreadPool 用以處理到來的連接。

由於 TCP 連接生命週期管理的情況比較多, 其中 TcpConnection 應該是邏輯最爲複雜的一個類。 其次是 Connector, 不像 Acceptor 那麼簡單, Connector 有三種狀態(未啓動, 正在重試, 正在連接)。
這裏會碰到很多網絡編程的知識, 對書上的內容有了更進一步的體會。比如非阻塞套接字調用 connect 函數返回後的重啓, 以及各種套接字錯誤碼的處理辦法。


四、完成 ———— EchoServer、EchoClient實現

框架完成後, 我們來實現一個回顯服務器及客戶端。這兩個代碼幾乎用上了庫中所有的組件, 可以說是自頂向下學習的範例代碼。至於其他的服務器邏輯, 只需要修改消息處理及返回部分即可。

EchoServer:

class EchoServer : public Uncopyable {
 public:
  EchoServer(EventLoop* loop,
             const InetAddress& listenAddr,
             const std::string& name)
      : loop_(loop),
        server_(loop, listenAddr, name, false) {
    server_.SetConnectionCallback(
      std::bind(&EchoServer::OnConnection, this, _1));
    server_.SetNewMessageCallback(
      std::bind(&EchoServer::OnMessage, this, _1, _2, _3));
  }

  void Start(int numThreads) {
    server_.Start(numThreads);
  }

  void OnConnection(const TcpConnectionPtr& conn) {
    if (conn->IsConnected()) {  // 新連接.
      conn->Send("hello\n");;
      printf("new connection: %s -> %s\n",
            conn->LocalAddress().ToString(true).c_str(),
            conn->PeerAddress().ToString(true).c_str());
    } else {  // 已有連接斷開.
      printf("connection diconnected: %s -> %s\n",
             conn->LocalAddress().ToString(true).c_str(),
             conn->PeerAddress().ToString(true).c_str());
    }
  }

  void OnMessage(const TcpConnectionPtr& conn, Buffer* buffer, Timestamp when) {
    std::string msg(buffer->RetrieveAllAsString());
    printf("[%s] new message: %s", when.ToFormattedString().c_str(), msg.c_str());
    if (msg == "quit") {
      conn->Shutdown();
    } else {
      conn->Send(msg);
    }
  }

 private:
  EventLoop* loop_;
  TcpServer server_;
};

int main() {
  Logger::SetLogLevel(Logger::LogLevel::NONE);
  EventLoop loop;
  InetAddress listenAddr(7766);
  EchoServer echoServer(&loop, listenAddr, "EchoServer");
  echoServer.Start(4);

  loop.Loop();
  return 0;
}

EchoClient 中多使用了一個 Channel, 用來監聽用戶標準輸入。當有服務器消息到來時 OnMessage() 會被觸發。而當用戶有標準輸入時, OnInput() 則會被觸發。
EchoClient:

class EchoClient : public Uncopyable {
 public:
  EchoClient(EventLoop* loop,
             const InetAddress& serverAddr,
             const std::string& name)
      : loop_(loop),
        client_(loop, serverAddr, name),
        channel_(loop, 0) {
    client_.SetConnectionCallback(
      std::bind(&EchoClient::OnConnection, this, _1));
    client_.SetMessageCallback(
      std::bind(&EchoClient::OnMessage, this, _1, _2, _3));
    channel_.SetReadCallback(std::bind(&EchoClient::OnInput, this));
    channel_.EnableReading();
  }
  ~EchoClient() {
    channel_.DisableAll();
    channel_.RemoveFromPoller();
  }

  void Start() {
    client_.Connect();
  }

  void OnConnection(const TcpConnectionPtr& conn) {
    if (conn->IsConnected()) {  // 新連接.
      printf("new connection: %s -> %s\n",
            conn->LocalAddress().ToString(true).c_str(),
            conn->PeerAddress().ToString(true).c_str());
    } else {  // 連接異常斷開.
      printf("connection diconnected: %s -> %s\n",
             conn->LocalAddress().ToString(true).c_str(),
             conn->PeerAddress().ToString(true).c_str());
      loop_->Quit();
    }
  }

  void OnMessage(const TcpConnectionPtr& conn, Buffer* buffer, Timestamp when) {
    std::string msg(buffer->RetrieveAllAsString());
    printf("[%s] new message: %s", when.ToFormattedString().c_str(), msg.c_str());
  }

  void OnInput() {
    ssize_t n = ::read(channel_.Fd(), buf, sizeof(buf));
    if (n < 0) {
      LOG_SYSERROR << "OnInput(): read() error.";
    }
    client_.Connection()->Send(buf, static_cast<size_t>(n));
  }

 private:
  EventLoop* loop_;
  TcpClient client_;
  Channel channel_;  // 監聽用戶的標準輸入.
  char buf[512];
};

int main() {
  Logger::SetLogLevel(Logger::LogLevel::NONE);
  EventLoop loop;
  InetAddress serverAddr("127.0.0.1", 7766);
  EchoClient client(&loop, serverAddr, "EchoClient");
  client.Start();
  loop.Loop();
  return 0;
}

五、總結和思考

下面是我的一些學習他人代碼的經驗:

  1. 看懂代碼最關鍵點在於明白作者的意圖。 知道了一個類、函數整體的動機是什麼, 才能更好地理解實現細節爲什麼如此。
  2. 初次看請挑重點, 不重要的細枝末節可以跳過。不要事無鉅細地從頭讀到尾。
  3. 看懂之後最好親自實現一遍, 這樣才能發現更多在閱讀過程中發現不了的問題。

一開始我確實是走了很多彎路的。比如我在去年冬天開始啃代碼從 Timestamp 看起, 遇到有關曆法部分我還專門學習了天文曆法的計算, 以及後面的異常處理部分有關demangle的函數, 一個東西就要花上一週。後來我發現這些並不是重點, 調整了策略, 先了解整體框架, 再深入細節, 這樣整個代碼庫對我就規規整整, 一目瞭然了。

項目暫時告一段落, 後續可能需要加一些易用性上的更改, 以及效率上的優化。本人受限於知識水平及工程經驗的不足, 不免會有很多漏洞或者錯誤, 還請各路前輩以及後生們多多指導!

實踐永遠都是最好的教材, 祝大家學習進步!

2020/06/19 23:25
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章