我有一个简单的 Node 程序,可以从命令行使用它来测试外部 API。该工具的工作原理大致如下:它根据命令行参数和标志进行 API 调用,对返回的内容 (JSON) 进行少量处理,然后将结果写入标准输出。输出通过以下小实用程序:
const output = (function() {
let accum = "";
return {
add(content) {
accum += content;
},
lpad(indent) {
this.add(indent ? new Array(indent + 1).join(" ") : "");
},
nl() {
this.add("\n");
},
write() {
writeFileSync(1, accum);
},
reset() {
accum = "";
}
};
})();
它将对
.add()
和 .lpad()
等的调用累积到一个字符串中,然后程序执行的最后一件事是调用 .write()
,它使用 writeFileSync()
将该字符串写入标准输出。后一个函数已导入:
import { writeFileSync } from "fs";
当标准输出未重定向(即终端窗口)时,一切正常。当我重定向到文件时它也可以正常工作。但是,如果我运行程序并将输出通过管道传输到
grep
或其他,我会得到:
node:fs:939
handleErrorFromBinding(ctx);
^
Error: EAGAIN: resource temporarily unavailable, write
at Object.writeSync (node:fs:939:3)
at writeFileSync (node:fs:2301:26)
at Object.write (file:///Users/m5/nodestuff/node_modules/nscall/src/output.mjs:20:13)
at commit (file:///Users/m5/nodestuff/node_modules/nscall/src/output.mjs:109:12)
at file:///Users/m5/nodestuff/node_modules/nscall/nscall.js:49:9
at process.processTicksAndRejections (node:internal/process/task_queues:95:5) {
errno: -35,
syscall: 'write',
code: 'EAGAIN'
}
Node.js v20.1.0
在某些输出通过管道后会发生该错误。
积累的内容可能有点大,但不会有很多兆字节大。直观上,就像操作系统管道缓冲区停止时事情会中断一样,但令我困惑的是
writeFileSync()
会遇到问题。这种情况发生在 MacOS 上,这是一个我不太熟悉的平台,但多年来我在 Linux 和 UNIX 上编写了无数类似的工具,低级文件系统 write()
调用处理输出类型的性质- 透明的,除非在特殊情况下(我猜;管道几乎不是什么特殊情况)。
我真的不是 Node 专家,所以这可能属于关于为什么坚持使用异步 API 是一个更好的主意的示例类别。不过看起来还是很奇怪。
edit — 如果我更改输出
write
方法以使用回调 write()
API,我不会收到错误:
async write() {
const buf = Buffer.from(accum, "UTF-8");
return new Promise(function(res, rej) {
write(1, buf, function() {
res();
});
});
},
我可以就这样保留它,但还是很奇怪。
我不知道这是否正确,但这是一个理论:
当使用 node 写入标准输出时,它将文件描述符置于
O_NONBLOCK
模式。请参阅此处:
主要问题:为什么非阻塞模式下文件描述符为0?
抱歉,如果我不清楚:节点按顺序将其置于非阻塞模式
或process.stdin.pipe(...)
有效。 更高级别:为了例如REPL 可以与事件集成 循环。process.stdin.on('data', ...)
上面的 PR 特别讨论了
stdin
,但它也应该适用于 stdout
。非阻塞 I/O 引发了 EAGAIN 的担忧。接下来,这个问题:Can write() to a non-blocking fd return EAGAIN when select reports it as writable?包含这个答案(已投票但不接受):
您在这里使用管道。管道有一个有趣的原子写入属性—— 保证写入小于 PIPE_BUF(此处为 4096)字节 原子。因此,对管道的写入可能会因 EAGAIN 失败,即使它是 可以向管道写入较少数量的字节。
我不确定这是否是你在这里遇到的情况(我没有看过 太接近了),但这种行为记录在 man 7 管道中。
现在,对于一些猜想:我不知道有什么会强制
grep
在非阻塞模式下运行。如果其输入缓冲区设置为 64k 并且正在执行阻塞读取,则您可能会尝试写入字节 65530-65580,而 grep 已读取 64k 并且现在正在处理数据并且不调用 read()
,从而导致延迟,从而引发EAGAIN 因为节点的标准输出不允许阻塞。
在我的测试中
grep
似乎读取了96k块,所以事实并非如此:
$ od -x /dev/urandom | strace grep "abcdef"
[...]
read(0, "1cf4 920f 31aa 5c95\n67602500 ad3"..., 98304) = 4096
read(0, "3104 5390 7a92 ef03 ec5d 5910 be"..., 94208) = 4096
read(0, " be6d 9fb2\n67607660 0604 d9a4 1b"..., 90112) = 4096
[...]
read(0, "7671360 b851 135f 79d9 4213 8275"..., 12288) = 4096
read(0, "edd 6b65 d774 369d\n67674060 cbd0"..., 8192) = 4096
read(0, "893 1ab4 57ad 1fa5 8ae8 6727 825"..., 4096) = 4096
read(0, "648b 61c9\n67701240 8d70 7210 652"..., 98304) = 4096
我找不到在非阻塞模式下运行的程序,但是如果你能找到一个(或写一个),这就是我发现
socat
使用完全阻塞的pselect()
:
$ dd if=/dev/urandom bs=4k count=100 | strace socat - - | sleep 10
返回到
grep
的管道:stdio 进程缓冲区大小也可能存在转换。根据在C中,stdout缓冲区的大小是多少?:
Linux 创建管道时使用默认管道大小 64K。在 /proc/sys/fs/pipe-max-size 存在最大管道大小。为了 默认 1048576 是典型值。
这符合您的经验。因为您已经建立了一个缓冲区,所以节点可能会如此快地将数据塞入管道中,以至于缓冲区在 grep 甚至有时间唤醒之前就被填满了。您应该能够通过跟踪节点应用程序并通过管道进入睡眠状态来确认 EAGAIN。如果这给出了相同的行为,我们至少可以得到部分解释。