在三个层级上完整理解 IO
前言
这已经不知是第几次试图学习和理解 IO 了,但总是因为缺乏足够的实践而遗忘,希望这次记录以后,能够改善这种局面。
IO 基础知识
IO 在计算机中的意思是「输入和输出:Input 和 Output」,具体是指发生在主存和外部设备之间的数据传输也就是数据拷贝行为。
在 Unix 系统中,一切外部设备都是文件,包括磁盘,终端,网络等,都统一抽象为文件。
然后,Unix 为所有文件提供统一的读写接口,很自然,读写之前要打开,读写结束之后要关闭。
在 Unix IO 之上,各种编程语言都会提供在 Unix IO 基础上封装好的更为高级的 IO 接口。一般情况下,高级别 IO 就足够用了,但总还有需要 Unix IO 的时候:
- 需要理解 Unix IO 来学习理解文件和进程的概念
- 只有 Unix IO 才能获取文件元数据
IO 中的文件打开、关闭与读写操作
首先,再来说说文件。文件就是m 个字节序列,仅此而已。通过一个文件名,我们可以打开一个文件。这个工作由系统内核来完成,即使在高级别 IO 中,依然如此。在成功打开一个文件后,系统内核会返回一个代表文件描述符的整数,一个相应的保存文件其它信息的数据结构,一个代表文件位置的整数。
应用程序在读写时,只需要文件描述符和文件位置即可。
内核会维护一个描述符的池,池中是仍在打开状态的文件,在任何情况下,只要进程结束,进程中打开的文件都会被关闭。关闭就是将相应描述符移出描述符的池,并销毁内核中保存着相应文件信息的数据。
通过文件描述符和文件位置,可以通过指定文件位置和字节大小来进行读写操作。在进行读操作时,文件位置抵达文件字节之外,会触发一个称为 end-of-file(EOF)的条件,应用程序可以检测到这个条件,从而终止读操作。值得注意的是,EOF 只是一个条件,并没有这样一个字符。
相应地,写操作就是在指定文件位置处写入(拷贝进)指定数据之后,再更新文件位置,从而可以不断地写。
注意,文件描述符最好尽快关闭,且可以重复关闭(同一个进程下同一线程可以,多线程下会出问题),不会报错。
读和写文件
应用程序分别调用 read 和 write 函数来执行输入和输出的。
C 语言下的函数参数为文件描述符整数,位置指针(读操作为空指针),字节数,返回值为一个有符号整数,-1 时代表出错,这使得 read 操作一次最大从 4 G 缩减为 2 G。
通过调用 lseek 函数,应用程序可以显式修改当前文件的位置。
某些情况下,read 和 write 传送的字节比应用程序要求的要少。这些不足值不表示有错误。出现这种情况的原因如下:
- 读时遇到 EOF。文件剩余字节数小于应用程序要求的字节数时,会触发 EOF 条件。read 函数也恰恰就是通过返回 0 不足值来触发 EOF,完美。
- 从终端读取文本行。如果文件是与终端(键盘和显示器)相关联的,那么每个 read 函数将一次传送一个文本行,返回的不足值等于文本行的大小。
- 读写网络套接字(socket)。内部缓冲约束和较长的网络延迟会引发 read 和 write 返回不足值。
- 进程间通讯,即对 Unix 管道(pipe)调用 read 和 write 。
实际上,除了 EOF,在读磁盘文件时,将不会遇到不足值,而且在写磁盘文件时,也不会遇到不足值。
处理不足值最频繁的场景是 Web 服务器这样的网络应用,为了保证应用的健壮和可靠性,就必须反复调用 read 和 write 处理不足值,直到所需的字节都传送完毕。
RIO
健壮(robust) IO,分为无缓冲和有缓冲两种版本。
- 无缓冲 RIO,适合文件和存储器之间直接 IO,如网络套接字 socket 文件和存储器(比如磁盘,固态硬盘)之间。
- 带缓冲 RIO,提供应用程序级别的缓冲带,只有缓冲区内还有数据,就不必调用开销相对较高的内核 IO。
读取文件元数据
调用 stat 和 fstat 函数,可以查看文件具体信息,例如文件大小和类型。其中 stat 参数为文件名,fstat 函数参数为文件描述符。对内核而言,文件类型基本只有三类:
- 普通文件。磁盘上的二进制或文本文件,都是普通文件,因为对内核而言,它们没有任何区别。
- 目录文件。我们通常管目录文件叫文件夹,其实文件夹对内核而言,是一种特殊的文件类型。所以在 Python 有 os.listdir() 函数列出所有文件路径,又有 。os.path.isdir() 函数判断文件路径是否为「文件夹」类型的文件。
- 网络套接字文件。即 socket 文件,对用户不可见,但又可能是用户使用最频繁的文件类型。
共享文件
要想搞清楚内核是如何共享文件的,就要先理解内核中用来表示打开文件的三个数据结构:
- 描述符表。每个进程都有它独立的描述符表,表项由进程打开的文件描述符来索引。每个打开的描述符都会指向文件表中的一个表项。
- 文件表。打开文件的集合,由文件表来表示,所有进程之间共享这张文件表。表项组成为当前文件位置、引用计数(关联的描述符的个数)以及一个指向 v-node 表中对应表项的指针。
- v-node 表。同文件表一样,所有进程共享这张 v-node 表。每个表项包含 stat 结构中的大多数信息,包括文件类型和文件大小。
进程间共享文件的方式通过这三张表来看就比较容易了:每个进程都可以有自己独立的文件描述符,同时每个文件描述符可以指向各自的文件表,文件表又和 包含文件信息的 v-node 表关联,而文件表和 v-node 进程间共享,所以文件就可以在进程间共享。
再换言之,就是:虽然各个进程拥有独立的文件入口(描述符),但描述符指向的具体文件表和 v-node 表却可以在进程间共享,也就是文件的位置、类型和大小可以在进程间共享。进程有的,只是一个看上去独立存在的文件描述符。
IO 重定向
还是利用上述三个表,重定向时,只需要将文件描述符重定向即可。一旦文件描述符重定向,原来的文件表因为引用计数为零,也会被内核自动销毁。同时,内核通过文件描述符新的指向关系,可以将相应数据的读写行为也进行重定向。
标准 IO
标准 IO 指的是 C 语言定义的一组高级 IO 函数,称为标准 IO 库。
标准 IO 库将文件描述符和应用级缓冲区封装抽象为文件流。这样可以既可以满足用户一系列琐碎的 IO 操作的同时,降低调用内核 Unix IO 的次数,从而降低开销。
因为缓冲区的存在,也就有了 fflush 函数刷新缓冲区。
IO 函数的选择
绝大多数情况下,只需要使用标准 IO 或者相应程序语言提供的高级 IO 函数即可。
然而,例外总是存在。对网络文件的 IO 操作,就最好使用 RIO。
这是由于标准 IO 和 网络 socket 文件之间存在矛盾:
- 标准 IO 下,O 之后 的 I 操作之前,需要先调用 fflush 清除缓冲区。
- 同样的,I 之后 的 O 操作之前,需要先调用 fseek 定位文件位置。
- 但 socket 文件不支持 fseek 操作。这样使用标准 IO 先写后读 socket 文件时,只能通过生成两个文件描述符进行操作,但是标准 IO 函数生成的两个描述符实际上共享同一个「底层内核文件操作符」。生成两次,就要关闭两次,同一个线程下还没有问题,但多线程中关闭一个关闭的描述符,会有大问题(此处暂时不深究)
总结
再次鼓起勇气整理 IO 相关的内容,发现内容好多。。。
值得着重理解的关键概念:
- 系统内核级别的 Unix IO 和 RIO 以及 标准 IO 之间的区别
- Unix IO 是很直接的指定读写的字节数据,返回结果为失败,成功,并可能产生不足值。这里的读写是 主存 和 文件 之间最直接的读写。
- RIO 是将不足值的处理自动化,并提供了带缓冲版本,降低开销。
- 标准 IO 将文件描述符和缓冲区抽象为文件流,此时 IO 操作返回的文件描述符已经不再完全等价于内核中的文件描述符。
- 内核中对已打开文件的完整刻画所使用的三张表:描述符表,文件表,v-node 表,并以此三表初步理解文件共享和 IO 重定向在内核中的实现逻辑。
不知是该恭喜,还是该怎样,总之阅读到该文的,你是第 人。每一次刷新,都是不同的自己。