陳碩 (giantchen_AT_gmail)
Blog.csdn.net/Solstice t.sina.com.cn/giantchen
Muduo 全系列文章列表: http://blog.csdn.net/Solstice/category/779646.aspx
本文介紹 Muduo 中輸入輸出緩沖區(qū)的設(shè)計(jì)與實(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)、信號(hào)驅(qū)動(dòng)(signal-driven)、異步(asynchronous)。這些都是單線程下的 IO 模型。
C10k 問題的頁面介紹了五種 IO 策略,把線程也納入考量。(現(xiàn)在 C10k 已經(jīng)不是什么問題,C100k 也不是大問題,C1000k 才算得上挑戰(zhàn))。
在這個(gè)多核時(shí)代,線程是不可避免的。那么服務(wù)端網(wǎng)絡(luò)編程該如何選擇線程模型呢?我贊同 libev 作者的觀點(diǎn):one loop per thread is usually a good model。之前我也不止一次表述過這個(gè)觀點(diǎn),見《多線程服務(wù)器的常用編程模型》《多線程服務(wù)器的適用場(chǎng)合》。
如果采用 one loop per thread 的模型,多線程服務(wù)端編程的問題就簡化為如何設(shè)計(jì)一個(gè)高效且易于使用的 event loop,然后每個(gè)線程 run 一個(gè) event loop 就行了(當(dāng)然、同步和互斥是不可或缺的)。在“高效”這方面已經(jīng)有了很多成熟的范例(libev、libevent、memcached、varnish、lighttpd、nginx),在“易于使用”方面我希望 muduo 能有所作為。(muduo 可算是用現(xiàn)代 C++ 實(shí)現(xiàn)了 Reactor 模式,比起原始的 Reactor 來說要好用得多。)
event loop 是 non-blocking 網(wǎng)絡(luò)編程的核心,在現(xiàn)實(shí)生活中,non-blocking 幾乎總是和 IO-multiplexing 一起使用,原因有兩點(diǎn):
- 沒有人真的會(huì)用輪詢 (busy-pooling) 來檢查某個(gè) non-blocking IO 操作是否完成,這樣太浪費(fèi) CPU cycles。
- IO-multiplex 一般不能和 blocking IO 用在一起,因?yàn)?blocking IO 中 read()/write()/accept()/connect() 都有可能阻塞當(dāng)前線程,這樣線程就沒辦法處理其他 socket 上的 IO 事件了。見 UNPv1 第 16.6 節(jié)“nonblocking accept”的例子。
所以,當(dāng)我提到 non-blocking 的時(shí)候,實(shí)際上指的是 non-blocking + IO-muleiplexing,單用其中任何一個(gè)是不現(xiàn)實(shí)的。另外,本文所有的“連接”均指 TCP 連接,socket 和 connection 在文中可互換使用。
當(dāng)然,non-blocking 編程比 blocking 難得多,見陳碩在《Muduo 網(wǎng)絡(luò)編程示例之零:前言》中“TCP 網(wǎng)絡(luò)編程本質(zhì)論”一節(jié)列舉的難點(diǎn)。基于 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,讓一個(gè)線程能服務(wù)于多個(gè) socket 連接。IO 線程只能阻塞在 IO-multiplexing 函數(shù)上,如 select()/poll()/epoll_wait()。這樣一來,應(yīng)用層的緩沖是必須的,每個(gè) TCP socket 都要有 stateful 的 input buffer 和 output buffer。
TcpConnection 必須要有 output buffer
考慮一個(gè)常見場(chǎng)景:程序想通過 TCP 連接發(fā)送 100k 字節(jié)的數(shù)據(jù),但是在 write() 調(diào)用中,操作系統(tǒng)只接受了 80k 字節(jié)(受 TCP advertised window 的控制,細(xì)節(jié)見 TCPv1),你肯定不想在原地等待,因?yàn)椴恢罆?huì)等多久(取決于對(duì)方什么時(shí)候接受數(shù)據(jù),然后滑動(dòng) TCP 窗口)。程序應(yīng)該盡快交出控制權(quán),返回 event loop。在這種情況下,剩余的 20k 字節(jié)數(shù)據(jù)怎么辦?
對(duì)于應(yīng)用程序而言,它只管生成數(shù)據(jù),它不應(yīng)該關(guān)心到底數(shù)據(jù)是一次性發(fā)送還是分成幾次發(fā)送,這些應(yīng)該由網(wǎng)絡(luò)庫來操心,程序只要調(diào)用 TcpConnection::send() 就行了,網(wǎng)絡(luò)庫會(huì)負(fù)責(zé)到底。網(wǎng)絡(luò)庫應(yīng)該接管這剩余的 20k 字節(jié)數(shù)據(jù),把它保存在該 TCP connection 的 output buffer 里,然后注冊(cè) 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é),而這時(shí)候 output buffer 里還有待發(fā)送的 20k 數(shù)據(jù),那么網(wǎng)絡(luò)庫不應(yīng)該直接調(diào)用 write(),而應(yīng)該把這 50k 數(shù)據(jù) append 在那 20k 數(shù)據(jù)之后,等 socket 變得可寫的時(shí)候再一并寫入。
如果 output buffer 里還有待發(fā)送的數(shù)據(jù),而程序又想關(guān)閉連接(對(duì)程序而言,調(diào)用 TcpConnection::send() 之后他就認(rèn)為數(shù)據(jù)遲早會(huì)發(fā)出去),那么這時(shí)候網(wǎng)絡(luò)庫不能立刻關(guān)閉連接,而要等數(shù)據(jù)發(fā)送完畢,見我在《為什么 muduo 的 shutdown() 沒有直接關(guān)閉 TCP 連接?》一文中的講解。
綜上,要讓程序在 write 操作上不阻塞,網(wǎng)絡(luò)庫必須要給每個(gè) tcp connection 配置 output buffer。
TcpConnection 必須要有 input buffer
TCP 是一個(gè)無邊界的字節(jié)流協(xié)議,接收方必須要處理“收到的數(shù)據(jù)尚不構(gòu)成一條完整的消息”和“一次收到兩條消息的數(shù)據(jù)”等等情況。一個(gè)常見的場(chǎng)景是,發(fā)送方 send 了兩條 10k 字節(jié)的消息(共 20k),接收方收到數(shù)據(jù)的情況可能是:
- 一次性收到 20k 數(shù)據(jù)
- 分兩次收到,第一次 5k,第二次 15k
- 分兩次收到,第一次 15k,第二次 5k
- 分兩次收到,第一次 10k,第二次 10k
- 分三次收到,第一次 6k,第二次 8k,第三次 6k
- 其他任何可能
網(wǎng)絡(luò)庫在處理“socket 可讀”事件的時(shí)候,必須一次性把 socket 里的數(shù)據(jù)讀完(從操作系統(tǒng) buffer 搬到應(yīng)用層 buffer),否則會(huì)反復(fù)觸發(fā) POLLIN 事件,造成 busy-loop。(Again, Muduo EventLoop 采用的是 epoll level trigger,這么做的具體原因我以后再說。)
那么網(wǎng)絡(luò)庫必然要應(yīng)對(duì)“數(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ò)庫必須要給每個(gè) tcp connection 配置 input buffer。
所有 muduo 中的 IO 都是帶緩沖的 IO (buffered IO),你不會(huì)自己去 read() 或 write() 某個(gè) socket,只會(huì)操作 TcpConnection 的 input buffer 和 output buffer。更確切的說,是在 onMessage() 回調(diào)里讀取 input buffer;調(diào)用 TcpConnection::send() 來間接操作 output buffer,一般不會(huì)直接操作 output buffer。
btw, muduo 的 onMessage() 的原型如下,它既可以是 free function,也可以是 member function,反正 muduo TcpConnection 只認(rèn) boost::function<>。
void onMessage(const TcpConnectionPtr& conn, Buffer* buf, Timestamp receiveTime);
對(duì)于網(wǎng)絡(luò)程序來說,一個(gè)簡單的驗(yàn)收測(cè)試是:輸入數(shù)據(jù)每次收到一個(gè)字節(jié)(200 字節(jié)的輸入數(shù)據(jù)會(huì)分 200 次收到,每次間隔 10 ms),程序的功能不受影響。對(duì)于 Muduo 程序,通常可以用 codec 來分離“消息接收”與“消息處理”,見陳碩《在 muduo 中實(shí)現(xiàn) protobuf 編解碼器與消息分發(fā)器》一文中對(duì)“編解碼器 codec”的介紹。
如果某個(gè)網(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è)計(jì)考慮了常見的網(wǎng)絡(luò)編程需求,我試圖在易用性和性能之間找一個(gè)平衡點(diǎn),目前這個(gè)平衡點(diǎn)更偏向于易用性。
Muduo Buffer 的設(shè)計(jì)要點(diǎn):
- 對(duì)外表現(xiàn)為一塊連續(xù)的內(nèi)存(char*, len),以方便客戶代碼的編寫。
- 其 size() 可以自動(dòng)增長,以適應(yīng)不同大小的消息。它不是一個(gè) fixed size array (即 char buf[8192])。
- 內(nèi)部以 vector of char 來保存數(shù)據(jù),并提供相應(yīng)的訪問函數(shù)。
Buffer 其實(shí)像是一個(gè) queue,從末尾寫入數(shù)據(jù),從頭部讀出數(shù)據(jù)。
誰會(huì)用 Buffer?誰寫誰讀?根據(jù)前文分析,TcpConnection 會(huì)有兩個(gè) Buffer 成員,input buffer 與 output buffer。
- input buffer,TcpConnection 會(huì)從 socket 讀取數(shù)據(jù),然后寫入 input buffer(其實(shí)這一步是用 Buffer::readFd() 完成的);客戶代碼從 input buffer 讀取數(shù)據(jù)。
- output buffer,客戶代碼會(huì)把數(shù)據(jù)寫入 output buffer(其實(shí)這一步是用 TcpConnection::send() 完成的);TcpConnection 從 output buffer 讀取數(shù)據(jù)并寫入 socket。
其實(shí),input 和 output 是針對(duì)客戶代碼而言,客戶代碼從 input 讀,往 output 寫。TcpConnection 的讀寫正好相反。
以下是 muduo::net::Buffer 的類圖。請(qǐng)注意,為了后面畫圖方便,這個(gè)類圖跟實(shí)際代碼略有出入,但不影響我要表達(dá)的觀點(diǎn)。

這里不介紹每個(gè)成員函數(shù)的作用,留給《Muduo 網(wǎng)絡(luò)編程示例》系列。下文會(huì)仔細(xì)介紹 readIndex 和 writeIndex 的作用。
Buffer::readFd()
我在《Muduo 網(wǎng)絡(luò)編程示例之零:前言》中寫道
- 在非阻塞網(wǎng)絡(luò)編程中,如何設(shè)計(jì)并使用緩沖區(qū)?一方面我們希望減少系統(tǒng)調(diào)用,一次讀的數(shù)據(jù)越多越劃算,那么似乎應(yīng)該準(zhǔn)備一個(gè)大的緩沖區(qū)。另一方面,我們系統(tǒng)減少內(nèi)存占用。如果有 10k 個(gè)連接,每個(gè)連接一建立就分配 64k 的讀緩沖的話,將占用 640M 內(nèi)存,而大多數(shù)時(shí)候這些緩沖區(qū)的使用率很低。muduo 用 readv 結(jié)合棧上空間巧妙地解決了這個(gè)問題。
具體做法是,在棧上準(zhǔn)備一個(gè) 65536 字節(jié)的 stackbuf,然后利用 readv() 來讀取數(shù)據(jù),iovec 有兩塊,第一塊指向 muduo Buffer 中的 writable 字節(jié),另一塊指向棧上的 stackbuf。這樣如果讀入的數(shù)據(jù)不多,那么全部都讀到 Buffer 中去了;如果長度超過 Buffer 的 writable 字節(jié)數(shù),就會(huì)讀到棧上的 stackbuf 里,然后程序再把 stackbuf 里的數(shù)據(jù) append 到 Buffer 中。
代碼見 http://code.google.com/p/muduo/source/browse/trunk/muduo/net/Buffer.cc#36
這么做利用了臨時(shí)棧上空間,避免開巨大 Buffer 造成的內(nèi)存浪費(fèi),也避免反復(fù)調(diào)用 read() 的系統(tǒng)開銷(通常一次 readv() 系統(tǒng)調(diào)用就能讀完全部數(shù)據(jù))。
這算是一個(gè)小小的創(chuàng)新吧。
線程安全?
muduo::net::Buffer 不是線程安全的,這么做是有意的,原因如下:
- 對(duì)于 input buffer,onMessage() 回調(diào)始終發(fā)生在該 TcpConnection 所屬的那個(gè) IO 線程,應(yīng)用程序應(yīng)該在 onMessage() 完成對(duì) input buffer 的操作,并且不要把 input buffer 暴露給其他線程。這樣所有對(duì) input buffer 的操作都在同一個(gè)線程,Buffer class 不必是線程安全的。
- 對(duì)于 output buffer,應(yīng)用程序不會(huì)直接操作它,而是調(diào)用 TcpConnection::send() 來發(fā)送數(shù)據(jù),后者是線程安全的。
如果 TcpConnection::send() 調(diào)用發(fā)生在該 TcpConnection 所屬的那個(gè) IO 線程,那么它會(huì)轉(zhuǎn)而調(diào)用 TcpConnection::sendInLoop(),sendInLoop() 會(huì)在當(dāng)前線程(也就是 IO 線程)操作 output buffer;如果 TcpConnection::send() 調(diào)用發(fā)生在別的線程,它不會(huì)在當(dāng)前線程調(diào)用 sendInLoop() ,而是通過 EventLoop::runInLoop() 把 sendInLoop() 函數(shù)調(diào)用轉(zhuǎn)移到 IO 線程(聽上去頗為神奇?),這樣 sendInLoop() 還是會(huì)在 IO 線程操作 output buffer,不會(huì)有線程安全問題。當(dāng)然,跨線程的函數(shù)轉(zhuǎn)移調(diào)用涉及函數(shù)參數(shù)的跨線程傳遞,一種簡單的做法是把數(shù)據(jù)拷一份,絕對(duì)安全(不明白的同學(xué)請(qǐng)閱讀代碼)。
另一種更為高效做法是用 swap()。這就是為什么 TcpConnection::send() 的某個(gè)重載以 Buffer* 為參數(shù),而不是 const Buffer&,這樣可以避免拷貝,而用 Buffer::swap() 實(shí)現(xiàn)高效的線程間數(shù)據(jù)轉(zhuǎn)移。(最后這點(diǎn),僅為設(shè)想,暫未實(shí)現(xiàn)。目前仍然以數(shù)據(jù)拷貝方式在線程間傳遞,略微有些性能損失。)
Muduo Buffer 的數(shù)據(jù)結(jié)構(gòu)
Buffer 的內(nèi)部是一個(gè) vector of char,它是一塊連續(xù)的內(nèi)存。此外,Buffer 有兩個(gè) data members,指向該 vector 中的元素。這兩個(gè) indices 的類型是 int,不是 char*,目的是應(yīng)對(duì)迭代器失效。muduo Buffer 的設(shè)計(jì)參考了 Netty 的 ChannelBuffer 和 libevent 1.4.x 的 evbuffer。不過,其 prependable 可算是一點(diǎn)“微創(chuàng)新”。
Muduo Buffer 的數(shù)據(jù)結(jié)構(gòu)如下:
圖 1
兩個(gè) 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 里有兩個(gè)常數(shù) kCheapPrepend 和 kInitialSize,定義了 prependable 的初始大小和 writable 的初始大小。(readable 的初始大小為 0。)在初始化之后,Buffer 的數(shù)據(jù)結(jié)構(gòu)如下:括號(hào)里的數(shù)字是該變量或常量的值。
圖 2
根據(jù)以上(公式一)可算出各塊的大小,剛剛初始化的 Buffer 里沒有 payload 數(shù)據(jù),所以 readable == 0。
Muduo Buffer 的操作
1. 基本的 read-write cycle
Buffer 初始化后的情況見圖 1,如果有人向 Buffer 寫入了 200 字節(jié),那么其布局是:
圖 3
圖 3 中 writeIndex 向后移動(dòng)了 200 字節(jié),readIndex 保持不變,readable 和 writable 的值也有變化。
如果有人從 Buffer read() & retrieve() (下稱“讀入”)了 50 字節(jié),結(jié)果見圖 4。與上圖相比,readIndex 向后移動(dòng) 50 字節(jié),writeIndex 保持不變,readable 和 writable 的值也有變化(這句話往后從略)。
圖 4
然后又寫入了 200 字節(jié),writeIndex 向后移動(dòng)了 200 字節(jié),readIndex 保持不變,見圖 5。
圖 5
接下來,一次性讀入 350 字節(jié),請(qǐng)注意,由于全部數(shù)據(jù)讀完了,readIndex 和 writeIndex 返回原位以備新一輪使用,見圖 6,這和圖 2 是一樣的。
圖 6
以上過程可以看作是發(fā)送方發(fā)送了兩條消息,長度分別為 50 字節(jié)和 350 字節(jié),接收方分兩次收到數(shù)據(jù),每次 200 字節(jié),然后進(jìn)行分包,再分兩次回調(diào)客戶代碼。
自動(dòng)增長
Muduo Buffer 不是固定長度的,它可以自動(dòng)增長,這是使用 vector 的直接好處。
假設(shè)當(dāng)前的狀態(tài)如圖 7 所示。(這和前面圖 5 是一樣的。)
圖 7
客戶代碼一次性寫入 1000 字節(jié),而當(dāng)前可寫的字節(jié)數(shù)只有 624,那么 buffer 會(huì)自動(dòng)增長以容納全部數(shù)據(jù),得到的結(jié)果是圖 8。注意 readIndex 返回到了前面,以保持 prependable 等于 kCheapPrependable。由于 vector 重新分配了內(nèi)存,原來指向它元素的指針會(huì)失效,這就是為什么 readIndex 和 writeIndex 是整數(shù)下標(biāo)而不是指針。
圖 8
然后讀入 350 字節(jié),readIndex 前移,見圖 9。
圖 9
最后,讀完剩下的 1000 字節(jié),readIndex 和 writeIndex 返回 kCheapPrependable,見圖 10。
圖 10
注意 buffer 并沒有縮小大小,下次寫入 1350 字節(jié)就不會(huì)重新分配內(nèi)存了。換句話說,Muduo Buffer 的 size() 是自適應(yīng)的,它一開始的初始值是 1k,如果程序里邊經(jīng)常收發(fā) 10k 的數(shù)據(jù),那么用幾次之后它的 size() 會(huì)自動(dòng)增長到 10k,然后就保持不變。這樣一方面避免浪費(fèi)內(nèi)存(有的程序可能只需要 4k 的緩沖),另一方面避免反復(fù)分配內(nèi)存。當(dāng)然,客戶代碼可以手動(dòng) shrink() buffer size()。
size() 與 capacity()
使用 vector 的另一個(gè)好處是它的 capcity() 機(jī)制減少了內(nèi)存分配的次數(shù)。比方說程序反復(fù)寫入 1 字節(jié),muduo Buffer 不會(huì)每次都分配內(nèi)存,vector 的 capacity() 以指數(shù)方式增長,讓 push_back() 的平均復(fù)雜度是常數(shù)。比方說經(jīng)過第一次增長,size() 剛好滿足寫入的需求,如圖 11。但這個(gè)時(shí)候 vector 的 capacity() 已經(jīng)大于 size(),在接下來寫入 capacity()-size() 字節(jié)的數(shù)據(jù)時(shí),都不會(huì)重新分配內(nèi)存,見圖 12。
圖 11
圖 12
細(xì)心的讀者可能會(huì)發(fā)現(xiàn)用 capacity() 也不是完美的,它有優(yōu)化的余地。具體來說,vector::resize() 會(huì)初始化(memset/bzero)內(nèi)存,而我們不需要它初始化,因?yàn)榉凑⒖叹鸵钊霐?shù)據(jù)。比如,在圖 12 的基礎(chǔ)上寫入 200 字節(jié),由于 capacity() 足夠大,不會(huì)重新分配內(nèi)存,這是好事;但是 vector::resize() 會(huì)先把那 200 字節(jié)設(shè)為 0 (圖 13),然后 muduo buffer 再填入數(shù)據(jù)(圖 14)。這么做稍微有點(diǎn)浪費(fèi),不過我不打算優(yōu)化它,除非它確實(shí)造成了性能瓶頸。(精通 STL 的讀者可能會(huì)說用 vector::append() 以避免浪費(fèi),但是 writeIndex 和 size() 不一定是對(duì)齊的,會(huì)有別的麻煩。)
圖 13
圖 14
google protobuf 中有一個(gè) STLStringResizeUninitialized 函數(shù),干的就是這個(gè)事情。
內(nèi)部騰挪
有時(shí)候,經(jīng)過若干次讀寫,readIndex 移到了比較靠后的位置,留下了巨大的 prependable 空間,見圖 14。
圖 14
這時(shí)候,如果我們想寫入 300 字節(jié),而 writable 只有 200 字節(jié),怎么辦?muduo Buffer 在這種情況下不會(huì)重新分配內(nèi)存,而是先把已有的數(shù)據(jù)移到前面去,騰出 writable 空間,見圖 15。
圖 15
然后,就可以寫入 300 字節(jié)了,見圖 16。
圖 16
這么做的原因是,如果重新分配內(nèi)存,反正也是要把數(shù)據(jù)拷到新分配的內(nèi)存區(qū)域,代價(jià)只會(huì)更大。
prepend
前面說 muduo Buffer 有個(gè)小小的創(chuàng)新(或許不是創(chuàng)新,我記得在哪兒看到過類似的做法,忘了出處),即提供 prependable 空間,讓程序能以很低的代價(jià)在數(shù)據(jù)前面添加幾個(gè)字節(jié)。
比方說,程序以固定的4個(gè)字節(jié)表示消息的長度(即《Muduo 網(wǎng)絡(luò)編程示例之二:Boost.Asio 的聊天服務(wù)器》中的 LengthHeaderCodec),我要序列化一個(gè)消息,但是不知道它有多長,那么我可以一直 append() 直到序列化完成(圖 17,寫入了 200 字節(jié)),然后再在序列化數(shù)據(jù)的前面添加消息的長度(圖 18,把 200 這個(gè)數(shù) prepend 到首部)。
圖 17
圖 18
通過預(yù)留 kCheapPrependable 空間,可以簡化客戶代碼,一個(gè)簡單的空間換時(shí)間思路。
其他設(shè)計(jì)方案
這里簡單談?wù)勂渌赡艿膽?yīng)用層 buffer 設(shè)計(jì)方案。
不用 vector<char>?
如果有 STL 潔癖,那么可以自己管理內(nèi)存,以 4 個(gè)指針為 buffer 的成員,數(shù)據(jù)結(jié)構(gòu)見圖 19。
圖 19
說實(shí)話我不覺得這種方案比 vector 好。代碼變復(fù)雜,性能也未見得有 noticeable 的改觀。
如果放棄“連續(xù)性”要求,可以用 circular buffer,這樣可以減少一點(diǎn)內(nèi)存拷貝(沒有“內(nèi)部騰挪”)。
Zero copy ?
如果對(duì)性能有極高的要求,受不了 copy() 與 resize(),那么可以考慮實(shí)現(xiàn)分段連續(xù)的 zero copy buffer 再配合 gather scatter IO,數(shù)據(jù)結(jié)構(gòu)如圖 20,這是 libevent 2.0.x 的設(shè)計(jì)方案。TCPv2介紹的 BSD TCP/IP 實(shí)現(xiàn)中的 mbuf 也是類似的方案,Linux 的 sk_buff 估計(jì)也差不多。細(xì)節(jié)有出入,但基本思路都是不要求數(shù)據(jù)在內(nèi)存中連續(xù),而是用鏈表把數(shù)據(jù)塊鏈接到一起。
圖 20
當(dāng)然,高性能的代價(jià)是代碼變得晦澀難讀,buffer 不再是連續(xù)的,parse 消息會(huì)稍微麻煩。如果你的程序只處理 protobuf Message,這不是問題,因?yàn)?protobuf 有 ZeroCopyInputStream 接口,只要實(shí)現(xiàn)這個(gè)接口,parsing 的事情就交給 protobuf Message 去操心了。
性能是不是問題?看跟誰比
看到這里,有的讀者可能會(huì)嘀咕,muduo Buffer 有那么多可以優(yōu)化的地方,其性能會(huì)不會(huì)太低?對(duì)此,我的回應(yīng)是“可以優(yōu)化,不一定值得優(yōu)化。”
Muduo 的設(shè)計(jì)目標(biāo)是用于開發(fā)公司內(nèi)部的分布式程序。換句話說,它是用來寫專用的 Sudoku server 或者游戲服務(wù)器,不是用來寫通用的 httpd 或 ftpd 或 www proxy。前者通常有業(yè)務(wù)邏輯,后者更強(qiáng)調(diào)高并發(fā)與高吞吐。
以 Sudoku 為例,假設(shè)求解一個(gè) Sudoku 問題需要 0.2ms,服務(wù)器有 8 個(gè)核,那么理想情況下每秒最多能求解 40,000 個(gè)問題。每次 Sudoku 請(qǐng)求的數(shù)據(jù)大小低于 100 字節(jié)(一個(gè) 9x9 的數(shù)獨(dú)只要 81 字節(jié),加上 header 也可以控制在 100 bytes 以下),就是說 100 x 40000 = 4 MB per second 的吞吐量就足以讓服務(wù)器的 CPU 飽和。在這種情況下,去優(yōu)化 Buffer 的內(nèi)存拷貝次數(shù)似乎沒有意義。
再舉一個(gè)例子,目前最常用的千兆以太網(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 倍以上。就是說,對(duì)于幾 k 或幾十 k 大小的數(shù)據(jù),在內(nèi)存里邊拷幾次根本不是問題,因?yàn)槭芤蕴W(wǎng)延遲和帶寬的限制,跟這個(gè)程序通信的其他機(jī)器上的程序不會(huì)覺察到性能差異。
最后舉一個(gè)例子,如果你實(shí)現(xiàn)的服務(wù)程序要跟數(shù)據(jù)庫打交道,那么瓶頸常常在 DB 上,優(yōu)化服務(wù)程序本身不見得能提高性能(從 DB 讀一次數(shù)據(jù)往往就抵消了你做的全部 low-level 優(yōu)化),這時(shí)不如把精力投入在 DB 調(diào)優(yōu)上。
專用服務(wù)程序與通用服務(wù)程序的另外一點(diǎn)區(qū)別是 benchmark 的對(duì)象不同。如果你打算寫一個(gè) httpd,自然有人會(huì)拿來和目前最好的 nginx 對(duì)比,立馬就能比出性能高低。然而,如果你寫一個(gè)實(shí)現(xiàn)公司內(nèi)部業(yè)務(wù)的服務(wù)程序(比如分布式存儲(chǔ)或者搜索或者微博或者短網(wǎng)址),由于市面上沒有同等功能的開源實(shí)現(xiàn),你不需要在優(yōu)化上投入全部精力,只要一版做得比一版好就行。先正確實(shí)現(xiàn)所需的功能,投入生產(chǎn)應(yīng)用,然后再根據(jù)真實(shí)的負(fù)載情況來做優(yōu)化,這恐怕比在編碼階段就盲目調(diào)優(yōu)要更 effective 一些。
Muduo 的設(shè)計(jì)目標(biāo)之一是吞吐量能讓千兆以太網(wǎng)飽和,也就是每秒收發(fā) 120 兆字節(jié)的數(shù)據(jù)。這個(gè)很容易就達(dá)到,不用任何特別的努力。
如果確實(shí)在內(nèi)存帶寬方面遇到問題,說明你做的應(yīng)用實(shí)在太 critical,或許應(yīng)該考慮放到 Linux kernel 里邊去,而不是在用戶態(tài)嘗試各種優(yōu)化。畢竟只有把程序做到 kernel 里才能真正實(shí)現(xiàn) zero copy,否則,核心態(tài)和用戶態(tài)之間始終是有一次內(nèi)存拷貝的。如果放到 kernel 里還不能滿足需求,那么要么自己寫新的 kernel,或者直接用 FPGA 或 ASIC 操作 network adapter 來實(shí)現(xiàn)你的高性能服務(wù)器。
(待續(xù))