詳細muduo的使用(一)——源碼分析(架構和概念)

 

目錄

 

Muduo是什麼?

muduo的架構和概念

一個簡單的例子

連接的建立

消息的讀取

消息的發送

爲什麼要移除可寫事件

連接的斷開

runInLoop的實現

爲什麼要喚醒EventLoop

wakeup是怎麼實現的

doPendingFunctors的實現

muduo的線程模型

主從reactor模式

業務線程池

總結

參考


Muduo是什麼?

muduo陳碩大神個人開發的C++的TCP網絡編程庫。muduo基於Reactor模式實現。Reactor模式也是目前大多數Linux端高性能網絡編程框架和網絡應用所選擇的主要架構,例如內存數據庫Redis和Java的Netty庫等。

陳碩的《Linux多線程服務器端編程》一書對muduo整個架構進行了非常詳盡的介紹和分析,可以說是學習muduo源碼和設計理念最好的資料了。這本書也非常推薦大家購買閱讀,感覺是後臺開發的必讀書目了。

而本文則主要是從源碼角度輔助理解整個muduo的實現,同時也姑且算是對muduo的一個小小的補充。

同時提供了一個muduo註釋版,以輔助大家參考學習。

muduo的架構和概念

muduo中類的職責和概念劃分的非常清晰,在《Linux多線程服務器端編程》一書的6.3.1章節有詳細的介紹。實際上目前很多網絡庫的接口設計也都受到了muduo的影響,例如360的evpp等。

而muduo的整體風格受到netty的影響,整個架構依照Reactor模式,基本與如下圖所示相符:

                                                                              single_thread_reactor.png

所謂Reactor模式,是有一個循環的過程,監聽對應事件是否觸發,觸發時調用對應的callback進行處理。

這裏的事件在muduo中包括Socket可讀寫事件、定時器事件。在其他網絡庫中如libevent也包括了signal、用戶自定義事件等。

負責事件循環的部分在muduo命名爲EventLoop,其他庫如netty、libevent也都有對應的組件。

負責監聽事件是否觸發的部分,在muduo中叫做Poller。muduo提供了epoll和poll兩種來實現,默認是epoll實現。

通過環境變量MUDUO_USE_POLL來決定是否使用poll:

Poller* Poller::newDefaultPoller(EventLoop* loop)
{
  // 通過此環境變量來決定使用poll還是epoll
  if (::getenv("MUDUO_USE_POLL"))
  {
    return new PollPoller(loop);
  }
  else
  {
    return new EPollPoller(loop);
  }
}

此外,圖中的acceptor負責accept新連接,並將新連接分發到subReactor。這個組件在muduo中也叫做Acceptor

關於圖中的其他部分,會在muduo的線程模型一節有詳細介紹。

一個簡單的例子

本文首先從最簡單的echo server入手,來介紹muduo的基本使用,同時也方便後面概念的理解。

void onMessage(const muduo::net::TcpConnectionPtr& conn,
                           muduo::net::Buffer* buf,
                           muduo::Timestamp time)
{
  conn->send(buf);
}

int main()
{
    muduo::net::EventLoop loop;//建立一個事件循環器EventLoop
    muduo::net::InetAddress listenAddr(2007);
    TcpServer server(&loop, listenAddr);//建立對應的業務服務器TcpServer
    server.setMessageCallback(onMessage);//設置TcpServer的Callback
    server.start();//啓動server
    loop.loop();//開啓事件循環
}

echo-server的代碼量非常簡潔。一個典型的muduo的TcpServer工作流程如下:

  1. 建立一個事件循環器EventLoop
  2. 建立對應的業務服務器TcpServer
  3. 設置TcpServer的Callback
  4. 啓動server
  5. 開啓事件循環

陳碩認爲,TCP網絡編程的本質是處理三個半事件,即:

  1. 連接的建立
  2. 連接的斷開:包括主動斷開和被動斷開
  3. 消息到達,文件描述符可讀。
  4. 消息發送完畢。這個算半個事件。

連接的建立

在我們單純使用linux的API,編寫一個簡單的Tcp服務器時,建立一個新的連接通常需要四步:

步驟1. socket() // 調用socket函數建立監聽socket

步驟2. bind() // 綁定地址和端口

步驟3. listen() // 開始監聽端口

步驟4. accept() // 返回新建立連接的fd

我們接下來分析下,這四個步驟在muduo中都是何時進行的:

首先在TcpServer對象構建時,TcpServer的屬性acceptor同時也被建立。

在Acceptor的構造函數中分別調用了socket函數和bind函數完成了步驟1步驟2

即,當TcpServer server(&loop, listenAddr);執行結束時,監聽socket已經建立好,並已綁定到對應地址和端口了。

而當執行server.start()時,主要做了兩個工作:

  1. 在監聽socket上啓動listen函數,也就是步驟3
  2. 將監聽socket的可讀事件註冊到EventLoop中。

此時,程序已完成對地址的監聽,但還不夠,因爲此時程序的主角EventLoop尚未啓動。

當調用loop.loop()時,程序開始監聽該socket的可讀事件。

當新連接請求建立時,可讀事件觸發,此時該事件對應的callback在EventLoop::loop()中被調用。

該事件的callback實際上就是Acceptor::handleRead()方法。

在Acceptor::handleRead()方法中,做了三件事:

  1. 調用了accept函數,完成了步驟4,實現了連接的建立。得到一個已連接socket的fd
  2. 創建TcpConnection對象
  3. 將已連接socket的可讀事件註冊到EventLoop中。

這裏還有一個需要注意的點,創建的TcpConnnection對象是個shared_ptr,該對象會被保存在TcpServer的connections中。這樣才能保證引用計數大於0,對象不被釋放。

至此,一個新的連接已完全建立好,其可讀事件也已註冊到EventLoop中了。

消息的讀取

上節講到,在新連接建立的時候,會將新連接的socket的可讀事件註冊到EventLoop中。

假如客戶端發送消息,導致已連接socket的可讀事件觸發,該事件對應的callback同樣也會在EventLoop::loop()中被調用。

該事件的callback實際上就是TcpConnection::handleRead方法。

在TcpConnection::handleRead方法中,主要做了兩件事:

  1. 從socket中讀取數據,並將其放入inputbuffer中
  2. 調用messageCallback,執行業務邏輯。
ssize_t n = inputBuffer_.readFd(channel_->fd(), &savedErrno);
if (n > 0)
{
    messageCallback_(shared_from_this(), &inputBuffer_, receiveTime);
}

messageCallback是在建立新連接時,將TcpServer::messageCallback方法bind到了TcpConnection::messageCallback的方法。

messageCallback是在建立新連接時,將TcpServer::messageCallback方法bind到了TcpConnection::messageCallback的方法。

TcpServer::messageCallback就是業務邏輯的主要實現函數。通常情況下,我們可以在裏面實現消息的編解碼、消息的分發等工作,這裏就不再深入探討了。

在我們上面給出的示例代碼中,echo-server的messageCallback非常簡單,就是直接將得到的數據,重新send回去。在實際的業務處理中,一般都會調用TcpConnection::send()方法,給客戶端回覆消息。

這裏需要注意的是,在messageCallback中,用戶會有可能會把任務拋給自定義的Worker線程池處理。

但是這個在Worker線程池中任務,切忌直接對Buffer的操作。因爲Buffer並不是線程安全的。

我們需要記住一個準則:

所有對IO和buffer的讀寫,都應該在IO線程中完成。

一般情況下,先在交給Worker線程池之前,應該現在IO線程中把Buffer進行切分解包等動作。將解包後的消息交由線程池處理,避免多個線程操作同一個資源。

消息的發送

用戶通過調用TcpConnection::send()向客戶端回覆消息。由於muduo中使用了OutputBuffer,因此消息的發送過程比較複雜。

首先需要注意的是線程安全問題, 對於消息的讀寫必須都在EventLoop的同一個線程(通常稱爲IO線程)中進行:

因此,TcpConnection::send保證了線程安全性,它是這麼做的:

void TcpConnection::send(const StringPiece& message)
{
  if (state_ == kConnected)
  {
//檢測send的時候,是否在當前IO線程,如果是的話,直接進行寫相關操作sendInLoop。
    if (loop_->isInLoopThread())
    {
      sendInLoop(message);
    }
//如果不在一個線程的話,需要將該任務拋給IO線程執行runInloop, 以保證write動作是在IO線程中執行的
    else
    {
      loop_->runInLoop(
          boost::bind(&TcpConnection::sendInLoop,
                      this,     // FIXME
                      message.as_string()));
    }
  }
}

檢測send的時候,是否在當前IO線程,如果是的話,直接進行寫相關操作sendInLoop

如果不在一個線程的話,需要將該任務拋給IO線程執行runInloop, 以保證write動作是在IO線程中執行的。我們後面會講解runInloop的具體實現。

在sendInloop中,做了下面幾件事:

  1. 假如OutputBuffer爲空,則直接向socket寫數據
  2. 如果向socket寫數據沒有寫完,則統計剩餘的字節個數,並進行下一步。沒有寫完可能是因爲此時socket的TCP緩衝區已滿了。
  3. 如果此時OutputBuffer中的舊數據的個數和未寫完字節個數之和大於highWaterMark,則將highWaterMarkCallback放入待執行隊列中
  4. 將對應socket的可寫事件註冊到EventLoop中

注意,直到發送的時候,才把socket的可寫事件註冊到了EventLoop中。之前只註冊了可讀事件。

連接socket的可寫事件對應的callback是TcpConnection::handleWrite()

當某個socket的可寫事件觸發時,TcpConnection::handleWrite會做兩個工作:

  1. 儘可能將數據從OutputBuffer中向socket中write數據
  2. 如果OutputBuffer沒有剩餘的,則將該socket的可寫事件移除,並調用writeCompleteCallback

爲什麼要移除可寫事件

因爲當OutputBuffer中沒數據時,我們不需要向socket中寫入數據。但是此時socket一直是處於可寫狀態的, 這將會導致TcpConnection::handleWrite()一直被觸發。然而這個觸發毫無意義,因爲並沒有什麼可以寫的。

所以muduo的處理方式是,當OutputBuffer還有數據時,socket可寫事件是註冊狀態。當OutputBuffer爲空時,則將socket的可寫事件移除。

此外,highWaterMarkCallback和writeCompleteCallback一般配合使用,起到限流的作用。在《linux多線程服務器端編程》一書的8.9.3一節中有詳細講解。這裏就不再贅述了

連接的斷開

我們看下muduo對於連接的斷開是怎麼處理的。

連接的斷開分爲被動斷開和主動斷開。主動斷開和被動斷開的處理方式基本一致,因此本文只講下被動斷開的部分。

被動斷開即遠程端斷開了連接,server端需要感知到這個斷開的過程,然後進行的相關的處理。

其中感知遠程斷開這一步是在Tcp連接的可讀事件處理函數handleRead中進行的:當對socket進行read操作時,返回值爲0,則說明此時連接已斷開。

接下來會做四件事情:

  1. 將該TCP連接對應的事件從EventLoop移除
  2. 調用用戶的ConnectionCallback
  3. 將對應的TcpConnection對象從Server移除。
  4. close對應的fd。此步驟是在析構函數中被動觸發的,當TcpConnection對象被移除後,引用計數爲0,對象析構時會

 

runInLoop的實現

在講解消息的發送過程時候,我們講到爲了保證對buffer和socket的寫動作是在io線程中進行,使用了一個runInLoop函數,將該寫任務拋給了io線程處理。

我們接下來看下runInLoop的實現:

void EventLoop::runInLoop(const Functor& cb)
{
//如果調用時是此EventLoop的運行線程,則直接執行此函數。
  if (isInLoopThread())
  {
    cb();
  }
  else
  {
    queueInLoop(cb);
  }
}

這裏可以看到,做了一層判斷。如果調用時是此EventLoop的運行線程,則直接執行此函數。

否則調用queueInLoop函數。我們看下queueInLoop的實現。

void EventLoop::queueInLoop(const Functor& cb)
{
  {
  MutexLockGuard lock(mutex_);
  pendingFunctors_.push_back(cb);
  }

  if (!isInLoopThread() || callingPendingFunctors_)
  {
    wakeup();
  }
}

這裏有兩個動作:

  1. 加鎖,然後將該函數放到該EventLoop的pendingFunctors_隊列中。
  2. 判斷是否要喚醒EventLoop,如果是則調用wakeup()喚醒該EventLoop。

這裏有幾個問題:

  • 爲什麼要喚醒EventLoop?
  • wakeup是怎麼實現的?
  • pendingFunctors_是如何被消費的?

爲什麼要喚醒EventLoop

我們首先調用了pendingFunctors_.push_back(cb);, 將該函數放在pendingFunctors_中。EventLoop的每一輪循環最後會調用doPendingFunctors依次執行這些函數。

而EventLoop的喚醒是通過epoll_wait實現的,如果此時該EventLoop中遲遲沒有事件觸發,那麼epoll_wait一直就會阻塞。

這樣會導致,pendingFunctors_遲遲不能被執行了。

所以對EventLoop的喚醒是必要的。

wakeup是怎麼實現的

muduo這裏採用了對eventfd的讀寫來實現對EventLoop的喚醒。

在EventLoop建立之後,就創建一個eventfd,並將其可讀事件註冊到EventLoop中。

wakeup()的過程本質上是對這個eventfd進行寫操作,以觸發該eventfd的可讀事件。這樣就起到了喚醒EventLoop的作用。

void EventLoop::wakeup()
{
  uint64_t one = 1;
  sockets::write(wakeupFd_, &one, sizeof one);
}

很多庫爲了兼容macos,往往使用pipe來實現這個功能。muduo採用了eventfd,性能更好些,但代價是不能支持macos了。不過muduo似乎從一開始的定位就不打算支持?

doPendingFunctors的實現

本部分講下doPendingFunctors的實現,muduo是如何處理這些待處理的函數的,以及中間用了哪些優化操作。

代碼如下所示
 

void EventLoop::doPendingFunctors()
{
  std::vector<Functor> functors;
 
  callingPendingFunctors_ = true;

  {
  MutexLockGuard lock(mutex_);
  functors.swap(pendingFunctors_);
  }

  for (size_t i = 0; i < functors.size(); ++i)
  {
    functors[i]();
  }
  callingPendingFunctors_ = false;
}

從代碼可以看到,函數非常簡單。大概只有十行代碼,但是這十行卻有兩個非常巧妙的措施。

callingPendingFunctors_的作用

從代碼可以看出,如果callingPendingFunctors_爲false,則說明此時尚未開始執行doPendingFunctors函數。

這個有什麼作用呢,我們需要結合下queueInLoop中,對是否執行wakeup()的判斷

if (!isInLoopThread() || callingPendingFunctors_)
{
  wakeup();
}

這裏還需要結合下EventLoop循環的實現,其中doPendingFunctors()每輪循環的最後一步處理

如果調用queueInLoop和EventLoop在同一個線程,且callingPendingFunctors_爲false時,則說明:此時尚未執行到doPendingFunctors()。

那麼此時即使不用wakeup,也可以在之後照舊執行doPendingFunctors()了。

這麼做的好處非常明顯,可以減少對eventfd的io讀寫。

鎖範圍的減少

在此函數中,有一段特別的代碼:

std::vector<Functor> functors;
{
  MutexLockGuard lock(mutex_);
  functors.swap(pendingFunctors_);
}

這個作用是pendingFunctors和functors的內容進行交換,實際上就是此時functors持有了pendingFunctors的內容,而pendingFunctors_被清空了。

這個好處是什麼呢?

如果不這麼做,直接遍歷pendingFunctors_,然後處理對應的函數。這樣的話,鎖會一直等到所有函數處理完纔會被釋放。在此期間,queueInLoop將不可用。

而以上的寫法,可以極大減小鎖範圍,整個鎖的持有時間就是swap那一下的時間。待處理函數執行的時候,其他線程還是可以繼續調用queueInLoop。

muduo的線程模型

muduo默認是單線程模型的,即只有一個線程,裏面對應一個EventLoop。這樣整體對於線程安全的考慮可能就比較簡單了,

但是muduo也可以支持以下幾種線程模型:

主從reactor模式

主從reactor是netty的默認模型,一個reactor對應一個EventLoop。主Reactor只有一個,只負責監聽新的連接,accept後將這個連接分配到子Reactor上。子Reactor可以有多個。這樣可以分攤一個Eventloop的壓力,性能方面可能會更好。如下圖所示:

                                                                        main_sub_reactor.jpg

在muduo中也可以支持主從Reactor,其中主Reactor的EventLoop就是TcpServer的構造函數中的EventLoop*參數。Acceptor會在此EventLoop中運行。

而子Reactor可以通過TcpServer::setThreadNum(int)來設置其個數。因爲一個Eventloop只能在一個線程中運行,所以線程的個數就是子Reactor的個數。

如果設置了子Reactor,新的連接會通過Round Robin的方式分配給其中一個EventLoop來管理。如果沒有設置子Reactor,則是默認的單線程模型,新的連接會再由主Reactor進行管理。

但其實這裏似乎有些不合適的地方:多個TcpServer之間可以共享同一個主EventLoop,但是子Eventloop線程池卻不能共享,這個是每個TcpServer獨有的。

這裏不太清楚是muduo的設計問題,還是作者有意爲之。不過netty的主EventLoop和子Eventloop池都是可以共享的。

業務線程池

對於一些阻塞型或者耗時型的任務,例如SQL操作等。這些顯然是不能放在IO線程(即EventLoop所在的線程)中運行的,因爲會嚴重影響EventLoop的正常運行。

對於這類耗時型的任務,一般做法是可以放在另外單獨線程池中運行,這樣就不會阻塞IO線程的運行了。我們一般把這種處理耗時任務的線程叫做Worker線程。

muduo本身沒有提供一套直接使用Worker線程池的方式,但是muduo本身提供了線程池的相關類ThreadPool

muduo官方的推薦做法是,在OnMessage中,自行進行包的切分,然後將數據和對應的處理函數打包成Task的方式提交給線程池。

總結

個人認爲,muduo源碼對於學習網絡編程和項目設計非常有幫助, 裏面幾乎包含了大部分網絡編程和框架設計的最佳實踐,配合《Linux多線程服務器端編程》一書,可以學到很多東西。

基於這幾個方面來說,muduo絕對是一個值得一探究竟的優質源碼。

此外,不但是網絡編程方面,如何將複雜的底層細節封裝好,暴露出友好的通用業務層接口,如何設計類的職責,對象的生命週期管理等方面,muduo都給了我們一個很好的示範。

參考

 

 

 

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