I/O Stream
为什么要写这篇文章?
原因是在为 grammy 提 PR 的时候,Deno 1.42.0 在使用 using
去释放文件描述符时有 bug(1.42.4 修复了):
async function getStream() {
using fd = await Deno.open("README.md")
return fd.readable
}
文件描述符被释放了,这时候返回是不可能有资源的,返回的 stream 也是没有写到缓冲区的,所以这段代码本身就是有问题的,然而直接返回 stream,并没有关闭文件描述符会造成资源泄漏(🤡)。
当然在 Deno 中完全可以使用 const
然后返回一个异步迭代器的方式,异步迭代器在 done: true
的时候会主动释放文件描述符,具体参考:https://github.com/denoland/deno/issues/23481。
I/O 流的分类
- 按照类型分:分为文件 I/O和网络 I/O
- 按照缓冲分:分为带缓冲和不带缓冲,其中分为全缓冲(fully buffering)、行缓冲(line buffering)以及无缓冲(non Buffering)
- ......
缓冲
提供缓冲(Buffer)是为了提高内存和 I/O 设备之间的交换速度设计的,例如每次需要对硬盘进行 100 次读写,而有缓冲区之后可能就是每秒 10 次;缓存是 CPU 和内存之间的数据交换,弥补内存的频率过低问题
- 全缓冲:当数据填满整个缓冲区之后才进行实际的 I/O 操作(驻留在磁盘上的文件的读写通常是使用全缓冲),当缓冲区满了,或者手动刷新时,才会将缓冲区的数据写出。
- 行缓冲:每当输入输出遇到换行或者缓冲区满了的情况下才会进行实际的 I/O 操作(当涉及到终端输入输出的时候通常使用行缓冲),读取到一个换行符时,才会将缓冲区中的数据写出。
- 无缓冲:要求 I/O 立即进行,如标准错误流,若果出现错误,会立马输出
Rust 中使用 I/O 缓冲和不使用缓冲的示例:https://nnethercote.github.io/perf-book/io.html#buffering。Rust 中缓冲区的默认大小是 0x2000
fn main() -> Result<(), Box<dyn Error>> {
let mut writer = BufWriter::new(File::create("README.md")?);
let content = b"# Hello, world!";
for c in content.iter() {
write!(writer, "{}", *c as char)?;
}
Ok(())
}
Go 中使用 bufio
中的 NewWriter
和 NewReader
来缓冲 io.Writer
和 io.Reader
,它的默认大小是 4096 个字节
func main() {
writer := bufio.NewWriter(os.Stdout)
fd, err := os.Open("go.mod")
defer fd.Close()
if err != nil {
fmt.Printf("打开文件失败")
}
writer.ReadFrom(fd)
writer.Flush()
}
Go 中的 I/O 流甚至更简单,只要实现 Writer interface 就是可读流,实现 Reader interface 就是可写流。
bytes
库提供了一个既可读也可写的 Buffer
类型
func main() {
producer := []string{
"Channels orchestrate mutexes serialize",
"Cgo is not go",
"Errors are values",
"Don't panic",
}
var buffer bytes.Buffer
for _, p := range producer {
n, err := buffer.Write([]byte(p))
if err != nil {
fmt.Println(err)
os.Exit(1)
}
if n != len(p) {
fmt.Println("failure")
os.Exit(1)
}
}
fs, err := os.Create("data.txt")
if err != nil {
fmt.Println(err)
os.Exit(1)
}
buffer.WriteTo(fs)
}
C 中提供了低级 I/O、标准 I/O(fopen、fwrite 等带有 I/O)以及高级 I/O。
int main(){
for (int i = 0; i < 10000; i++)
fputs("hello world", stdout);
return 0;
}
我们可以使用以下命令来查看缓冲之后会执行多少次 write(Linux),如果没有缓冲,将是执行 10000 次。
strace ./main 2>err
grep "write(" err | wc -l
在 C 中很容易就可以写一个带缓冲的 I/O 流
#include <stdio.h>
int main() {
// 文件指针
FILE *fp;
// 用于存储读取的字符
char ch;
// 打开文件用于读取
fp = fopen("main.go", "r");
if (fp == NULL) {
perror("Error opening file");
return 1;
}
// 读取文件内容并打印
while ((ch = fgetc(fp)) != EOF) {
putchar(ch);
}
// 关闭文件
fclose(fp);
return 0;
}
关于 JavaScript 中的 ReadableStream
和 WriteableStream
也是同样的,它们本身就是可缓冲的。
I/O 库的设计
Deno 2.0 中将 1.0 命名空间中 Reader
、Writer
等接口迁移到了标准库中,但是诸如 Seeker
等接口还是保留在了命名空间中,但是实际上 FsFile
这类方法已经确实实现了 Reader
、Writer
等接口,这种迁移之后有巨大的割裂感,参考:https://github.com/denoland/deno/blob/5683ca40707ae98bba6b58c710b9ff31e9f41944/cli/tsc/dts/lib.deno.ns.d.ts#L2312-L2321。
但是 Deno 中设计 I/O 接口的整体思路和 Golang 十分相似,或者就是直接照搬过来,下面来学习一下:
// Reader
export interface Reader {
read(p: Uint8Array): Promise<number | null>;
}
// Writer
export interface Writer {
write(p: Uint8Array): Promise<number>;
}
// Seeker
export interface Seeker {
seek(offset: number | bigint, whence: SeekMode): Promise<number>;
}
// Closer
export interface Closer {
close(): void;
}
然后看一下 Go 中接口的定义:
type Reader interface {
Read(p []byte) (n int, err error)
}
type Writer interface {
Write(p []byte) (n int, err error)
}
type Closer interface {
Close() error
}
type Seeker interface {
Seek(offset int64, whence int) (int64, error)
}
Rust 中的 trait 相比较于 Go 和 Deno 更加复杂一点,虽然在实现 Reader
等 trait 的时候只要实现其中的 read
方法,但是 trait 中有一系列相关的方法都以提前实现好,只要实现了该 trait 之后,就可以实现这类方法。而 Deno 中更简洁,类似于 Go,这一系列方法都要自己实现:https://github.com/denoland/deno/blob/5683ca40707ae98bba6b58c710b9ff31e9f41944/ext/fs/30_fs.js#L659-L804。
相较于 Go 和 Rust,它们的文件句柄都稍微复杂,由于多线程可能产生数据竞争,它们往往都需要加锁,而 Deno 更加简单一点。