【问题标题】:Async write file million times cause out of memory异步写入文件百万次导致内存不足
【发布时间】:2018-04-21 15:05:29
【问题描述】:

下面是代码:

var fs = require('fs')

for(let i=0;i<6551200;i++){
    fs.appendFile('file',i,function(err){

    })
}

当我运行这段代码时,几秒钟后,它显示:

FATAL ERROR: CALL_AND_RETRY_LAST Allocation failed - JavaScript heap out of memory

但文件中没有任何内容!

我的问题是:

  1. 为什么文件中没有字节?
  2. 内存不足的原因在哪里?
  3. 无论写入次数有多大,如何在 for 循环中异步写入文件?

谢谢提前。

【问题讨论】:

  • 如果您以 10 个为一组依次处理会发生什么?例如,您将 i = 0...10 写入文件,一旦完成,您将执行接下来的十个等。您可以使用类似 async.times
  • 如果将循环体包裹在 iife 中会怎样? for(...) { (function(i) { ... })(i) } 每个 iife 退出时,应该收集它的内存使用量
  • @tsuz 谢谢,我试过 async.times。但它不起作用并崩溃。
  • @JoeFrambach 谢谢,但是当我运行你的代码时,它也会导致内存不足的问题。我认为即使您将 fs.apendFile 函数包装在 for 循环中,但它不会等待回调完成,以便它会调用内存直到它耗尽
  • @suoyong 那是因为您正在循环中运行下一个 async.times 而无需等待前一个完成。重点是控制流程,一次处理部分。

标签: node.js asynchronous


【解决方案1】:

这里的底线是fs.appendFile() 是一个异步调用,您根本不会“等待”该调用在每次循环迭代中完成。这会产生许多后果,包括但不限于:

  • 回调在解决之前一直被分配,这会导致最终到达“堆内存不足”

    李>
  • 您正在与文件句柄竞争,因为您正在使用的功能实际上是打开/写入/关闭给定的文件,如果您不等待每个回合都这样做,那么您就是只会发生冲突。

所以这里的简单解决方案是“等待”,一些现代语法糖让这变得简单:

const fs = require('mz/fs');

const x = 6551200;

(async function() {
  try {
    const fd = await fs.open('file','w');
    for (let i = 0; i < x; i++) {
      await fs.write(fd, `${i}\n`);
    }
    await fs.close(fd);
  } catch(e) {
    console.error(e)
  } finally {
    process.exit();
  }
})()

这当然需要一段时间,但它不会在它工作时“炸毁”你的系统。

第一个简化的事情是获取mz 库,它已经将常见的nodejs 库与支持promise 的每个函数的现代化版本包装在一起。与使用回调相比,这将有助于清理语法。

接下来要意识到的是fs.appendFile() 在一次通话中如何“打开/写入/关闭”。这不是很好,所以你通常会做的只是open 然后write 循环中的字节,完成后你实际上可以close 文件句柄。

“糖”出现在现代版本中,尽管 “可能” 带有简单的承诺链,但它仍然不是那么易于管理。因此,如果您实际上没有支持 async/await 糖或“转换”此类代码的工具的 nodejs 环境,那么您也可以考虑使用带有普通回调的 asyncjs 库:

const Async = require('async');
const fs = require('fs');

const x = 6551200;

let i = 0;
fs.open('file','w',(err,fd) => {
  if (err) throw err;

  Async.whilst(
    () => i < x,
    callback => fs.write(fd,`${i}\n`,err => {
      i++;
      callback(err)
    }),
    err => {
      if (err) throw err;
      fs.closeSync(fd);
      process.exit();
    }
  );

});

同样的基本原则适用于我们在继续之前“等待”每个回调完成。这里的whilst() helper 允许迭代直到满足测试条件,当然在数据传递给迭代器本身的回调之前不会进行下一次迭代。

还有其他方法可以解决这个问题,但对于“大循环”的迭代来说,这两种方法可能是最明智的。通过.reduce()“链接”等常见方法确实更适合您已经拥有的“合理”大小的数据数组,而在这里构建这种大小的数组本身就有问题。

例如,以下“工作”(至少在我的机器上)但它确实会消耗大量资源:

const fs = require('mz/fs');
const x = 6551200;

fs.open('file','w')
  .then( fd =>
    [ ...Array(x)].reduce(
      (p,e,i) => p.then( () => fs.write(fd,`${i}\n`) )
      , Promise.resolve()
    )
    .then(() => fs.close(fd))
  )
  .catch(e => console.error(e) )
  .then(() => process.exit());

因此,本质上在内存中构建如此大的链然后让它解决实际上并不实际。您可以在此添加一些“治理”,但所示的主要两种方法要简单得多。

对于这种情况,您要么拥有可用的 async/await 糖,因为它在当前 LTS 版本的 Node (LTS 8.x) 中,或者我会坚持使用其他经过验证的真正“异步助手”来进行回调仅限于没有该支持的版本


您当然可以“开箱即用”最近几个版本的 nodejs 来“承诺”任何功能,因为Promise 在一段时间内已经成为一个全球性的东西:

const fs = require('fs');

await new Promise((resolve, reject) => fs.open('file','w',(err,fd) => {
  if (err) reject(err);
  resolve(fd);
});

所以确实没有必要仅仅为了做到这一点而导入库,但是这里作为示例给出的 mz 库可以为您完成所有这些工作。因此,是否引入其他依赖项完全取决于个人喜好。

【讨论】:

  • 感谢 Neil Lunn。这解决了我的问题,宝贵的提示。
【解决方案2】:

Javascript 是一种单线程语言,这意味着您的代码可以同时执行一个函数。所以当你执行一个异步函数时,它会在栈中“排队”等待下一次执行。

所以在您的代码中,您正在向堆栈发送 6551200 次调用,这当然会使您的应用在开始对其中任何一个“appendFile”工作之前崩溃。

您可以通过将循环拆分为更小的循环、使用 async 和 await 函数或迭代器来实现您想要的。

如果您要实现的目标与您的代码一样简单,则可以使用以下代码:

const fs = require("fs");

function SomeTask(i=0){
    fs.appendFile('file',i,function(err){
        //err in the write function
        if(err) console.log("Error", err);
        //check if you want to continue (loop)
        if(i<6551200) return SomeTask(i);
        //on finish
        console.log("done");
    });
}
SomeTask();

在上面的代码中,您编写了一行代码,完成后,您调用下一行代码。 这个函数只是基本的使用,它需要重构和使用Javascript迭代器来进行高级使用check out Iterators and generators on MDN web docs

【讨论】:

  • 感谢建设性的迭代器回答。如果我迭代百万次内存也会耗尽,不是吗?
【解决方案3】:

1 - 文件为空,因为 fs.append 调用都没有完成,Node.JS 进程之前中断。

2 - Node.JS 堆内存是有限的,它会存储回调直到它返回,而不仅仅是“i”变量。

3 - 你可以尝试使用 Promise 来做到这一点。

"use strict";

const Bluebird = require('bluebird');
const fs = Bluebird.promisifyAll(require('fs'));

let promisses = [];
for (let i = 0; i < 6551200; i++){
    promisses.push(fs.appendFileAsync('file', i + '\n'));
}

Bluebird.all(promisses)
.then(data => {
  console.log(data, 'End.');
})
.catch(e => console.error(e));

但是对于这么大的循环,没有任何逻辑可以避免堆内存错误。你可以增加 Node.JS Heep Memory,或者,合理的方式,间隔获取大块数据:

'use strict';

const fs = require('fs');

let total = 6551200;

let interval = setInterval(() => {
  fs.appendFile('file', total + '\n', () => {});
  total--;
  if (total < 1) {
    clearInterval(interval);
  }
}, 1);

【讨论】:

  • 这只是创建了一个包含 650 万个元素的数组,Bluebird.all 不保证它按顺序运行(可能并行运行)。第二个假设fs.appendFile在1ms内完成写入。
  • 感谢回复,bluebird 是很棒的模块,但它不能循环数百万次。setInterval 是一个很好的解决方法。但是如何保证任务在 setInterval 回调中完成,例如fs.apendFile、socket.send...再次感谢。
  • 这是一个简单的例子,你可以使用递归方法,比如 function x() { fs.appendAsync().then(() => x()).catch(e => console .log(e));我在几个解析文件的微服务中使用这种间隔方法,通常间隔大于 1 毫秒,但它已经完成了工作。
猜你喜欢
  • 1970-01-01
  • 2011-01-21
  • 1970-01-01
  • 2012-12-15
  • 2018-10-25
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
相关资源
最近更新 更多