Node Buffer/Stream內(nèi)存策略分析
在Node 中,Buffer 是一個(gè)廣泛用到的類(lèi),本文將從以下層次來(lái)分析其內(nèi)存策略:
◆ User 層面,即Node lib/*.js 或用戶(hù)自己的Js 文件調(diào)用 new Buffer
◆ Socekt read/write
◆ File read/write
51CTO推薦專(zhuān)題:Node.js專(zhuān)區(qū)
User Buffer
在 lib/buffer.js 模塊中,有個(gè)模塊私有變量 pool, 它指向當(dāng)前的一個(gè)8K 的slab :
- Buffer.poolSize = 8 * 1024;
- var pool;
- function allocPool() {
- pool = new SlowBuffer(Buffer.poolSize);
- pool.used = 0;
- }
SlowBuffer 為 src/node_buffer.cc 導(dǎo)出,當(dāng)用戶(hù)調(diào)用new Buffer時(shí) ,如果你要申請(qǐng)的空間大于8K,Node 會(huì)直接調(diào)用SlowBuffer ,如果小于8K ,新的Buffer 會(huì)建立在當(dāng)前slab 之上:
◆ 新創(chuàng)建的Buffer的 parent成員變量會(huì)指向這個(gè)slab ,
◆ offset 變量指向在這個(gè)slab 中的偏移:
- if (!pool || pool.length - pool.used < this.length) allocPool();
- this.parent = pool;
- this.offset = pool.used;
- pool.used += this.length;
比如當(dāng)你需要2K 的空間時(shí) : new Buffer(2*1024),它會(huì)檢查這個(gè)slab 的剩余空間,如果有剩余,則分配給你這段可用空間,并把當(dāng)前 slab 的已用空間 used += 2*1024
比如當(dāng)我們連續(xù)兩次調(diào)用new Buffer(2*1024)時(shí) :

當(dāng)我們?cè)俅紊暾?qǐng)一個(gè)5K 的空間時(shí),當(dāng)前的pool 僅有4K 可用,所以這時(shí)node會(huì)再次申請(qǐng)一個(gè)8K 的slab ,并把當(dāng)前的pool 指向它 ,注意此時(shí)原先的slab 會(huì)有4K空間被浪費(fèi):

此時(shí)原先的slab 被兩個(gè)2K 的 Buffer 所引用,所以當(dāng)這兩個(gè)Buffer 引用都變?yōu)閚ull 后,V8 認(rèn)為可以銷(xiāo)毀這個(gè)slab。
注意,假如我們的某一個(gè)slab被一個(gè)1Byte 的Buffer 所引用 ,那么,即使其他所有的引用都已經(jīng)變?yōu)閚ull ,這塊8K 的slab 也不會(huì)被回收:

Socket 讀寫(xiě)
首先讓我們看stream read 的情況:
在stream_wrap 當(dāng)中,此時(shí)的策略與用戶(hù)層的 new Buffer 相似,只是slab 的 size 變?yōu)?1MB ,此時(shí)我們需要考慮socket “讀操作” 緩沖區(qū)大小問(wèn)題,設(shè)想以下,假如我們數(shù)據(jù)長(zhǎng)度為30K,而我們的緩沖區(qū)大小僅為2K,這意味著我們至少調(diào)用15次socket read操作,要觸發(fā)15次 on(“data”) 事件,每次都需要把這個(gè)事件及數(shù)據(jù)從libuv 層次傳遞到用戶(hù)js 層次,這是極其低效的,所以我們需要設(shè)置一個(gè)較大的緩沖區(qū),在libuv 的 unix/stream.c ,當(dāng)綁定socket 的 watcher read 事件被觸發(fā)時(shí),會(huì)調(diào)用uv__read 函數(shù),其固化了buffer 大小為64*1024 :
- ...
- buf = stream->alloc_cb((uv_handle_t*)stream, 64 * 1024);
- ...
alloc_cb 定義在 stream_wrap.cc 中
uv_buf_t StreamWrap::OnAlloc(uv_handle_t* handle, size_t suggested_size)

但事實(shí)上我們知道,我們socket read 一般很少會(huì)有64K 大小,比如假如nread 僅為 2k,此時(shí)我們?yōu)榱吮苊饫速M(fèi),可以重設(shè)slab_used :
- if (handle_that_last_alloced == handle) {
- slab_used -= (buf.len - nread);
- }

敬請(qǐng)注意,我們之所以能夠這么做,是因?yàn)楫?dāng)檢測(cè)到socket 上read事件時(shí)才分配緩沖區(qū), alloc_cb →socket read → read callback 這一過(guò)程是順序進(jìn)行的,沒(méi)有外來(lái)的干擾!(我不明白為何node 還要加上一次判斷 if (handle_that_last_alloced == handle) ,深究的可以告訴我)
我們看到,在socket read 的情況下,緩沖區(qū)的管理在stream_wrap 中控制,uv steram.c 執(zhí)行讀操作,返回的回調(diào)函數(shù)也是在stream_wrap 中定義,然后把讀取到的Buffe 層層傳遞給user 的js當(dāng)中,即我們的on(“data”) 事件,這個(gè)過(guò)程中沒(méi)有額外的內(nèi)存拷貝,還是相當(dāng)高效的, 不過(guò)有個(gè)問(wèn)題:假使你持久引用了一個(gè)有stream.read 上浮的Buffer ,你將導(dǎo)致其所引用的那個(gè)1M 的slab 得不到釋放!
我們?cè)趤?lái)看 Socket.prototype.write ,當(dāng)你傳入一個(gè) string 時(shí),node 會(huì)自動(dòng)生成一個(gè)Buffer ,如果你本身就是Buffer ,那就省了這一步 (注意調(diào)用的是user 層面的 new Buffer):
- // Change strings to buffers. SLOW
- if (typeof data == 'string') {
- data = new Buffer(data, encoding);
- }
然后這個(gè)Buffer 對(duì)應(yīng)的指針會(huì)層層傳遞,直至 uv 的stream.c 的相應(yīng)的 write 函數(shù),這個(gè)過(guò)程也不會(huì)再有額外的拷貝操作,尤其要注意的是:當(dāng)你直接傳入一個(gè)Buffer 時(shí),直至socket.write 回調(diào)返回表示結(jié)束,此過(guò)程中你不應(yīng)該再修改它,因?yàn)榈讓诱诨驅(qū)⒁僮魉?
文件讀寫(xiě)
regular file 的write 和 socket 比較類(lèi)似,沒(méi)什么亮點(diǎn),我們重點(diǎn)來(lái)看 file read。
關(guān)于IO 操作時(shí)bufsize 大小的重要性,上文已有介紹,記得APUE 中 steven 老先生也有專(zhuān)門(mén)的測(cè)試結(jié)果,此處不再贅述,
在 fs.ReadStream 時(shí),我們可以傳入一些參數(shù):
- { flags: 'r',
- encoding: null,
- fd: null,
- mode: 0666,
- bufferSize: 64 * 1024
- }
默認(rèn)bufsize 為 64K ,但在 lib/fs.js 中,還有一個(gè)poolSize 控制變量:
- var kPoolSize = 40 * 1024;
當(dāng)node 最終實(shí)際調(diào)用fs.read 時(shí):
- var thisPool = pool;
- var toRead = Math.min(pool.length - pool.used, this.bufferSize);
- var start = pool.used;
Node 會(huì)對(duì)用戶(hù)傳入的bufsize 與 當(dāng)前pool 的剩余空間作比較,取其小者而用之,所以默認(rèn)的64*1024 大小其實(shí)是永遠(yuǎn)不會(huì)生效的。
好吧,40K 大小也可以接受,但如果你要讀取的文件比較小,比如1K ,2K 級(jí)別的比較多,這時(shí)我們預(yù)留40K 的buf ,當(dāng)讀返回時(shí),其實(shí)只用到了1K 或 2K ,這時(shí)候,Node 不會(huì)再像socket.read 那樣,再把 pool.used 減去 39K 或 38K ,因?yàn)槲覀儗?shí)際的fs.read 操作是在另一獨(dú)立線(xiàn)程中執(zhí)行的,即 buf alloc → fs read → read cb 這一個(gè)過(guò)程不是順序的,我們不能再像socket.read 那樣重新設(shè)置pool used !這種情況下內(nèi)存的浪費(fèi)相當(dāng)嚴(yán)重!
所以當(dāng)你想緩存大量小文件時(shí),如靜態(tài)服務(wù)器,我的建議是:自己分配大塊Buffer ,然后把從fs.readStream 上浮的Buffer 拷貝到我們自己的大塊Buffer 中,然后在這個(gè)大塊Buffer 上做 slice生成相應(yīng)的小Buffer ,這樣我們就沒(méi)有引用readStream 上浮的Buffer ,使其可以被V8 回收,當(dāng)然如果你內(nèi)存足夠你揮霍,當(dāng)我啥都沒(méi)說(shuō)…
內(nèi)存池
再來(lái)看底層的node_buffer :
void Buffer::Replace(char *data, size_t length, free_callback callback, void *hint)
這個(gè)函數(shù)的內(nèi)存操作很單純:
- ….
- delete [] data_;
- ….
- data_ = new char[length_];
其實(shí)通過(guò)上面分析可知,一個(gè)繁忙的網(wǎng)絡(luò)服務(wù)器,很可能會(huì)頻繁的new/delete 8K / 1M 的內(nèi)存塊,如果是靜態(tài)文件服務(wù),可能還會(huì)有頻繁的40K 內(nèi)存塊的操作,所以我試著對(duì)node 添加了 8K 內(nèi)存塊的內(nèi)存池控制,服務(wù)繁忙時(shí)命中率無(wú)限接近100%,可惜總體性能提升沒(méi)有達(dá)到預(yù)期,在此就不現(xiàn)拙了,有興趣的同學(xué)可以自己hack 玩玩,有成果了可以知會(huì)我一聲(http://weibo.com/windyrobin)…
小節(jié):
由以上分析,我們可知
◆ 不要輕易持久引用由 socket.readStream 或 fs.readStream 上浮的Buffe
◆ 當(dāng)你調(diào)用stream.write 并直接傳遞Buffer 進(jìn)去時(shí),在此操作返回之前,你不應(yīng)該再修改它
◆ 當(dāng)調(diào)用fs.readStream 時(shí),如果你對(duì)文件大小有估值,盡量傳入較接近的bufsize
◆ 當(dāng)你持久引用一個(gè)Buffer 時(shí),哪怕它只有一個(gè)字節(jié),也可能導(dǎo)致其依賴(lài)的slab (可能是8K /1M…)得不到釋放
附:以上分析基于node 0.6 系列,就這方面的問(wèn)題,我已提交了幾個(gè)Issue 給 Node 官方,開(kāi)發(fā)人員正在對(duì)以上暴露的問(wèn)題就行改進(jìn):
原文:http://cnodejs.org/blog/?p=4186
【編輯推薦】