陳碩 (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 問題的頁(yè)面介紹了五種 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ù)端編程的問題就簡(jiǎn)化為如何設(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ò)庫(kù)來操心,程序只要調(diào)用 TcpConnection::send() 就行了,網(wǎng)絡(luò)庫(kù)會(huì)負(fù)責(zé)到底。網(wǎng)絡(luò)庫(kù)應(yīng)該接管這剩余的 20k 字節(jié)數(shù)據(jù),把它保存在該 TCP connection 的 output buffer 里,然后注冊(cè) POLLOUT 事件,一旦 socket 變得可寫就立刻發(fā)送數(shù)據(jù)。當(dāng)然,這第二次 write() 也不一定能完全寫入 20k 字節(jié),如果還有剩余,網(wǎng)絡(luò)庫(kù)應(yīng)該繼續(xù)關(guān)注 POLLOUT 事件;如果寫完了 20k 字節(jié),網(wǎng)絡(luò)庫(kù)應(yīng)該停止關(guān)注 POLLOUT,以免造成 busy loop。(Muduo EventLoop 采用的是 epoll level trigger,這么做的具體原因我以后再說。)
如果程序又寫入了 50k 字節(jié),而這時(shí)候 output buffer 里還有待發(fā)送的 20k 數(shù)據(jù),那么網(wǎng)絡(luò)庫(kù)不應(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ò)庫(kù)不能立刻關(guān)閉連接,而要等數(shù)據(jù)發(fā)送完畢,見我在《為什么 muduo 的 shutdown() 沒有直接關(guān)閉 TCP 連接?》一文中的講解。
綜上,要讓程序在 write 操作上不阻塞,網(wǎng)絡(luò)庫(kù)必須要給每個(gè) tcp connection 配置 output buffer。
TcpConnection 必須要有 input buffer
TCP 是一個(gè)無(wú)邊界的字節(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ò)庫(kù)在處理“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ò)庫(kù)必然要應(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ò)庫(kù)必須要給每個(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è)簡(jiǎn)單的驗(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ò)庫(kù)只提供相當(dāng)于 char buf[8192] 的緩沖,或者根本不提供緩沖區(qū),而僅僅通知程序“某 socket 可讀/某 socket 可寫”,要程序自己操心 IO buffering,這樣的網(wǎng)絡(luò)庫(kù)用起來就很不方便了。(我有所指,你懂得。)
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)增長(zhǎ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ù)。
誰(shuí)會(huì)用 Buffer?誰(shuí)寫誰(shuí)讀?根據(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 中去了;如果長(zhǎng)度超過 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ù)的跨線程傳遞,一種簡(jiǎn)單的做法是把數(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ā)送了兩條消息,長(zhǎng)度分別為 50 字節(jié)和 350 字節(jié),接收方分兩次收到數(shù)據(jù),每次 200 字節(jié),然后進(jìn)行分包,再分兩次回調(diào)客戶代碼。
自動(dòng)增長(zhǎng)
Muduo Buffer 不是固定長(zhǎng)度的,它可以自動(dòng)增長(zhǎng),這是使用 vector 的直接好處。
假設(shè)當(dāng)前的狀態(tài)如圖 7 所示。(這和前面圖 5 是一樣的。)
圖 7
客戶代碼一次性寫入 1000 字節(jié),而當(dāng)前可寫的字節(jié)數(shù)只有 624,那么 buffer 會(huì)自動(dòng)增長(zhǎ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)增長(zhǎ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ù)方式增長(zhǎng),讓 push_back() 的平均復(fù)雜度是常數(shù)。比方說經(jīng)過第一次增長(zhǎ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é)表示消息的長(zhǎng)度(即《Muduo 網(wǎng)絡(luò)編程示例之二:Boost.Asio 的聊天服務(wù)器》中的 LengthHeaderCodec),我要序列化一個(gè)消息,但是不知道它有多長(zhǎng),那么我可以一直 append() 直到序列化完成(圖 17,寫入了 200 字節(jié)),然后再在序列化數(shù)據(jù)的前面添加消息的長(zhǎng)度(圖 18,把 200 這個(gè)數(shù) prepend 到首部)。
圖 17
圖 18
通過預(yù)留 kCheapPrependable 空間,可以簡(jiǎn)化客戶代碼,一個(gè)簡(jiǎn)單的空間換時(shí)間思路。
其他設(shè)計(jì)方案
這里簡(jiǎn)單談?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 去操心了。
性能是不是問題?看跟誰(shuí)比
看到這里,有的讀者可能會(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ù)庫(kù)打交道,那么瓶頸常常在 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ù))