Skip to content

Commit

Permalink
post: summary on TLPI about file io
Browse files Browse the repository at this point in the history
  • Loading branch information
TangliziGit committed Mar 23, 2024
1 parent b4898f3 commit bb1ee68
Showing 1 changed file with 139 additions and 0 deletions.
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
# Chapter 4. 文件 I/O之通用的 I/O 模型

文件描述符用以表示所有类型的已打开文件:管道、FIFO、socket、终端、设备和普通文件
4个主要的系统调用:

```
int fd = open(path, flags, mode) // fd: 总是非负整数;mode:当open创建文件时设定的权限
ssize_t n = read(fd, buf, count) // 无字节读出时,返回0;出现错误时,返回-1
ssize_t n = write(fd, buf, count)
int status = close(fd)
off_t off = lseek(fd, offset, whence)
int = ioctl(fd, request, ...) // 为具体设备执行某种操作
```

通用IO:使用 4 个同样的系统调用可以对所有类型的文件执行 I/O 操作,包括/dev/tty、/dev/pts/1等。
- 但需要确保这种设备驱动程序实现了IO调用集。
打开文件:open
- 早期Unix使用012代表只读、只写和读写,大多数现代OS兼容这种模式。所以O_RDWR 并不等同于 O_RDONLY | O_WRONLY,后者属于逻辑错误。
- **新建文件的访问权限不仅仅依赖于参数 mode,而且受到进程的 umask 值和(可能存在的)父目录的默认访问控制列表(17.6 节)影响。**
- SUSv3 规定,如果调用 open()成功,必须保证其返回值为进程未用文件描述符中数值最小者。
关于打开文件的flag
- ![2023-11-24_11-56.png](../assets/2023-11-24_11-56_1700798221300_0.png)
- flags由三部分组成:文件访问模式、文件创建模式、文件状态标志(追加写、同步写、非阻塞、...)。
- 文件访问模式标志:O_RDONLY、O_WRONLY 和 O_RDWR,能够被检索
- 文件创建模式:仅用于创建文件时起效。不能检索,也无法修改。因为没有意义。
- 文件状态标志:可以检索和修改
- `/proc/<pid>/fdinfo/`目录下的文件记录了该进程的已打开文件描述符,记录了偏移量、文件状态标志和mnt_id。可参考man 5 proc
- O_ASYNC:当可以实施IO操作时,内核发送信号通知,称为信号驱动IO。Linux里open使用它是没有作用的。
- O_CREAT:必须指定mode,否则会使用栈上的垃圾值。
- O_SYNC:每次write做fsync。O_DSYNC与之类似,仅当文件属性更新时再fsync。O_RSYNC是指read等待所有写操作完成,Linux上与O_SYNC是一致的实现。
- O_EXCL:如果创建文件时该文件存在,那么open失败。在实现文件锁时可以保证检查存在和创建文件的原子性。
- **O_NOATIME:Linux的非标准扩展。**该标志的使用**能够显著减少磁盘的活动量**,省却了既要读取文件内容,又要更新文件 i-node 结构中最近访问时间的繁琐,进而节省了磁头在磁盘上的反复寻道时间。实测BUF_SIZE==1024,SSD上大概有3%的读速度提升。
读数据:read
- `sszie_t`是一个神奇的类型,能存储[-1, SSZIE_MAX]范围的数据。
- 什么时候read / write返回值不等于count参数?对普通文件可能读到文件末尾,对其他文件看实现差异,如管道、FIFO、socket 或者终端。对于终端,读到回车即结束。
写数据:write
- 对磁盘文件来说,造成“部分写”的原因可能是由于磁盘已满,或是因为进程资源对文件大小的限制
关闭文件:close
- 当进程终止,自动回收fd。
- 能够捕获的错误有:企关闭一个未打开的文件描述符,或者double-close,也能捕获特定文件系统在关闭操作中诊断出的错误条件。如NFS提交失败将做为close的失败原因。
设置文件偏移量:lseek
- whence有三种基点:指代从头开始、从当前开始、从尾部开始。
- lseek()调用只是调整内核中与文件描述符相关的文件偏移量记录,并没有引起对任何物理设备的访问。
- lseek()并不适用于所有类型的文件。错误是ESPIPE
id:: 6558eb9f-9679-4a53-92b3-e099bee3979d
空洞文件:hole file
- 参考:[Linux空洞文件](https://banbanpeppa.github.io/2019/08/21/linux/holefile/https://banbanpeppa.github.io/2019/08/21/linux/holefile/)
- lseek在尾部之后write / read会怎么样?read()调用将返回 0,表示文件结尾。write()函数可以在文件结尾后的任意位置写入数据。这被称为文件空洞,读取内容将是0。文件空洞可能会占用更少的磁盘空间。
- 写入的空洞文件不会占用物理磁盘空间,读出的逻辑大小不变。系统并未fallocate长度。
问题:
- 错误处理最佳实践?
- 对被调函数做特定的检验,例如对open、lseek做-1检查。当错误被发现,则检查errno即可。
- 对close而言,它会首先删除打开文件描述符。可以理解其返回的错误码仅用于通知。如果使用stdio库函数,推荐做法是先fflush再fclose,fflush是可以重试的。
- ssize_t是怎么存储和表达的?
- EOF是read的errno么?什么时候会出现?
- 不是,是libc的stdio定义的标志,用于getc之类函数返回值判断文件结尾。
- 空洞文件在内核中如何实现?

# Chapter 5. 深入探究文件 I/O

本章系统调用:
```
int fcntl(int fd, int cmd, ...);
int dup(int old_fd);
int dup2(int old_fd, int new_fd);
int dup3(int old_fd, int new_fd, int flags);
ssize_t pread (int fd, void *buf, size_t count, off_t offset);
ssize_t pwrite (int fd, const void *buf, size_t count, off_t offset);
ssize_t readv (int fd, const struct iovec *iov, int iovcount);
ssize_t writev (int fd, const struct iovec *iov, int iovcount);
ssize_t preadv (int fd, const struct iovec *iov, int iovcount, off_t offset);
ssize_t pwritev(int fd, const struct iovec *iov, int iovcount, off_t offset);
int truncate (const char *path, off_t length);
int ftruncate(int fd, off_t length);
// 以下不是系统调用
int mkstemp(char *template);
FILE *tmpfile();
```

系统调用的原子性
- 所有系统调用都是以原子操作方式执行。(相当于对竞争资源上锁)
- 例子:open with O_EXCL,write with O_APPEND
- 有些文件系统(例如 NFS)不支持 O_APPEND 标志。内核会选择lseek+write的方式,做非原子调用,可能导致文件脏写(即写入被覆盖)。
**内核管理打开文件的方式:内核维护的三个结构**
- ![2023-11-24_12-14.png](../assets/2023-11-24_12-14_1700799300884_0.png)
- 进程级的^^文件描述符表^^:目前仅维护close-on-exec标志、文件指针
- 系统级的^^打开文件表^^:维护offset、文件状态标志、inode指针
- 文件系统的 i-node 表:维护文件基本属性
- 一些特例:
- 同一进程的不同fd指向同一打开文件:dup和dup2可以产生
- 不同进程的同一fd指向同一打开文件:fork可以产生
- 不同进程的不同fd指向不同打开文件:open可以产生
- 总结:只要共享一个打开文件表项,就会共享offset,会感应到其他线程或进程在操作。
文件控制:fcntl
- F_GETFL:获取文件访问模式,文件状态标志
- F_SETFL:仅设置文件状态标志,其他会被忽略。一些fd不是通过open开启的,所以存在仅能用fcntl的场景。
- F_DUPFD:可以替换dup和dup2的调用。
- F_DUPFD_CLOEXEC:可以替换dup3的调用。
复制fd:dup & dup2 & dup3
- dup:意义在与复制出的两个fd共享同一个打开文件,即共享offset和文件状态。
- dup2:会先close old_fd,再替换新的fd上去。一定小心close的错误会被忽略掉,最佳实践是先close再dup2。
- dup3:在创建文件时添加状态标识,仅支持O_CLOEXEC,也就是进程级别的那个。
带偏移量的读写:pread & pwrite
- 指定在文件头开始的偏移量上做IO,不修改当前打开文件表中的偏移量。**与lseek+read相比,pread系列减少一次系统调用次数,虽然IO时间才是真正的瓶颈。**
Scatter-Gather I/O: readv & writev
- 注:「若因 iovcnt 参数值过大而失败,glibc外壳函数将拷贝数据,一次执行 read()或 write()调用」在glibc 2.20之后就会直接调用syscall,不做这种隐式开销的动作。
- 这两个系统调用会放回实际读写长度,用户需要按iov的顺序,对读写边界做匹配。
截断文件:truncate & ftruncate
- 若文件大小小于参数 length,调用将在文件尾部添加一系列空字节(可造成文件空洞)
- truncate:需要整个路径都有x权限,且文件本身可写
- ftruncate:需文件可写,且不修改偏移量
大文件IO:LFS系列
- 为了解决32位体系架构中支持大于2^31-1次方字节存储设备的IO。(因为off_t是long类型只有4字节)64位则不需要用这些调用。(64位下数据类型变大的只有:指针、long、unsigned long从4变8)
- 这些系统调用仅仅在原有名称后面带64,如lseek64(x, x, off64_t)。
- 编译时只用开启_FILE_OFFSET_BITS为64,所有相关的 32 位函数和数据类型将自动转换为 64 位版本,例如,实际会将 open()转换为 open64(),数据类型 off_t 的长度也将转而定义为 64 位。几乎无需修改源码。
`/dev/fd`目录
- **对于每个进程,内核都提供有一个特殊的虚拟目录/dev/fd(实际上是一个符号链接,链接到 Linux 所专有的/proc/self/fd 目录)**。例如`/dev/fd/0`相当于标准输入。
- 打开/dev/fd 目录中的一个文件等同于复制相应的文件描述符
- 这个机制在shell里比较有效,例如我需要将pipe作为某个输入文件参数:`ls | diff /dev/fd/0 other`
临时文件:mkstemp & tmpfile
- 前者需要转递带`XXXXXX`的模板字符串,返回`O_RDWD | O_CREAT | O_EXCL`的fd。后者直接返回FILE stream。这两个都只是glibc提供的函数。
- 前者为了防止有其他进程再次打开fd,通常用户会直接unlink掉再使用。后者内部会直接unlink。
问题:
- 线程A打开一个fd,线程B之后删除它。那么线程A可以读到文件么?ls可以看到文件么?
- 可以,因为inode没有实际删除。当没有open时才删除。
- ls看不到
- 线程A把一个fd传给线程B,线程B可以读数据么?存在并发问题么?会从哪里开始读数据?进程呢?(fork可以产生)
- 不会,所有syscall都是原子性。只要注意是乱序即可。因为共享同一个进程的fd表。
- 进程fork产生的两个相同fd,也是可以做到相同的事情。因为共享同一个打开文件项,共享offset。但是注意不共享close-on-exec
- 为什么系统调用是原子性的?各个进程执行汇编的syscall进入内核态可以视为一次函数调用么?进程间有锁保护?
- 我认为应该理解成这些syscall内部是对竞争资源上锁,保证用户使用上都是原子的。

# Chapter 13. 文件 I/O 缓冲

TODO

0 comments on commit bb1ee68

Please sign in to comment.