为啥尝试写入大文件会导致js堆内存不足

Posted

技术标签:

【中文标题】为啥尝试写入大文件会导致js堆内存不足【英文标题】:why does attempting to write a large file cause js heap to run out of memory为什么尝试写入大文件会导致js堆内存不足 【发布时间】:2018-10-25 17:37:51 【问题描述】:

这段代码

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 的文件。据我了解,流的意义在于两个版本应该在大约相同的时间内运行,因为数据是相同的。即使每次迭代将as 的数量增加到 100 或 1000 也几乎不会增加运行时间,并且写入 1GB 文件也没有任何问题。在 1e6 次迭代中每次迭代写入一个字符也可以正常工作。

这是怎么回事?

【问题讨论】:

疯狂猜测。可能是一次写入一个字符会导致更多的内存分配来调整流缓冲区的大小,但运行 1e7 循环永远不会让 GC 有机会运行或写入有机会得到处理。 我不知道为什么1e7 将其置于边缘,但您应该能够通过使用drain 事件来避免OOM 以尊重背压。 file.write(...) 返回 false 如果您需要在下一次写入之前等待。这里的文档中有一个示例:nodejs.org/api/… 或者你可以从一个可读流到写流,它会为你处理背压 它们是相同的,只是一个使用更多的内存会导致错误。流的重点是允许缓冲 I/O,您可以在缓冲块中读入/读出以避免此类内存溢出问题。 【参考方案1】:

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

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

这可以很容易地使用:tail -f test.dat

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

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

1e7 / 16384 = 610

一种解决方案是检查.write 返回值,如果返回false,则使用包裹在承诺中的file.once('drain') 等待drain 事件发出

注意: writable.writableHighWaterMark 已添加到节点 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 事件时,您在内存中有 1000 万个这些对象(公平地说,它在达到 10M 之前就崩溃了)

无论您使用单个a 还是1000,都可以忽略不计的内存增加。


你可以用--max_old_space_size=MB标志增加节点使用的最大内存(当然这不是解决方案,只是为了检查内存消耗而不会使脚本崩溃)

node --max_old_space_size=4096 memory.js 1e7

更新:我在内存 sn-p 上犯了一个错误,导致内存使用量增加了 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));
    

【讨论】:

这太棒了,清理了很多。感谢您提供详细信息。现在我了解了 highWaterMark 的工作原理,但为什么 在 1e6 次迭代中写入 1000 字节的数据会导致崩溃? 因为你总共只写了 10mb 的数据,所以只占用 10mb 的内存。在您的情况下,内存耗尽不是由正在写入的数据引起的,而是由节点存储块的方式引起的。如果您正在写入 1 个字节的数据,则缓冲区中的该块将占用大约 220 个字节。 @schu34 我在回调方面犯了一个错误,因为它不是在每个 .write 都创建一个新的回调,它会重复声明一次的 nop。我更新了我的答案以反映这一点。 @schu34 更新了脚本,所以它会更快,以前每次写入都在一个 Promise 中,使得每次写入都在事件循环的不同滴答声中执行。现在它只会在需要时等待。

以上是关于为啥尝试写入大文件会导致js堆内存不足的主要内容,如果未能解决你的问题,请参考以下文章

在 node.js 中使用 createWriteStream 创建大文件时 JavaScript 堆内存不足致命错误:达到堆限制分配失败

内存不足错误发生在堆大小高但分配大小低的情况下。为啥?

致命错误:接近堆限制的无效标记压缩分配失败 - 使用 fs 处理大文件时 JavaScript 堆内存不足

构建 Vue.js 应用程序时 JavaScript 堆内存不足

在 mongodb 和节点 js 中堆内存不足

Eclipse 堆空间(内存不足错误)