如何在node.js中使用阻塞写入stdout?

11
我正在编写一个 node.js 应用程序,将 stdout 导向文件。我使用 console.log 写入所有内容。一段时间后,我的应用程序达到了 1GB 的限制并停止工作。有趣的是,如果我使用 console.error 而不是 console.log,则内存使用率保持较低,程序运行正常。因此,看起来 node.js 无法刷新 stdout 流,导致所有内容都被保存在内存中。我希望保留 stderr 来处理错误。
我的问题是:
有没有一种方法可以阻塞写入 stdout?或者至少,我可以使用回调函数写入 stdout,以便确保我不写入太多内容?
谢谢!
5个回答

22

如果你真的非常需要同步写入到标准输出,你可以这样做:

var fs = require('fs');
fs.writeSync(1, "Foo\n");
fs.fsyncSync(1);

这是一个很棒的解决方案!但是,fs.fsyncSync(1) 是用来做什么的? - user698601
3
请确保所有内容都写到文件描述符一(fd1)中。其中一代表标准输出(stdout),二代表标准错误(stderr),三代表标准输入(stdin)。 - thejh
似乎这种方法在将输出管道传输到stdout / stderr时无法工作 https://stackoverflow.com/questions/50574243/receiving-bad-file-descriptor-error-when-piping-program-output-to-stdout-and-usi. - Gajus
3
标准输入(stdin)的文件描述符是零,而不是三。 - Lucas Werkmeister

8
使用process.stdout.write进行编写,返回值是数据是否被缓冲。如果为true,则在process.stdout发出drain事件时继续编写。
如果您希望代码看起来同步,请按照此处描述使用streamlinejs:Node.js stdout flush

1
问题(内存使用爆炸)可能是因为您的程序创建输出的速度比它能够显示的速度快。因此,您需要进行限制。您的问题要求“同步输出”,但实际上可以通过使用纯“异步”(*)代码来解决问题。
(*注意:在本帖中,“异步”一词是以“javascript-single-thread”的意义使用的。这与传统的“多线程”意义不同,是完全不同的事情)。
本答案展示了如何使用Promise来使用“异步”代码,通过“暂停”(而不是阻塞)执行,直到写出的输出已成功刷新,从而防止内存使用爆炸。本答案还解释了与同步代码解决方案相比,异步代码解决方案的优势。
问:“暂停”听起来像“阻塞”,异步代码怎么可能会“阻塞”?这是个自相矛盾的说法!
答:这是因为javascript v8引擎暂停(阻塞)仅等待异步承诺完成的单个代码片段的执行,同时允许其他代码片段在此期间执行。

这是一个异步写入函数(改编自此处)。

async function streamWriteAsync(
  stream,
  chunk,
  encoding='utf8') {
  return await new Promise((resolve, reject) => {
    const errListener = (err) => {
      stream.removeListener('error', errListener);
      reject(err);
    };
    stream.addListener('error', errListener);
    const callback = () => {
      stream.removeListener('error', errListener);
      resolve(undefined);
    };
    stream.write(chunk, encoding, callback);
  });
}

它可以从您源代码中的异步函数调用,例如:
案例1
async function main() {
  while (true)
    await streamWriteAsync(process.stdout, 'hello world\n')
}
main();

在顶层只调用 main() 函数。与调用 console.log('hello world'); 相比,内存使用率不会大幅上升。

需要更多上下文才能清楚地看到与真正同步写入相比的优势:
情况2

async function logger() {
  while (true)
    await streamWriteAsync(process.stdout, 'hello world\n')
}
const snooze = ms => new Promise(resolve => setTimeout(resolve, ms));
function allowOtherThreadsToRun(){
  return Promise(resolve => setTimeout(resolve, 0));
}
async function essentialWorker(){
  let a=0,b=1;
  while (true) {
    let tmp=a; a=b; b=tmp;
    allowOtherThreadsToRun();
  }
}
async function main(){
  Promise.all([logger(), essentialWorker()])  
}
main();

运行上述代码(case 2)会发现内存使用仍未爆炸(与case 1相同),因为与logger相关的切片已被暂停,但CPU使用率仍在,因为essentialWorker切片未被暂停 - 这是好的(想想COVID)。

相比之下,同步解决方案也会阻塞essentialWorker

多个切片调用streamWrite会发生什么?
case 3

async function loggerHi() {
  while (true)
    await streamWriteAsync(process.stdout, 'hello world\n')
}
async function loggerBye() {
  while (true)
    await streamWriteAsync(process.stdout, 'goodbye world\n')
}
function allowOtherThreadsToRun(){
  return Promise(resolve => setTimeout(resolve, 0));
}
async function essentialWorker(){
  let a=0,b=1;
  while (true) {
    let tmp=a; a=b; b=tmp;
    allowOtherThreadsToRun();
  }
}
async function main(){
  Promise.all([loggerHi(), loggerBye(), essentialWorker()])  
}
main();

在这种情况下(第三种情况),内存使用已经到达极限,essentialWorker 的 CPU 使用率很高,与第二种情况相同。 hello worldgoodbye world 的单独行仍然是原子的,但是这些行不会干净地交替出现,例如:
...
hello world 
hello world 
goodbye world 
hello world 
hello world 
...

可能会出现。


1

编辑:正如评论者所指出的,这种解决方案存在问题。printResolver可以被制成数组,但使用顶部解决方案更容易。

一个同步打印函数,也可以与管道(FIFO)一起使用,使用Async/await。确保您总是使用"await print"来调用"print"。

let printResolver;
process.stdout.on('drain', function () {
    if (printResolver) printResolver();
});

async function print(str) {
    var done = process.stdout.write(str);
    if (!done) {
        await new Promise(function (resolve) {
            printResolver = resolve;
        });
    }
}

1
如果在“drain”发生之前两次调用“print”,全局的“printResolve”将被覆盖,而先前的Promise将无法得到解决。 - Craig Hicks

0

不要这样做。

你想要做的是在输出满了时暂停输入,就像pump()方法一样,然后在有空间可写时resume()它。如果不这样做,你的进程会膨胀到巨大的大小。

您可能想要使用更直接的outputStream来完成这个操作,或者使用write()调用,而不是console.log()


虽然这里的特定性能问题并不是使用阻塞写入控制台的主要原因,但确实存在一些情况下你确实需要使用它。例如,如果你正在使用 async_hooks 监视器,你不能在异步监听回调函数中使用异步方法(因为写操作本身将触发监听器并无限递归)。 - Chris Williamson
暂停和恢复影响读取,而不是写入。 - Samuel Neff
正确,但这就是驱动循环读写的原因。 - aredridel

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