一些「流与管道」的小事

in 开发 with 1 comment

「流」这个概念在开发中非常常见,在 java 语言里我们熟知InputStreamOutputStream,node 中有WriteStreamReadStream,cpp 里也有 stream… 似乎这是编程语言里不可或缺的一部分。而初学者一般会照着文档完成我们的程序却对流本身并不是特别了解。如果你是科班出身的话,老师会和你说过,「流」顾名思义,就像水流一样,从这一端流向那一端。那么流能为我们提供什么呢?为什么它会存在呢?

举个简单的例子,如果我们需要顺序读取或者写入文件里的内容的话(非随机读写),我们一定会用到流,我们可以打开一个输出流,流以文件数据为源,开发者代码为输出端形成一个管道。我们在代码里那一端操作read就可以获取到文件内容。这是一个典型的从一个水桶中用管子把水抽到另外一个水桶中的过程。这个过程似乎没什么神奇的,我们依靠他完成了简单的读写操作。

Stream

如果这时候,我们拥有一个可以基于一个或者个字节就能压缩和解压的算法呢?我们可以先从文件流中读几个字节,然后经过算法处理后,把处理结果输出去,这样对于外部操作者来说,其实并无感知—开发者依旧是“拿着水桶接水”而已,不管这个“水”是“纯净水”还是别的玩意儿。那么,这种操作对于我们来说,带来了一种可能,这种可能就是流的变换 (Transform) 。好比在源头和我们接收端的当中加入了一些中间件,把我们的管子拆开,连了中间件的管子,不断的为流过的每个字节做转换。没错,我们刚提到的那个压缩算法想必大家都非常熟悉,就是gzip算法。

GZIP

流的变换

基于这种 Transform 的应用,我们在 node 中会经常看见一个叫pipe的方法,对于流的操作,我们可以用 pipe 方法串联起来,就像这样

fs.createReadStream(...)
.pipe(gzip())
.pipe(other())
.pipe(fs.createWriteStream(...))

这种基于流处理的声明式编程范式使得我们的业务流程非常的清晰,如果我们把中间过程比喻成为“水“染色的话,我们的 pipe 更像是一个提供“染色功能的管道”,为什么要强调这一点,我们可以想象到,对水进行染色的话,我们是不需要把所有的水先放在一个桶里,然后再在桶里加染色剂,再把染色好的水倒到另外的一条管子里。 也就是说,我们面向流的操作,很多情况下会比一般的方案更加省内存! 很显然我们有许多种办法办到,我们可以使用少量的内存(缓存),比如当我们需要把 4 个字节转成 1 个 Int ,要知道流的最小单元是字节,但是我们可以利用缓存(想想 Java 中用到的 BufferedInputStream/BufferedOutputStream 类),把流式处理改成块式(帧式)处理的方式,处理完后可以变成另外一种流。

管道

流的容器,其实就是管道。管道可以拥有 Buffer 也可以不拥有 Buffer,如果你需要处理一块的数据的时候,一般都会需要缓冲区。注意这里块的概念,一般都是有边界或者固定长度的一组字节。我们这里再做一个想象,把WriteStreamReadStream看做一条管道的两端,管道里面可能有缓冲区,管道最基础的数据类型是字节,一个管道的两端可以任意对接其他的管道,每个管道会对经过的数据做一定的处理,或是修改字节,或是类型变换。

Transformer

那么这么一整套的模型,就是我们的管道编程模型。管道不算是一个特别抽象的概念,他在操作系统中非常常见,最经典的就是我们的标准输入和标准输出。标准输入是一个写入端,管道的另一头连接着这个进程。我们在写入端写入一些数据,进程即可读到。这就可以让我们达到进程间通信(IPC)的目的。如果一个进程接收标准输入和执行标准输出,那么我们进行进程间通信的成本将非常的低廉——只要把目标进程的 Std IO 重定向到我们这边生成的管道即可。

OS 中的 Stream 和 I/O

我们已经了解到了管道是一个整体的概念,那么我们经常碰见的 OS 级别的流编程模型有哪些呢?其实非常常见,包括我们前文介绍的,那么主要有以下几种:

  1. 标准输入输出 (stdio)
  2. 文件
  3. 管道(匿名管道和命名管道)
  4. Socket (网络套接字)
  5. tty (终端)
  6. unix local socket

那么对于这些文件的读写,我们能听到的最常见的名词就是 I/O 了,在 unix 的世界里,所有的流都以文件描述符的方式进行描述(File Descriptor 缩写成 fd),我们在 C 语言的编程过程中,可能对于这个名词非常的熟悉了,我们似乎还听过一句话:“linux 把一切可读写的玩意儿都比作文件”,可能就是这个意思吧。

我们可以使用 linux 提供的open函数来获取fd,然后使用readwrite进行对fd的操作,一旦你把readwrite进行了多次封装,那么它就变成了stream transformer像我们之前介绍的那样,linux 提供的对I/O的系统调用,是我们概念的基石。

I/O 模型

实际上,如果要写出优雅范式的代码,我们可能对于I/O 模型还需要做一些了解,在三大操作系统中,各有自己最优的I/O 模型实现

OSI/O 模型
WindowsIOCP (完成端口)
Linuxepoll
macOS(Darwin)kqueue

他们之间有非常多的相似点,如果你为特定的操作系统提供 I/O 操作,那么你应该选择该系统下面最优的模型去做,这样才能让你的 I/O 效率(流读写效率)最大化,本文暂时不再赘述,有想了解的同学可以自行查阅相关资料。

欢迎关注微信公众号「TalkWithMobile」
TalkWithMobile