为什么尝试写大型文件导致js堆耗尽内存

sch*_*u34 6 v8 node.js node-streams

这段代码

const file = require("fs").createWriteStream("./test.dat");
for(var i = 0; i < 1e7; i++){

    file.write("a");
}
Run Code Online (Sandbox Code Playgroud)

运行约30秒后出现此错误消息

<--- Last few GCs --->

[47234:0x103001400]    27539 ms: Mark-sweep 1406.1 (1458.4) -> 1406.1 (1458.4) MB, 2641.4 / 0.0 ms  allocation failure GC in old space requested
[47234:0x103001400]    29526 ms: Mark-sweep 1406.1 (1458.4) -> 1406.1 (1438.9) MB, 1986.8 / 0.0 ms  last resort GC in old spacerequested
[47234:0x103001400]    32154 ms: Mark-sweep 1406.1 (1438.9) -> 1406.1 (1438.9) MB, 2628.3 / 0.0 ms  last resort GC in old spacerequested


<--- JS stacktrace --->

==== JS stack trace =========================================

Security context: 0x30f4a8e25ee1 <JSObject>
    1: /* anonymous */ [/Users/matthewschupack/dev/streamTests/1/write.js:~1] [pc=0x270efe213894](this=0x30f4e07ed2f1 <Object map = 0x30f4ede823b9>,exports=0x30f4e07ed2f1 <Object map = 0x30f4ede823b9>,require=0x30f4e07ed2a9 <JSFunction require (sfi = 0x30f493b410f1)>,module=0x30f4e07ed221 <Module map = 0x30f4edec1601>,__filename=0x30f493b47221 <String[49]: /Users/matthewschupack/dev/streamTests/...

FATAL ERROR: CALL_AND_RETRY_LAST Allocation failed - JavaScript heap out of memory
 1: node::Abort() [/usr/local/bin/node]
 2: node::FatalException(v8::Isolate*, v8::Local<v8::Value>, v8::Local<v8::Message>) [/usr/local/bin/node]
 3: v8::internal::V8::FatalProcessOutOfMemory(char const*, bool) [/usr/local/bin/node]
 4: v8::internal::Factory::NewFillerObject(int, bool, v8::internal::AllocationSpace) [/usr/local/bin/node]
 5: v8::internal::Runtime_AllocateInTargetSpace(int, v8::internal::Object**, v8::internal::Isolate*) [/usr/local/bin/node]
 6: 0x270efe08463d
 7: 0x270efe213894
 8: 0x270efe174048
[1]    47234 abort      node write.js
Run Code Online (Sandbox Code Playgroud)

而这段代码

const file = require("fs").createWriteStream("./test.dat");
for(var i = 0; i < 1e6; i++){

    file.write("aaaaaaaaaa");//ten a's
}
Run Code Online (Sandbox Code Playgroud)

几乎立即完美运行并生成10MB文件.据我所知,流的重点是两个版本应该在大约相同的时间内运行,因为数据是相同的.即使将a每次迭代次数增加到100次或1000次,也几乎不会增加运行时间,并且写入1GB文件时没有任何问题.在1e6迭代中每次迭代编写单个字符也可以正常工作.

这里发生了什么?

Mar*_*nde 16

发生内存不足错误是因为您没有等待drain发出事件,而不等待Node.js将缓冲所有写入的块,直到最大内存使用量发生.

.write将返回false如果内部缓冲器大于highWaterMark缺省为16384个字节(16KB).在您的代码中,您没有处理返回值.write,因此缓冲区永远不会被刷新.

这可以使用以下方法轻松测试: tail -f test.dat

执行脚本时,您将看到test.dat在脚本完成之前没有写入任何内容.

对于1e7缓冲区应清除610次.

1e7 / 16384 = 610
Run Code Online (Sandbox Code Playgroud)

解决方案是包装.writefalse返回if ,等待直到file.once('drain')发出事件

注意: drain在节点v9.3.0中添加

const file = require("fs").createWriteStream("./test.dat");

(async() => {

    for(let i = 0; i < 1e7; i++) {
        if(!file.write('a')) {
            // Will pause every 16384 iterations until `drain` is emitted
            await new Promise(resolve => file.once('drain', resolve));
        }
    }
})();
Run Code Online (Sandbox Code Playgroud)

现在,如果您这样做,writable.writableHighWaterMark您将看到在脚本仍在运行时如何写入数据.


至于为什么你得到1e7而不是1e6的内存问题,我们必须看看Node.Js如何进行缓冲,这发生在writeOrBuffer函数中.

此示例代码将允许我们粗略估计内存使用情况:

const count = Number(process.argv[2]) || 1e6;
const state = {};

function nop() {}

const buffer = (data) => {
    const last = state.lastBufferedRequest;
    state.lastBufferedRequest = {
      chunk: Buffer.from(data),
      encoding: 'buffer',
      isBuf: true,
      callback: nop,
      next: null
    };

    if(last)
      last.next = state.lastBufferedRequest;
    else
      state.bufferedRequest = state.lastBufferedRequest;

    state.bufferedRequestCount += 1;
}

const start = process.memoryUsage().heapUsed;
for(let i = 0; i < count; i++) {
    buffer('a');
}
const used = (process.memoryUsage().heapUsed - start) / 1024 / 1024;
console.log(`${Math.round(used * 100) / 100} MB`);
Run Code Online (Sandbox Code Playgroud)

执行时:

// node memory.js <count>
1e4: 1.98 MB
1e5: 16.75 MB
1e6: 160 MB
5e6: 801.74 MB
8e6: 1282.22 MB
9e6: 1442.22 MB - Out of memory
1e7: 1602.97 MB - Out of memory
Run Code Online (Sandbox Code Playgroud)

因此,每个对象都使用tail -f test.dat,并且在执行1e7 ~0.16 kb而不等待writes事件时,您在内存中有1000万个这样的对象(公平地说它在达到10M之前崩溃)

如果使用单个drain或1000个无关紧要,那么内存增加可以忽略不计.


您可以使用a标志增加节点使用的最大内存:

node --max_old_space_size=4096 memory.js 1e7
Run Code Online (Sandbox Code Playgroud)

更新:我在内存片段上犯了一个错误,导致内存使用量增加了30%.我正在为每个--max_old_space_size={MB}Node 创建一个新的回调,Node重用.write回调.


更新II

如果你总是写相同的值(在实际场景中可疑),你可以通过每次传递相同的缓冲区来大大减少内存使用和执行时间:

const buf = Buffer.from('a');
for(let i = 0; i < 1e7; i++) {
    if(!file.write(buf)) {
        // Will pause every 16384 iterations until `drain` is emitted
        await new Promise(resolve => file.once('drain', resolve));
    }
}
Run Code Online (Sandbox Code Playgroud)

  • 因为你总共只写了10mb的数据,所以只占用10mb的内存.在您的情况下,耗尽的内存不是由写入的数据引起的,而是由节点存储块的方式引起的.如果你正在写1个字节的数据,缓冲区中的那个块将占用~220个字节. (3认同)
  • 这太棒了,清理了很多。感谢您提供详细信息。现在我明白了 highWaterMark 是如何工作的,但是为什么*不*在 1e6 次迭代时写入 1000 字节的数据会导致崩溃? (2认同)
  • @ schu34我在回调时犯了一个错误,因为它没有创建一个新的回调,每个`.write`它都会重新声明`nop`,它被声明一次.我更新了我的答案以反映这一点. (2认同)