在Node.js中实现对标准输出流的缓冲写入

5
在node.js中,文档记录了process.stdout流的行为是“同步”的,这意味着每次调用stdout.write都会立即触发一个write系统调用 -- 没有缓冲。例如:
import { stdout } from 'process';

for (let i = 1; i <= 1000; i++) {
    stdout.write(`line ${i}\n`);
}
stdout.end();

在编写传统的Unix数据发射工具时,可能需要进行1000次write系统调用。这并不是您想要的。可以绕过process.stdout,创建一个独立的可写流,将其指向文件描述符1。

import { stdout } from 'process';
import { createWriteStream } from 'fs';
let ostream = createWriteStream("/ignored", { fd: stdout.fd });
for (let i = 1; i <= 1000; i++) {
    ostream.write(`line ${i}\n`);
}
ostream.end();

仅进行一次系统调用,但是这样的绕过方式是危险的——在ostream.end调用之后,文件描述符1被关闭,但是process.stdout对象并不知道。

在使用process.stdout时,是否有官方方法可以获得缓冲输出?在我看来,理想情况下应该像C语言的setvbuf函数一样操作。


1
这个npm包write-buffer是否符合您的需求? - Ricky Mo
我也看到了write-buffer,认为它可能是解决方案的一部分,但似乎并不完全符合要求。 - kevintechie
3个回答

2

使用process.stdout.cork()

writable.cork()方法强制将所有写入的数据缓存在内存中。当调用流的uncork()end()方法时,缓冲数据将被刷新。

注意:这也会影响console.log()

process.stdout.cork();
for (let i = 1; i <= 1000; i++) {
    process.stdout.write(`line ${i}\n`);
}
process.stdout.uncork();

或者,如果您想在每次事件循环到达时刷新缓冲区,您可以重写write()

const { stdout } = process;
const { write } = stdout;

stdout.write = function() {
  if (this.writableCorked == 0) {
    this.cork();
    process.nextTick(() => this.uncork());
  }
  write.apply(this, arguments);
}

for (let i = 1; i <= 1000; i++) {
  process.stdout.write(`line ${i}\n`);
}

如果您担心缓冲区过大,您可能还需要检查stdout.writableNeedDrain

strace -f -c -e trace=write,writev的结果:

% time     seconds  usecs/call     calls    errors syscall
------ ----------- ----------- --------- --------- ----------------
100.00    0.000006           1         4           write
  0.00    0.000000           0         1           writev
------ ----------- ----------- --------- --------- ----------------
100.00    0.000006                     5           total

本文使用 node v16.13.2 进行测试。


这会让我从“不缓冲”变成“缓冲整个输出并一次性写入所有内容”,这在另一个方向上太过极端,特别是当输出的大小是不可预测的时候。 - zwol
是的,它确实可以做到,但并不理想。你询问了使用 process.stdout 时获取缓冲输出的官方方法,我已经回答了。 - niry
@zwol - 我更新了答案,给你提供了另一个选项。希望这有所帮助。 - niry

0

你也可以在stdout上调用.end()。我不确定这是否会使其更安全。

import { promisify } from 'util';
import stream from 'stream';
import { stdout } from 'process';
import { createWriteStream } from 'fs';

const finished = promisify(stream.finished);

let ostream = createWriteStream("/ignored", { fd: stdout.fd });
for (let i = 1; i <= 1000; i++) {
    ostream.write(`line ${i}\n`);
}
ostream.end();

await finished(ostream).then(()=> {
  console.log('Not quite done.');
  stdout.end()
  console.log('Not gonna see this.');
});

编辑:我刚在Node文档中读到,如果在POSIX系统上使用管道将数据发送到stdout,则它是异步的。因此,以下代码应该可以工作:

import { stdout } from 'process';
import { Readable } from 'stream';
const data = new Readable()

data._read = () => {};

data.pipe(stdout);

for (let i = 1; i <= 1000; i++) {
  data.push(`line ${i}\n`);
}

关于您的编辑,我没有看到文档中说“如果您使用管道将数据发送到stdout,则它是异步的”,而且快速测试表明您的第二个程序执行了与我的第一个程序相同的1000个写入调用。虽然我不知道您可以在Readable上使用push,但这仍然很有帮助,谢谢。 - zwol
第二个要点说,当使用管道和套接字时,stdout写入是异步的。我不确定它是否会减少对系统的调用,但因为它是异步的(根据文档),所以似乎与使用可写流到stdout文件描述符相同。这都是在我通常处理的更低级别上,所以我不熟悉如何测试以确保。非常有趣的问题。 - kevintechie
关于如何进行测试,例如 strace -f -c -e trace=write,writev node test.mjs,其中 test.mjs 包含我们一直在讨论的任何测试程序。 - zwol

0

经过更深入的思考,安全高效地设置使用process.stdout进行大量数据输出的理想方式是dup操作系统级别的STDOUT_FILENO,在副本周围包装一个普通的fs.WriteStream,然后dup2STDERR_FILENO覆盖STDOUT_FILENO,这将使所有全局控制台对象的方法和process.stdout写入stderr而不是stdout。(据我所知,没有其他方法可以做到这一点。)生成大量数据的代码将写入普通的WriteStream而不是process.stdout。

或者,在代码中:

import * as fs from "fs/promises";
import process from "process";
async function prepare_buffered_stdout(options) {
    options ??= {};
    const stdout_copy = await fs.dup(process.stdout.fd);
    await fs.dup2(process.stderr.fd, process.stdout.fd);
    return fs.fdopen(stdout_copy, "w").createWriteStream(options);
}

很不幸,fs.dupfs.dup2fs.fdopen函数不存在。我已经提交了一个功能请求,希望能够加入它们。

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