me

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 流的分类

缓冲

提供缓冲(Buffer)是为了提高内存和 I/O 设备之间的交换速度设计的,例如每次需要对硬盘进行 100 次读写,而有缓冲区之后可能就是每秒 10 次;缓存是 CPU 和内存之间的数据交换,弥补内存的频率过低问题

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 中的 NewWriterNewReader 来缓冲 io.Writerio.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 中的 ReadableStreamWriteableStream 也是同样的,它们本身就是可缓冲的。

I/O 库的设计

Deno 2.0 中将 1.0 命名空间中 ReaderWriter 等接口迁移到了标准库中,但是诸如 Seeker 等接口还是保留在了命名空间中,但是实际上 FsFile 这类方法已经确实实现了 ReaderWriter 等接口,这种迁移之后有巨大的割裂感,参考: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 更加简单一点。