为什么尝试写入大文件会导致 JS 堆内存耗尽

15

这段代码

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

    file.write("a");
}

运行约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

尽管这段代码

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

    file.write("aaaaaaaaaa");//ten a's
}

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

这里发生了什么?


猜测一下,可能是每次只写入一个字符会导致更多的内存分配来调整流缓冲区大小,但运行1e7循环永远不会给垃圾回收机会运行或写入机会得到处理。 - jfriend00
2
我不确定为什么 1e7 会导致内存溢出,但是您应该能够通过使用 drain 事件来避免OOM并尊重反压。如果需要在下一次写入之前等待,则 file.write(...) 返回 false。文档中有一个示例:https://nodejs.org/api/stream.html#stream_writable_write_chunk_encoding_callback - Robbie
1
你可以将可读流导入写入流中,它会自动处理反压。 - Robbie
它们是一样的,除了一个使用更多的内存导致错误。流的目的是允许缓冲I/O,在缓冲块中读入/输出以避免像这样的内存溢出问题。 - Alex W
1个回答

31

出现内存错误是因为您没有等待drain事件被触发。如果不等待Node.js会缓存所有写入的块,直到达到最大内存使用量。

.write如果内部缓冲区大于highWaterMark(默认为16384字节(16kb)),则返回false。在您的代码中,您没有处理.write的返回值,因此缓冲区永远不会被刷新。

可以使用以下命令轻松测试:tail -f test.dat

执行脚本时,您会发现在脚本完成之前未将任何内容写入test.dat

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

1e7 / 16384 = 610
一个解决方法是检查.write的返回值,如果返回false,则使用包装在一个Promise中的file.once('drain')来等待drain事件被触发。 注意:writable.writableHighWaterMark在Node 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));
        }
    }
})();

现在,如果你运行tail -f test.dat,你将看到数据是在脚本仍在运行时被写入的。


至于为什么用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`);

执行时:

// 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

每个对象使用约 0.16 kb,如果进行 1e7 次 writes 而不等待 drain 事件,那么您的内存中会有一千万个这样的对象(达到一千万之前程序崩溃)。

无论您是使用单个 a 还是 1000 个,从中增加的内存都是微不足道的。


您可以使用 --max_old_space_size={MB} 标志来增加 Node 的最大内存使用量 (当然这不是解决方案,仅用于检查内存消耗而不使脚本崩溃)

node --max_old_space_size=4096 memory.js 1e7

更新:我在内存片段上犯了一个错误,导致内存使用量增加了30%。我为每个 .write 创建了一个新的回调函数,但 Node 会重用 nop 回调函数。


更新 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));
    }
}

2
这太棒了,解决了很多问题。感谢您提供的详细信息。现在我明白了 highWaterMark 的工作原理,但是为什么在 1e6 次迭代中写入 1000 字节的数据不会导致崩溃呢? - schu34
3
因为您只写入了总共10MB的数据,所以它将只占用10MB的内存。在您的情况下,内存不足并非由于要写入的数据,而是由于节点存储块的方式引起的。如果您写入1字节的数据,那么缓冲区中的那个块将占用大约220字节的空间。 - Marcos Casagrande
2
@schu34,我在回调函数方面犯了一个错误,因为它不会在每个.write中创建一个新的回调函数,而是重复使用了一次声明的nop。我已经更新了我的答案以反映这一点。 - Marcos Casagrande
1
@schu34更新了脚本,使其更快。以前每个写操作都在一个Promise中,导致每个写操作在事件循环的不同刻度中执行。现在只有在需要等待时才会执行等待操作。 - Marcos Casagrande

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接