目錄
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工作流程如下:
- 建立一個事件循環器EventLoop
- 建立對應的業務服務器TcpServer
- 設置TcpServer的Callback
- 啓動server
- 開啓事件循環
陳碩認爲,TCP網絡編程的本質是處理三個半事件,即:
- 連接的建立
- 連接的斷開:包括主動斷開和被動斷開
- 消息到達,文件描述符可讀。
- 消息發送完畢。這個算半個事件。
連接的建立
在我們單純使用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()
時,主要做了兩個工作:
- 在監聽socket上啓動listen函數,也就是步驟3;
- 將監聽socket的可讀事件註冊到EventLoop中。
此時,程序已完成對地址的監聽,但還不夠,因爲此時程序的主角EventLoop
尚未啓動。
當調用loop.loop()
時,程序開始監聽該socket的可讀事件。
當新連接請求建立時,可讀事件觸發,此時該事件對應的callback在EventLoop::loop()中被調用。
該事件的callback實際上就是Acceptor::handleRead()方法。
在Acceptor::handleRead()方法中,做了三件事:
- 調用了accept函數,完成了步驟4,實現了連接的建立。得到一個已連接socket的fd
- 創建TcpConnection對象
- 將已連接socket的可讀事件註冊到EventLoop中。
這裏還有一個需要注意的點,創建的TcpConnnection對象是個shared_ptr,該對象會被保存在TcpServer的connections中。這樣才能保證引用計數大於0,對象不被釋放。
至此,一個新的連接已完全建立好,其可讀事件也已註冊到EventLoop中了。
消息的讀取
上節講到,在新連接建立的時候,會將新連接的socket的可讀事件註冊到EventLoop中。
假如客戶端發送消息,導致已連接socket的可讀事件觸發,該事件對應的callback同樣也會在EventLoop::loop()中被調用。
該事件的callback實際上就是TcpConnection::handleRead方法。
在TcpConnection::handleRead方法中,主要做了兩件事:
- 從socket中讀取數據,並將其放入inputbuffer中
- 調用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中,做了下面幾件事:
- 假如OutputBuffer爲空,則直接向socket寫數據
- 如果向socket寫數據沒有寫完,則統計剩餘的字節個數,並進行下一步。沒有寫完可能是因爲此時socket的TCP緩衝區已滿了。
- 如果此時OutputBuffer中的舊數據的個數和未寫完字節個數之和大於highWaterMark,則將highWaterMarkCallback放入待執行隊列中
- 將對應socket的可寫事件註冊到EventLoop中
注意,直到發送的時候,才把socket的可寫事件註冊到了EventLoop中。之前只註冊了可讀事件。
連接socket的可寫事件對應的callback是TcpConnection::handleWrite()
當某個socket的可寫事件觸發時,TcpConnection::handleWrite會做兩個工作:
- 儘可能將數據從OutputBuffer中向socket中write數據
- 如果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,則說明此時連接已斷開。
接下來會做四件事情:
- 將該TCP連接對應的事件從EventLoop移除
- 調用用戶的ConnectionCallback
- 將對應的TcpConnection對象從Server移除。
- 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();
}
}
這裏有兩個動作:
- 加鎖,然後將該函數放到該EventLoop的pendingFunctors_隊列中。
- 判斷是否要喚醒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都給了我們一個很好的示範。
參考
- 《Linux多線程服務器端編程》
- Scalable IO in Java