1. 程式人生 > >Muduo 設計與實現之一:Buffer 類的設計

Muduo 設計與實現之一:Buffer 類的設計

陳碩 (giantchen_AT_gmail)

本文介紹 Muduo 中輸入輸出緩衝區的設計與實現。

本文中 buffer 指一般的應用層緩衝區、緩衝技術,Buffer 特指 muduo::net::Buffer class。

Muduo 的 IO 模型

UNPv1 第 6.2 節總結了 Unix/Linux 上的五種 IO 模型:阻塞(blocking)、非阻塞(non-blocking)、IO 複用(IO multiplexing)、訊號驅動(signal-driven)、非同步(asynchronous)。這些都是單執行緒下的 IO 模型。

C10k 問題的頁面介紹了五種 IO 策略,把執行緒也納入考量。(現在 C10k 已經不是什麼問題,C100k 也不是大問題,C1000k 才算得上挑戰)。

在這個多核時代,執行緒是不可避免的。那麼服務端網路程式設計該如何選擇執行緒模型呢?我贊同 libev 作者的觀點:one loop per thread is usually a good model。之前我也不止一次表述過這個觀點,見《多執行緒伺服器的常用程式設計模型》《多執行緒伺服器的適用場合》。

如果採用 one loop per thread 的模型,多執行緒服務端程式設計的問題就簡化為如何設計一個高效且易於使用的 event loop,然後每個執行緒 run 一個 event loop 就行了(當然、同步和互斥是不可或缺的)。在“高效”這方面已經有了很多成熟的範例(libev、libevent、memcached、varnish、lighttpd、nginx),在“易於使用”方面我希望 muduo 能有所作為。(muduo 可算是用現代 C++ 實現了 Reactor 模式,比起原始的 Reactor 來說要好用得多。)

event loop 是 non-blocking 網路程式設計的核心,在現實生活中,non-blocking 幾乎總是和 IO-multiplexing 一起使用,原因有兩點:

  • 沒有人真的會用輪詢 (busy-pooling) 來檢查某個 non-blocking IO 操作是否完成,這樣太浪費 CPU cycles。
  • IO-multiplex 一般不能和 blocking IO 用在一起,因為 blocking IO 中 read()/write()/accept()/connect() 都有可能阻塞當前執行緒,這樣執行緒就沒辦法處理其他 socket 上的 IO 事件了。見 UNPv1 第 16.6 節“nonblocking accept”的例子。

所以,當我提到 non-blocking 的時候,實際上指的是 non-blocking + IO-muleiplexing,單用其中任何一個是不現實的。另外,本文所有的“連線”均指 TCP 連線,socket 和 connection 在文中可互換使用。

當然,non-blocking 程式設計比 blocking 難得多,見陳碩在《Muduo 網路程式設計示例之零:前言》中“TCP 網路程式設計本質論”一節列舉的難點。基於 event loop 的網路程式設計跟直接用 C/C++ 編寫單執行緒 Windows 程式頗為相像:程式不能阻塞,否則視窗就失去響應了;在 event handler 中,程式要儘快交出控制權,返回視窗的事件迴圈。

為什麼 non-blocking 網路程式設計中應用層 buffer 是必須的?

Non-blocking IO 的核心思想是避免阻塞在 read() 或 write() 或其他 IO 系統呼叫上,這樣可以最大限度地複用 thread-of-control,讓一個執行緒能服務於多個 socket 連線。IO 執行緒只能阻塞在 IO-multiplexing 函式上,如 select()/poll()/epoll_wait()。這樣一來,應用層的緩衝是必須的,每個 TCP socket 都要有 stateful 的 input buffer 和 output buffer。

TcpConnection 必須要有 output buffer

考慮一個常見場景:程式想通過 TCP 連線傳送 100k 位元組的資料,但是在 write() 呼叫中,作業系統只接受了 80k 位元組(受 TCP advertised window 的控制,細節見 TCPv1),你肯定不想在原地等待,因為不知道會等多久(取決於對方什麼時候接受資料,然後滑動 TCP 視窗)。程式應該儘快交出控制權,返回 event loop。在這種情況下,剩餘的 20k 位元組資料怎麼辦?

對於應用程式而言,它只管生成資料,它不應該關心到底資料是一次性發送還是分成幾次傳送,這些應該由網路庫來操心,程式只要呼叫 TcpConnection::send() 就行了,網路庫會負責到底。網路庫應該接管這剩餘的 20k 位元組資料,把它儲存在該 TCP connection 的 output buffer 裡,然後註冊 POLLOUT 事件,一旦 socket 變得可寫就立刻傳送資料。當然,這第二次 write() 也不一定能完全寫入 20k 位元組,如果還有剩餘,網路庫應該繼續關注 POLLOUT 事件;如果寫完了 20k 位元組,網路庫應該停止關注 POLLOUT,以免造成 busy loop。(Muduo EventLoop 採用的是 epoll level trigger,這麼做的具體原因我以後再說。)

如果程式又寫入了 50k 位元組,而這時候 output buffer 裡還有待發送的 20k 資料,那麼網路庫不應該直接呼叫 write(),而應該把這 50k 資料 append 在那 20k 資料之後,等 socket 變得可寫的時候再一併寫入。

如果 output buffer 裡還有待發送的資料,而程式又想關閉連線(對程式而言,呼叫 TcpConnection::send() 之後他就認為資料遲早會發出去),那麼這時候網路庫不能立刻關閉連線,而要等資料傳送完畢,見我在《為什麼 muduo 的 shutdown() 沒有直接關閉 TCP 連線?》一文中的講解。

綜上,要讓程式在 write 操作上不阻塞,網路庫必須要給每個 tcp connection 配置 output buffer。

TcpConnection 必須要有 input buffer

TCP 是一個無邊界的位元組流協議,接收方必須要處理“收到的資料尚不構成一條完整的訊息”和“一次收到兩條訊息的資料”等等情況。一個常見的場景是,傳送方 send 了兩條 10k 位元組的訊息(共 20k),接收方收到資料的情況可能是:

  • 一次性收到 20k 資料
  • 分兩次收到,第一次 5k,第二次 15k
  • 分兩次收到,第一次 15k,第二次 5k
  • 分兩次收到,第一次 10k,第二次 10k
  • 分三次收到,第一次 6k,第二次 8k,第三次 6k
  • 其他任何可能

網路庫在處理“socket 可讀”事件的時候,必須一次性把 socket 裡的資料讀完(從作業系統 buffer 搬到應用層 buffer),否則會反覆觸發 POLLIN 事件,造成 busy-loop。(Again, Muduo EventLoop 採用的是 epoll level trigger,這麼做的具體原因我以後再說。)

那麼網路庫必然要應對“資料不完整”的情況,收到的資料先放到 input buffer 裡,等構成一條完整的訊息再通知程式的業務邏輯。這通常是 codec 的職責,見陳碩《Muduo 網路程式設計示例之二:Boost.Asio 的聊天伺服器》一文中的“TCP 分包”的論述與程式碼。

所以,在 tcp 網路程式設計中,網路庫必須要給每個 tcp connection 配置 input buffer。

所有 muduo 中的 IO 都是帶緩衝的 IO (buffered IO),你不會自己去 read() 或 write() 某個 socket,只會操作 TcpConnection 的 input buffer 和 output buffer。更確切的說,是在 onMessage() 回撥裡讀取 input buffer;呼叫 TcpConnection::send() 來間接操作 output buffer,一般不會直接操作 output buffer。

btw, muduo 的 onMessage() 的原型如下,它既可以是 free function,也可以是 member function,反正 muduo TcpConnection 只認 boost::function<>。

void onMessage(const TcpConnectionPtr& conn, Buffer* buf, Timestamp receiveTime);

對於網路程式來說,一個簡單的驗收測試是:輸入資料每次收到一個位元組(200 位元組的輸入資料會分 200 次收到,每次間隔 10 ms),程式的功能不受影響。對於 Muduo 程式,通常可以用 codec 來分離“訊息接收”與“訊息處理”,見陳碩《在 muduo 中實現 protobuf 編解碼器與訊息分發器》一文中對“編解碼器 codec”的介紹。

如果某個網路庫只提供相當於 char buf[8192] 的緩衝,或者根本不提供緩衝區,而僅僅通知程式“某 socket 可讀/某 socket 可寫”,要程式自己操心 IO buffering,這樣的網路庫用起來就很不方便了。(我有所指,你懂得。)

Buffer 的要求

Muduo Buffer 的設計考慮了常見的網路程式設計需求,我試圖在易用性和效能之間找一個平衡點,目前這個平衡點更偏向於易用性。

Muduo Buffer 的設計要點:

  • 對外表現為一塊連續的記憶體(char*, len),以方便客戶程式碼的編寫。
  • 其 size() 可以自動增長,以適應不同大小的訊息。它不是一個 fixed size array (即 char buf[8192])。
  • 內部以 vector of char 來儲存資料,並提供相應的訪問函式。

Buffer 其實像是一個 queue,從末尾寫入資料,從頭部讀出資料。

誰會用 Buffer?誰寫誰讀?根據前文分析,TcpConnection 會有兩個 Buffer 成員,input buffer 與 output buffer。

  • input buffer,TcpConnection 會從 socket 讀取資料,然後寫入 input buffer(其實這一步是用 Buffer::readFd() 完成的);客戶程式碼從 input buffer 讀取資料。
  • output buffer,客戶程式碼會把資料寫入 output buffer(其實這一步是用 TcpConnection::send() 完成的);TcpConnection 從 output buffer 讀取資料並寫入 socket。

其實,input 和 output 是針對客戶程式碼而言,客戶程式碼從 input 讀,往 output 寫。TcpConnection 的讀寫正好相反。

以下是 muduo::net::Buffer 的類圖。請注意,為了後面畫圖方便,這個類圖跟實際程式碼略有出入,但不影響我要表達的觀點。

bc

這裡不介紹每個成員函式的作用,留給《Muduo 網路程式設計示例》系列。下文會仔細介紹 readIndex 和 writeIndex 的作用。

Buffer::readFd()

  • 在非阻塞網路程式設計中,如何設計並使用緩衝區?一方面我們希望減少系統呼叫,一次讀的資料越多越划算,那麼似乎應該準備一個大的緩衝區。另一方面,我們系統減少記憶體佔用。如果有 10k 個連線,每個連線一建立就分配 64k 的讀緩衝的話,將佔用 640M 記憶體,而大多數時候這些緩衝區的使用率很低。muduo 用 readv 結合棧上空間巧妙地解決了這個問題。

具體做法是,在棧上準備一個 65536 位元組的 stackbuf,然後利用 readv() 來讀取資料,iovec 有兩塊,第一塊指向 muduo Buffer 中的 writable 位元組,另一塊指向棧上的 stackbuf。這樣如果讀入的資料不多,那麼全部都讀到 Buffer 中去了;如果長度超過 Buffer 的 writable 位元組數,就會讀到棧上的 stackbuf 裡,然後程式再把 stackbuf 裡的資料 append 到 Buffer 中。

這麼做利用了臨時棧上空間,避免開巨大 Buffer 造成的記憶體浪費,也避免反覆呼叫 read() 的系統開銷(通常一次 readv() 系統呼叫就能讀完全部資料)。

這算是一個小小的創新吧。

執行緒安全?

muduo::net::Buffer 不是執行緒安全的,這麼做是有意的,原因如下:

  • 對於 input buffer,onMessage() 回撥始終發生在該 TcpConnection 所屬的那個 IO 執行緒,應用程式應該在 onMessage() 完成對 input buffer 的操作,並且不要把 input buffer 暴露給其他執行緒。這樣所有對 input buffer 的操作都在同一個執行緒,Buffer class 不必是執行緒安全的。
  • 對於 output buffer,應用程式不會直接操作它,而是呼叫 TcpConnection::send() 來發送資料,後者是執行緒安全的。

如果 TcpConnection::send() 呼叫發生在該 TcpConnection 所屬的那個 IO 執行緒,那麼它會轉而呼叫 TcpConnection::sendInLoop(),sendInLoop() 會在當前執行緒(也就是 IO 執行緒)操作 output buffer;如果 TcpConnection::send() 呼叫發生在別的執行緒,它不會在當前執行緒呼叫 sendInLoop() ,而是通過 EventLoop::runInLoop() 把 sendInLoop() 函式呼叫轉移到 IO 執行緒(聽上去頗為神奇?),這樣 sendInLoop() 還是會在 IO 執行緒操作 output buffer,不會有執行緒安全問題。當然,跨執行緒的函式轉移呼叫涉及函式引數的跨執行緒傳遞,一種簡單的做法是把資料拷一份,絕對安全(不明白的同學請閱讀程式碼)。

另一種更為高效做法是用 swap()。這就是為什麼 TcpConnection::send() 的某個過載以 Buffer* 為引數,而不是 const Buffer&,這樣可以避免拷貝,而用 Buffer::swap() 實現高效的執行緒間資料轉移。(最後這點,僅為設想,暫未實現。目前仍然以資料拷貝方式線上程間傳遞,略微有些效能損失。)

Muduo Buffer 的資料結構

Buffer 的內部是一個 vector of char,它是一塊連續的記憶體。此外,Buffer 有兩個 data members,指向該 vector 中的元素。這兩個 indices 的型別是 int,不是 char*,目的是應對迭代器失效。muduo Buffer 的設計參考了 Netty 的 ChannelBuffer 和 libevent 1.4.x 的 evbuffer。不過,其 prependable 可算是一點“微創新”。

Muduo Buffer 的資料結構如下:

圖 1buffer0

兩個 indices 把 vector 的內容分為三塊:prependable、readable、writable,各塊的大小是(公式一):

prependable = readIndex

readable = writeIndex - readIndex

writable = size() - writeIndex

(prependable 的作用留到後面討論。)

readIndex 和 writeIndex 滿足以下不變式(invariant):

0 ≤ readIndex ≤ writeIndex ≤ data.size()

Muduo Buffer 裡有兩個常數 kCheapPrepend 和 kInitialSize,定義了 prependable 的初始大小和 writable 的初始大小。(readable 的初始大小為 0。)在初始化之後,Buffer 的資料結構如下:括號裡的數字是該變數或常量的值。

圖 2buffer1

根據以上(公式一)可算出各塊的大小,剛剛初始化的 Buffer 裡沒有 payload 資料,所以 readable == 0。

Muduo Buffer 的操作

1. 基本的 read-write cycle

Buffer 初始化後的情況見圖 1,如果有人向 Buffer 寫入了 200 位元組,那麼其佈局是:

圖 3buffer2

圖 3 中 writeIndex 向後移動了 200 位元組,readIndex 保持不變,readable 和 writable 的值也有變化。

如果有人從 Buffer read() & retrieve() (下稱“讀入”)了 50 位元組,結果見圖 4。與上圖相比,readIndex 向後移動 50 位元組,writeIndex 保持不變,readable 和 writable 的值也有變化(這句話往後從略)。

圖 4buffer3

然後又寫入了 200 位元組,writeIndex 向後移動了 200 位元組,readIndex 保持不變,見圖 5

圖 5buffer4

接下來,一次性讀入 350 位元組,請注意,由於全部資料讀完了,readIndex 和 writeIndex 返回原位以備新一輪使用,見圖 6,這和圖 2 是一樣的。

圖 6buffer5

以上過程可以看作是傳送方傳送了兩條訊息,長度分別為 50 位元組和 350 位元組,接收方分兩次收到資料,每次 200 位元組,然後進行分包,再分兩次回撥客戶程式碼。

自動增長

Muduo Buffer 不是固定長度的,它可以自動增長,這是使用 vector 的直接好處。

假設當前的狀態如圖 7 所示。(這和前面圖 5 是一樣的。)

圖 7buffer4

客戶程式碼一次性寫入 1000 位元組,而當前可寫的位元組數只有 624,那麼 buffer 會自動增長以容納全部資料,得到的結果是圖 8。注意 readIndex 返回到了前面,以保持 prependable 等於 kCheapPrependable。由於 vector 重新分配了記憶體,原來指向它元素的指標會失效,這就是為什麼 readIndex 和 writeIndex 是整數下標而不是指標。

圖 8buffer6

然後讀入 350 位元組,readIndex 前移,見圖 9

圖 9buffer7

最後,讀完剩下的 1000 位元組,readIndex 和 writeIndex 返回 kCheapPrependable,見圖 10。

圖 10buffer8

注意 buffer 並沒有縮小大小,下次寫入 1350 位元組就不會重新分配記憶體了。換句話說,Muduo Buffer 的 size() 是自適應的,它一開始的初始值是 1k,如果程式裡邊經常收發 10k 的資料,那麼用幾次之後它的 size() 會自動增長到 10k,然後就保持不變。這樣一方面避免浪費記憶體(有的程式可能只需要 4k 的緩衝),另一方面避免反覆分配記憶體。當然,客戶程式碼可以手動 shrink() buffer size()。

size() 與 capacity()

使用 vector 的另一個好處是它的 capcity() 機制減少了記憶體分配的次數。比方說程式反覆寫入 1 位元組,muduo Buffer 不會每次都分配記憶體,vector 的 capacity() 以指數方式增長,讓 push_back() 的平均複雜度是常數。比方說經過第一次增長,size() 剛好滿足寫入的需求,如圖 11。但這個時候 vector 的 capacity() 已經大於 size(),在接下來寫入 capacity()-size() 位元組的資料時,都不會重新分配記憶體,見圖 12

圖 11buffer6

圖 12buffer9

細心的讀者可能會發現用 capacity() 也不是完美的,它有優化的餘地。具體來說,vector::resize() 會初始化(memset/bzero)記憶體,而我們不需要它初始化,因為反正立刻就要填入資料。比如,在圖 12 的基礎上寫入 200 位元組,由於 capacity() 足夠大,不會重新分配記憶體,這是好事;但是 vector::resize() 會先把那 200 位元組設為 0 (圖 13),然後 muduo buffer 再填入資料(圖 14)。這麼做稍微有點浪費,不過我不打算優化它,除非它確實造成了效能瓶頸。(精通 STL 的讀者可能會說用 vector::append() 以避免浪費,但是 writeIndex 和 size() 不一定是對齊的,會有別的麻煩。)

圖 13buffer9a

圖 14buffer9b

內部騰挪

有時候,經過若干次讀寫,readIndex 移到了比較靠後的位置,留下了巨大的 prependable 空間,見圖 14

圖 14buffer10

這時候,如果我們想寫入 300 位元組,而 writable 只有 200 位元組,怎麼辦?muduo Buffer 在這種情況下不會重新分配記憶體,而是先把已有的資料移到前面去,騰出 writable 空間,見圖 15

圖 15buffer11

然後,就可以寫入 300 位元組了,見圖 16

圖 16buffer12

這麼做的原因是,如果重新分配記憶體,反正也是要把資料拷到新分配的記憶體區域,代價只會更大。

prepend

前面說 muduo Buffer 有個小小的創新(或許不是創新,我記得在哪兒看到過類似的做法,忘了出處),即提供 prependable 空間,讓程式能以很低的代價在資料前面新增幾個位元組。

比方說,程式以固定的4個位元組表示訊息的長度(即《Muduo 網路程式設計示例之二:Boost.Asio 的聊天伺服器》中的 LengthHeaderCodec),我要序列化一個訊息,但是不知道它有多長,那麼我可以一直 append() 直到序列化完成(圖 17,寫入了 200 位元組),然後再在序列化資料的前面新增訊息的長度(圖 18,把 200 這個數 prepend 到首部)。

圖 17buffer13

圖 18buffer14

通過預留 kCheapPrependable 空間,可以簡化客戶程式碼,一個簡單的空間換時間思路。

其他設計方案

這裡簡單談談其他可能的應用層 buffer 設計方案。

不用 vector?

如果有 STL 潔癖,那麼可以自己管理記憶體,以 4 個指標為 buffer 的成員,資料結構見圖 19。

圖 19alternative

說實話我不覺得這種方案比 vector 好。程式碼變複雜,效能也未見得有 noticeable 的改觀。

如果放棄“連續性”要求,可以用 circular buffer,這樣可以減少一點記憶體拷貝(沒有“內部騰挪”)。

Zero copy ?

如果對效能有極高的要求,受不了 copy() 與 resize(),那麼可以考慮實現分段連續的 zero copy buffer 再配合 gather scatter IO,資料結構如圖 20,這是 libevent 2.0.x 的設計方案。TCPv2介紹的 BSD TCP/IP 實現中的 mbuf 也是類似的方案,Linux 的 sk_buff 估計也差不多。細節有出入,但基本思路都是不要求資料在記憶體中連續,而是用連結串列把資料塊連結到一起。

圖 20evbuf0

當然,高效能的代價是程式碼變得晦澀難讀,buffer 不再是連續的,parse 訊息會稍微麻煩。如果你的程式只處理 protobuf Message,這不是問題,因為 protobuf 有 ZeroCopyInputStream 介面,只要實現這個介面,parsing 的事情就交給 protobuf Message 去操心了。

效能是不是問題?看跟誰比

看到這裡,有的讀者可能會嘀咕,muduo Buffer 有那麼多可以優化的地方,其效能會不會太低?對此,我的迴應是“可以優化,不一定值得優化。”

Muduo 的設計目標是用於開發公司內部的分散式程式。換句話說,它是用來寫專用的 Sudoku server 或者遊戲伺服器,不是用來寫通用的 httpd 或 ftpd 或 www proxy。前者通常有業務邏輯,後者更強調高併發與高吞吐。

以 Sudoku 為例,假設求解一個 Sudoku 問題需要 0.2ms,伺服器有 8 個核,那麼理想情況下每秒最多能求解 40,000 個問題。每次 Sudoku 請求的資料大小低於 100 位元組(一個 9x9 的數獨只要 81 位元組,加上 header 也可以控制在 100 bytes 以下),就是說 100 x 40000 = 4 MB per second 的吞吐量就足以讓伺服器的 CPU 飽和。在這種情況下,去優化 Buffer 的記憶體拷貝次數似乎沒有意義。

再舉一個例子,目前最常用的千兆乙太網的裸吞吐量是 125MB/s,扣除乙太網 header、IP header、TCP header之後,應用層的吞吐率大約在 115 MB/s 上下。而現在伺服器上最常用的 DDR2/DDR3 記憶體的頻寬至少是 4GB/s,比千兆乙太網高 40 倍以上。就是說,對於幾 k 或幾十 k 大小的資料,在記憶體裡邊拷幾次根本不是問題,因為受乙太網延遲和頻寬的限制,跟這個程式通訊的其他機器上的程式不會覺察到效能差異。

最後舉一個例子,如果你實現的服務程式要跟資料庫打交道,那麼瓶頸常常在 DB 上,優化服務程式本身不見得能提高效能(從 DB 讀一次資料往往就抵消了你做的全部 low-level 優化),這時不如把精力投入在 DB 調優上。

專用服務程式與通用服務程式的另外一點區別是 benchmark 的物件不同。如果你打算寫一個 httpd,自然有人會拿來和目前最好的 nginx 對比,立馬就能比出效能高低。然而,如果你寫一個實現公司內部業務的服務程式(比如分散式儲存或者搜尋或者微博或者短網址),由於市面上沒有同等功能的開源實現,你不需要在優化上投入全部精力,只要一版做得比一版好就行。先正確實現所需的功能,投入生產應用,然後再根據真實的負載情況來做優化,這恐怕比在編碼階段就盲目調優要更 effective 一些。

Muduo 的設計目標之一是吞吐量能讓千兆乙太網飽和,也就是每秒收發 120 兆位元組的資料。這個很容易就達到,不用任何特別的努力。

如果確實在記憶體頻寬方面遇到問題,說明你做的應用實在太 critical,或許應該考慮放到 Linux kernel 裡邊去,而不是在使用者態嘗試各種優化。畢竟只有把程式做到 kernel 裡才能真正實現 zero copy,否則,核心態和使用者態之間始終是有一次記憶體拷貝的。如果放到 kernel 裡還不能滿足需求,那麼要麼自己寫新的 kernel,或者直接用 FPGA 或 ASIC 操作 network adapter 來實現你的高效能伺服器。

(待續)