1. Introduction
首先从UNIX系统的几个IO文件的函数开始,即open,read,write,lseek,close.本章所描述的函数都是成为unbuffered IO,与之相对的是 standard IO,提供的是缓冲的IO操作,将会在后续章节描述.这里的unbuffered意思是每一个read,write都会调用内核的系统调用.
2. File Descriptor
在Unix中,著名的概念是,一切皆为文件.这里代表的就是文件描述符.当打开,或者创建一个文件的时候,内核返回的都是一个文件描述符,并且作为参数传给read和write,用来鉴别我们需要的读取或者写入的文件.Unix系统将0,1,2这三个文件描述符分别对应于标准输入,输出,错误.unistd.h
中定义了这三个描述符定义的宏,STDIN_FILENO,
,STDOUT_FILENO
,STDERR_FILENO
.文件描述符的可用范围是0-OPEN_MAX - 1
.目前,就我使用的系统而言,每一个进程可以使用的文件描述符上限是1024,可以通过命令ulimit
查看.
关于文件描述符具体指代着什么,后面会描述.
3. open and openat Functions
一个文件的创建或者打开可以通过open或者openat函数.如下:
#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 */ );
path表示的是所要打开的文件的路径,oflag代表的是各种选项,各个选项通过or进行组装.如下是一些常见的选项,定义fcntl.h
中,man page中有介绍所有的选项,下面描述一些书上的选项:
- O_RDONLY 只读
- O_WRONLY 只写
- O_RDWR 可读可写
- O_EXEC 只能用于执行
- O_SEARCH 只能用于目录,但是这个Linux没有
大部分的系统将只读,只写,读写这三个标志位设置为了0,1,2是为了兼容起见.以上几个标志位是操作文件的时候,一定需要的.接下来的标志位是可选的:
O_APPEND 追加写
O_CREAT 如果文件不存在则创建,不过这个需要和第三个参数mode搭配,指定了文件的访问权限.
O_DIRECTORY 如果path并不是指向一个目录,那么返回错误
O_EXCL 如果O_CREAT设置了,但是文件已经存在,返回错误,该标志位主要用于测试文件是否存在.
O_NONBLOCK 如果path指向一个FIFO,或者是块设备,字符设备,那么接下来的IO操作都是非阻塞的.
O_SYNC 任何一个write操作都会等待实际的IO操作完成,包括文件数据的写入以及和文件相关的数据结构更新.
O_TRUNC 如果一个文件被打开用于读或者写,将它的length设置为0.
O_DSYNC 每一个write操作都会等待实际IO操作的完成,但是不会等待文件相关的数据结构的更新(前提是这些数据结构不影响读取刚才所写入的数据).
The O_DSYNC and O_SYNC flags are similar, but subtly different. The O_DSYNC flagaffects a file’s attributes only when they need to be updated to reflect a change in thefile’s data (for example, update the file’s size to reflect more data). With the O_SYNCflag, data and attributes are always updated synchronously.
open和openat的主要区别是:
- 如果path是绝对路径,那么这种情况下
openat
的fd参数是被忽略的,此时open和openat是没有什么差别. - 如果path指明了相对路径,且fd是指向一个相对路径的起始路径的文件描述符.意思是fd是path的父路径.
- path是相对路径,且fd是一个特殊值为:AT_FDCWD
4. creat Function
一个文件也可以通过creat来创建,函数签名如下:
#include <fcntl.h>
int creat(const char *path, mode_t mode);
它的作用和open类似:
open(path, O_WRONLY | O_CREAT | O_TRUNC, mode);
早起unix系统的open第二个参数只有0,1,2.他不能打开一个未创建的文件,因此引入了这样一个creat函数.但是后来open带有了O_CREAT和O_TRUNC 选项之后,creat已经不再需要了.
而且creat也低效,它所创建的文件只能用于只读,如果需要读取的话,还需要重新open.
5. close Function
调用close
来关闭一个打开的文件.
#include <unistd.h>
int close(int fd);
一般来说,close将参数fd所对应的表项关闭.当一个进程退出的时候,它所有打开的文件都会被关闭.
6. lseek function
每一个文件都有相关联的offset,通常是一个非负数的int,表明从文件开始的处的偏移量.默认情况下,offset都是为0,除非打开文件的时候指定了O_APPEND
选项.通过调用lseek函数来调节offset,如下:
#include <unistd.h>
off_t lseek(int fd, off_t offset, int whence);
offset的具体含义取决于whence参数:
- 如果whence为SEEK_SET,offset是从文件开始的地方开始计算的
- 如果whence为SEEK_CUR,offset是从当前位置加上offset计算的,offset可以为负数或正数
- 如果whence为SEEK_END,那么offfset是由文件的大小加上offset计算的.
那么我们可以使用SEEK_CUR来计算得到此时所处的偏移量.
off_tcurrpos;
currpos = lseek(fd, 0, SEEK_CUR);
某些文件描述符不能用lseek,比如说FIFO,pipe,socket,这些情况下会返回-1,并且将errno设置为ESPIPE.lseek并不会产生任何IO操作,只是将offset调整,会影响到下一次数据的读取以及写入.lseek可以调节offset甚至超过文件本身的大小,按照文中的实验,这会导致文件的file size增大(指代的是文件数据结构中的文件大小),但是不一定会实际分配硬盘空间.
7. read Function
read用于从打开的文件当中读取数据,如下:
#include <unistd.h>
ssize_t read(int fd, void *buf, size_t nbytes);
read的返回值是实际上所读取的数据数量,如果已经读到了文末,返回的是0.如下情况举例了实际上读取的数据可能小于所请求的数据:
- 读取普通的文件的时候,所剩下可读的数据小于所请求的数据.如剩下30字节的数据,但是请求读取100字节的数据,那么实际返回的只有三十字节的数据.
- 读取网络数据,内核所缓冲的网络数据小于所请求的数据
等等.
8. Write Function
write是写入到fd,如下:
#include <unistd.h>
ssize_t write(int fd, const void *buf, size_t nbytes);
返回值是成功写入的字节数.O_APPEND
指定了的话,那么就是从文件末尾开始追加.
9. I/O Efficiency
本小节讨论的是缓冲区大小对于IO性能的影响.原文采用的程序将一个文件复制到另外一个文件,程序比较简单,这里就不在重复.记录下剩下的内容.
可以看到,随着缓冲区(即在read和write参数中所指定的缓冲区)的增大,所需要的时间逐渐减少,并且在4096字节后,没有带来多大的性能提升.这是因为大多数的文件系统实现都使用了预读(read-ahead)的操作来提高效率,也就是一次read会额外的去读多一些但是数据缓存在内存中.
10. File Sharing
Unix系统支持进程之间共享所打开的文件,比如说使用dup.在正式介绍dup的用法之前,介绍一下这个是如何实现的.但是接下来的描述是概念性的,不同的实现不一样,但是总体上是这样的.
内核使用了三个结构来表示一个打开的文件.这样的结构也决定了一个进程对于文件的修改会对其他进程造成影响.
每一个进程有一个数组,下标就是文件描述符.每一个描述符所关联的数据有:
- 文件描述符的标志位
- 一个指向file table entry的指针.
一个由内核维护file table,所有的打开文件的信息都存在着这里,每一个entry包括的内容有:
- file status 标志位,也就是read,write,append,sync等标志位
- 目前的offset
- 一个指向v-node的指针.
每一个file table enrty都有一个指向v node的指针.它里面包含着的是这个文件的所有信息.大多数文件的v node内部还包含着 i node. inode 会被open 文件的时候被读取,所以内核才可以知道文件的所有信息.i node包含的数据有,文件的大小,owner,这个文件对应的块号,这个通常是一个数组.
这里的块号一般不是以磁盘扇区为大小的,通常是4096字节,也就是说文件系统的一个block 对应于8个硬盘扇区.在实际操作文件的时候,就是通过offset来计算得到块号,然后块号再来计算得到硬盘的扇区号.
下图是通常的图示,表明了这样的三层结构:
多个文件描述符对应同一个file table entry也是可以的,比如说fork的时候,会直接复制父进程的打开文件给子进程.dup会对文件描述符进行复制,让两个文件描述符指向同一个file table entry.
11. Atomic Operation
多个进程如果想读取同一个文件,并不会有任何问题.但是如果多个进程想同时写入一个文件,就会产生同步问题.考虑一个场景,两个进程都想往一个文件末尾追加数据,在老的unix 系统中(即那些不支持O_APPEND的内核),那么就需要使用lseek现将offset调整到文件末尾,在写入,如下:
if (lseek(fd, 0L, 2) < 0) // 2表示SEEK_END
err_sys("lseek error");
if (write(fd, buf, 100) != 100)
err_sys("write error");/* position to EOF *//* and write */
这个过程在单进程中是没什么问题的,但是多进程的时候,当第一个进程执行了lseek之后,调整了offset,但是还没有写入数据的时候,第二个进程开始执行,调整offset并且写入数据,再回到的一个数据,它还是会在老的offset写入,造成数据覆盖.
解决办法是,将lseek和write视为是原子性的操作.新的Unix内核中,只要O_APPEND
被设置,那么写入操作就是原子的,这会让每次写入的时候,都是在文件末尾写入.就不再需要用lseek和write结合了.
pread and pwrite Functions
虽然书上没说,但是上面的方法无法解决在中间offset插入的情况.所以还有两个新的函数来保证原子性的操作,pread和pwrite.它们的行为和lseek之后在进行读写是一样的.
12. dup and dup2 Functions
一个现有的文件描述符可以通过dup和dup2函数来进行复制文件描述符fd.
#include <unistd.h>
int dup(int fd);
int dup2(int fd, int fd2);
dup所返回的最小可用的文件描述符.而dup2的第二个参数指定了新创建的描述符的值.如果值fd2已经指向了一个打开的文件,那么先关闭这个文件.
If fd2 is already open, it is first closed. If fd equals fd2, then dup2 returnsfd2 without closing it.
dup执行的操作是,两个文件描述符指向了同一个file table entry.如下图:
每一个被dup的文件描述符标志位是完全独立的,且FD_CLOEXEC标志位总是会被clear,这个标志位的作用后面会说到.还有其他方式来对一个描述符 duplicate,如下的都是等价的:
dup(fd);
// 等价于
fcntl(fd, F_DUPFD, 0);
// 相类似的
dup2(fd, fd2);
// 等价于
close(fd2);fcntl(fd, F_DUPFD, fd2);
最后的两行有些细微的不同,dup2可以保证是原子的操作,而后面一行的操作并不保证是原子的.
13. sync,fsync, and fdatasync Functions
大部分的Unix系统实验的时候都会在内核中开辟缓冲区用于缓存IO操作的数据.当我们写入数据给文件的时候,内核通常先将数据复制到缓冲区当中,过一段时间之后在写入到硬盘.这个过程叫做delay write.当缓冲区满了的时候,再刷入数据到硬盘,通常使用sync, fsync, and fdatasync函数,如下:
#include <unistd.h>
int fsync(int fd);
int fdatasync(int fd);
sync(void);
sync的作用是,将所有被修改过的缓冲区加入到写的队列中并且返回,但是它并不等待实际的IO操作的发生.sync一般由后台线程定期的调用,将内核缓冲区的内容写入到硬盘.
fsync操作的是单个文件,由fd指定,并且它会等待IO操作完成后返回.这个函数通常会被数据库所使用,确保数据被正确写入.fdatasync 只是保证数据部分是被写入的,对于文件属性的内容并不关心.而fsync则是等待文件属性和数据都被写入后再返回.
14. fcntl Function
fcntl函数是用于修改已经打开的文件的属性.
#include <fcntl.h>
int fcntl(int fd, int cmd, ... /* int arg */ );
后面的参数暂且不表,fcntl的行为主要有cmd参数来控制,有如下几类参数:
- 复制现有的文件描述符,cmd=F_DUPFD or F_DUPFD_CLOEXEC
- 设置/获得描述符的flags cmd = F_GETFD or F_SETFD)
- 获得文件的状态 cmd = F_GETFL or F_SETFL
- 设置/获得 异步IO的onwership
- 设置/获得记录锁 还没学到
原文中对于cmd的解释都比较好懂,这里就记录一下我不懂cmd
- F_DUPFD_CLOEXEC 复制参数fd,并且将返回值和fd都设置为FD_CLOEXEC..
- F_GETFD 返回描述符fd的flags,如FD_CLOEXEC,
FD_CLOEXEC
这个标志的作用是,任何进程调用exec系列函数的时候,都会将这个描述符自动的关闭 - F_GETFL 返回fd的file status,如O_RDONLY,O_WRONLY等. 但是因为—O_RDONLY, O_WRONLY,O_RDWR, 不是用bits来表示的,而是用0,1,2来表示的,所以在获取的时候用O_ACCMODE 进行运算后才能得到对应的标志位.
使用位运算来设置或者取消标志位,引用书上的代码:
voidset_fl(int fd, int flags) z`/* flags are file status flags to turn on */
{int val;
if ((val = fcntl(fd, F_GETFL, 0)) < 0)
err_sys("fcntl F_GETFL error");
val |= flags;/* turn on flags */
if (fcntl(fd, F_SETFL, val) < 0)
err_sys("fcntl F_SETFL error");
}
如果要取消某个位,那么使用and,如下:
val &= ˜flags;
接下来通过实验来测试O_SYNC
的作用,引用书上的例子:
实验发现O_SYNC
所需的事件和read-ahead没有多大差距,那么可能的就是在Linux系统中,O_SYNC没有起到预期的作用.但是后面几个函数都延长了IO的事件,说明了每次写入的时候都会将缓冲区的数据刷入到硬盘,不过不知道现在的linux是怎么样的,无论怎么说,还是使用fsync进行同步比较好.
15. ioctl Function
ioctl能做的事情更多,terminal IO是最常用的场景.它的定义如下:
#include <unistd.h>
#include <sys/ioctl.h>/* System V *//* BSD and Linux */
int ioctl(int fd, int request, ...);
每一种设备类型都有自己的ioctl,比如说freebsd所提供的ioctl如下: