APUECh03

 5th July 2017 at 10:07am

这章主要讲 Unbuffered I/O,文件系统的原子操作,以及操作系统内核是如何维护相关数据结构的。

3.3 open and openat Functions

#include <fcntl.h>
int open(const char *path, int oflag, ... /* mode_t mode */ );
int openat(int fd, const char *path, int oflag, ... /* mode_t mode */ );
// Both return: file descriptor if OK, −1 on error

oflags 可以是多个 flag 用或操作组合起来。但是对于下面 5 个选项,你必须提供其中之一:

  • O_RDONLY: 0
  • O_WRONLY: 1
  • O_RDWR: 2
  • O_EXEC: 3
  • O_SEARCH: 4 (绝大多数系统没有实现它)

其他选项大多数是一些控制功能,比如文件不存在时创建文件(O_CREAT),再比如如果目标不是目录抛出错误(O_DIRECTORY)。有几个 SYNC 变量需要留意下:

  • O_SYNCwrite 请求会在物理 I/O 完成后,数据跟文件属性都更改后返回
  • O_DSYNCwrite 请求会在写数据的物理 I/O 完成后返回,并且如果文件属性的改动不会影响到读取刚写入的数据,则不会等文件属性更新好再返回
  • O_RSYNC:使 read 请求在其读取的文件区域的 pending write 做完后返回。这个选项比较特殊,各系统的实现不一样。比如 Mac OSX 没有实现这个选项,Linux 把它当作与 O_SYNC 等同。

其他选项,有需要时再查就好了。

openat 的含义是,在 fd 这个路径上打开 path 文件。它的应用场景在:

  1. 可以实现 a per-thread "current working directory"。多线程程序拥有同样的 working directory,在一个线程中更改 working directory 时会影响到其他线程。所以可以给每个线程单独分配一个目录的 fd,然后用 openat 函数相对于这个 fd 打开文件,就能实现一个线程一个“工作目录”了。(Reference
  2. 避免 time-of-check-to-time-of-use (TOCTTOU) 错误。书中没有详细说明,但是我发现一个 StackOverflow 问题 指出了原因。如果用 openat,打开的文件始终是相对于 fd 对应的目录的,无论这个目录是否被外部改名或者移动了;如果不用 openat,那么有可能你检查目录权限后,这个目录被移走了,后续的文件写入就失败了。这同时可以避免一些安全问题。

3.4 creat Function

creat 函数等同于 open(path, O_WRONLY | O_CREAT | O_TRUNC, mode)。这个函数现在没什么用了,它出现的原因是一些老的系统的 open 函数没法创建函数,所以需要 creat 函数来创建文件。

3.5 close Function

调用 close 会释放纪录锁(record lock)。进程结束时,内核会把它打开的文件描述符全部关掉。

3.6 lseek Function

每个打开的文件描述符,都有一个对应的当前文件偏移(current file offset),表示 write 请求在文件的哪个位置写入。lseek 可以修改这个文件偏移值。lseek 函数会返回新的文件偏移值,所以可以用来判断文件大小(not sure whether it's best practice);失败会返回 -1,可以用来判断 fd 是不是 not seekable 的,比如 pipe, FIFO, socket 是 not seekable 的,lseek 会返回 -1 并设 errnoESPIPE

lseek 可以修改文件偏移到比当前文件大小更大的值,然后写入数据会造成空洞。空洞不会占用磁盘空间,被 read 时返回的是 0。

readwrite 函数都会使文件偏移值增大。

3.7 read Function

#include <unistd.h>
ssize_t read(int fd, void *buf, size_t nbytes);
// Returns: number of bytes read, 0 if end of file, −1 on error

没有太多复杂的东西。需要留意的一点是,read 读到的内容长度(也就是它的返回值)不一定等于 nbytes,比如读到文件末、或者向网络或者终端读的时候。

3.8 write Function

Nothing special.

3.9 I/O Efficiency

作者用 read 函数不同的 nbytes 值,实验了在一个 4K 对齐的文件系统内读一个大文件。结论是 nbytes 为 4096 时性能最佳。同时由于一些文件系统有一些 read-ahead 的机制来提升性能,所以 nbytes 大到了 32 字节后,性能基本上跟 4096 时差别不大。

3.10 File Sharing

这节主要讲了文件在内核中用怎样的数据结构来表示。这样可以引入不同的进程是如何读写同个文件的。

  • Process table 保存 file descriptor flags,比如 close-on-exec
  • File table 保存 file status flags,比如 read, write, append, sync 和 nonblocking,同时保存当前文件偏移
  • v-node 是在 VFS (Virtual File System) 层引入的概念,用的是封装不同文件系统的差异;i-node 的实现文件系统相关的,但是它的数据结构在不同的文件系统间通用

这个图说明了,不同的进程是可以打开同个文件的,因为他们的 process table 和 file table 不一样。同时在一个进程内,可以有多个 fd 指向同一个文件(dup 函数,后面有讲);fork 出来的子进程拥有和父进程一样的 process table、file table。

另外,O_APPEND 选项会使每次 write 时,内核会帮你 lseek 到文件末尾再写入,同时保证这个过程是原子的,保证了多进程写同一个文件时,内容不会被写乱。

3.11 Atomic Operations

The Single UNIX Specification 提供了两个函数用来做原子读写:pread, pwrite。这两个函数可以保证 “lseek 到文件某个位置后进行读写操作” 这个过程是原子的,并且调用完后不会修改 current file offset。

这两个函数接口没有提供类似 lseekwhence 参数(SEEK_SET, SEEK_CUR, SEEK_END),因为它是不必要的。可以思考下为什么。

另外一个涉及原子操作的场景,是在创建文件时。为 open 同时指定 O_CREATO_EXCL 时,如果文件已存在会报错。但是这种情况下,“判断文件是否已经存在”和“创建文件”这两个过程是一起(atomic)被执行的。如果你想实现“文件不存在时则创建它”,那么你应该用这两个参数。

对于 Python 来说如何解决这个问题呢?Python 3.3 以前,可以使用 os.open(),它提供了与 C 函数 open() 一样的接口;3.3 及以后,可以内置函数 open(),它提供了一个 x 模式,作用是一样的。参考这个 StackOverflow 问题

3.12 dup and dup2 Function

#include <unistd.h>
int dup(int fd);
int dup2(int fd, int fd2);
// Both return: new file descriptor if OK, −1 on error

这两个函数,可以复制出一个新的 fd,指向一样的 file table。区别在于,新的 fd 的 FD_CLOEXEC 会被清掉(dup2 只在 fd 不等于 fd2 时清掉)。

对于 dup2,如果 fd2 已经存在并且不等于 fd,则会先被 close() 掉;如果相等,则什么都不做就返回 fd 值。

dup(fd) 等价于 fcntl(fd, F_DUPFD, 0)dup2(fd, fd2) 近似等价于 close(fd2); fcntl(fd, F_DUPFD, fd2),区别在于 dup2 是原子的,并不会把 close()fcntl() 拆开做,同时有一些 errno 不一样。

3.13 sync, fsync, and fdatasync Functions

大部分 UNIX 系统内核提供了 buffer cache 机制。buffer cache 是一块存在在内存中,并且不会被换出的缓存。它用来作为硬盘或者其它低速设备的内容缓存。比如你对硬盘的写入,可能会被缓存起来而不是马上写入硬盘,同时操作系统有可能把小的写入操作组合成大的操作;同时你读取的文件内容如果在 buffer cache 中,那么内核可以直接给你而不需要读硬盘。据说 buffer cache 可以减少 85% 的磁盘 IO。

调用 sync 会告诉系统,尽快把 buffer cache 里的改动写到磁盘里,但是它不等数据写完后返回,而是马上返回,所以没什么卵用(参考 这个回答 的评论)。fsyncfdatasync 是针对某个 fd 的,它会等到 buffer cache 中的数据写完再返回;区别在于 fdatasync 不会等文件的属性被更新再返回。

3.14 fcntl Function

#include <fcntl.h>
int fcntl(int fd, int cmd, ... /* int arg */ );
// Returns: depends on cmd if OK (see following), −1 on error

五个功能:

  1. Duplicate an existing descriptor (cmd = F_DUPFD or F_DUPFD_CLOEXEC)
  2. Get/set file descriptor flags (cmd = F_GETFD or F_SETFD)
  3. Get/set file status flags (cmd = F_GETFL or F_SETFL)
  4. Get/set asynchronous I/O ownership (cmd = F_GETOWN or F_SETOWN)
  5. Get/set record locks (cmd = F_GETLK, F_SETLK, or F_SETLKW)

其中第四、五项暂时没有细讲,只说了前三个。

如果要判断某个 fd 以什么访问模式打开,需要用 F_GETFL 的结果与 O_ACCMODE 做与运算,再看结果是 O_RDONLY 还是 O_WRONLY, O_RDWR。原因很简单。

留意使用 F_SETFL 前要先 Get 出来,避免把已有的 flag 给干掉了。

书中给出了一个使用 fcntl 的场景,比如你的程序读的是标准输入进行,但是提供把标准输入重定向到文件的,又是 shell 做的。所以你没有机会在 open() 时指定 flag(是 shell 打开的文件)。这时候你可以用 fcntl 改变文件 flag。

文中又对 O_SYNC 做了实验,显示 Linux 下使用 fcntl 设置 O_SYNC 是无效的。然后使用 O_SYNC / fsync / fdatasync 这些方式写文件,消耗的时间跟系统实现很相关,并不如你预测的那样。

3.15 ioctl Function

这些函数主要是 Terminal I/O 在用,不需要怎么关注。

3.16 /dev/fd

主要用在命令行上,/dev/fd/0 表示标准输入,/dev/fd/1 表示标准输出等等,如:

$ filter file2 | cat file1 /dev/fd/0 file3 | lpr

在大部分系统中,程序中 open("/dev/fd/13", mode) 相当于 dup(13)mode 参数被忽略;但是 Linux 的实现机制不太一样,它的 /dev/fd/n 是个软链接到具体文件,所以 mode 还是生效的。