注意事项
creat
以只写方式打开文件,不能进行读操作。- 为什么有了
open
要有creat
,早期的open
只支持0,1,2三个flag,不能打开不存在的文件,需要有单独的系统调用创建文件。而有了新的open
以后就不需要creat
了。 open
和dup
返回的文件描述符一定是最小的未使用的文件描述符。- 以
O_APPEND
打开的文件,如果进行write
的话,即使使用lseek
定位到非文件结尾处,最后也是在文件结尾处进行写。因为使用O_APPEND
的write
是由两个系统调用函数lseek
和“普通的”write
构成的一个操作。而read
操作可以使用lseek
进行定位。 - 所有的磁盘I/O都要经过内核的block buffers块缓存区,也称为内核的(buffer cache)缓冲区高速缓存。有一个例外就是对原始磁盘设备的I/O,先不考虑这种情况。
read
和write
的数据都要被内核进行缓冲,术语unbuffered I/O指的是在用户的进程中不会对这两个函数进行自动缓冲,每次read
和write
都会进行一次系统调用。
文件I/O
UNIX系统中的大多数文件I/O只用到了5个函数:open
,read
,write
, lseek
和close
。不同的缓冲长度对read
和write
的速度影响。
本章介绍的函数通常被称为不带缓冲的I/O,不带缓冲的I/O指的是每个read
和write
都调用内核中的一个系统调用,它们不是ISO C的组成部分,但是,它们都是POSIX.1和SUS的组成部分。
只要涉及在多个进程之间共享资源,原子操作的概念就非常重要。本章还进行一步讨论在多个进程之间如何共享文件,以及所涉及的内核有关数据结构。相应的函数有:dup
, fcntl
,sync
, fsync
和ioctl
等。
文件描述符
对于内核而言,所有打开的文件都是通过文件描述符引用。文件描述符是一个非负整数。当打开或者创建一个新文件时,内核向进程返回一个文件描述符。当读,写一个文件时,使用open
或者creat
返回的文件描述符标识该文件,将其作为参数传递给read
或者write
。
UNIX系统shell把文件描述符0和进程的标准输入关联,文件描述符1和标准输出关联,文件描述符2和标准错误关联。为了提高系统的可读性,通常把它们换成符号常量STDIN_FILENO
,STDOUT_FILENO
和STDERR_FILENO
,它们都在头文件<unistd.h>
中定义。
文件描述符的变化范围是0到OPEN_MAX-1
,早起的UNIX系统实现采用的上限值是19,现在的很多系统将它增加到63。(对于Linux, FreeBSD等的很多版本,文件描述符的变化范围几乎是无限的,只受到硬件资源的约束)
open
,openat
和creat
, close
函数原型:1
2
3
4
5
6
7
8
9
10
11
int open(const char *pathname, int flags);
int open(const char *pathname, int flags, mode_t mode);
int creat(const char *pathname, mode_t mode);
int openat(int dirfd, const char *pathname, int flags);
int openat(int dirfd, const char *pathname, int flags, mode_t mode);
参数pathname
是路径名,可以是相对路径也可以是绝对路径,flags
的选项有很多,它们定义在<fcntl.h>
头文件中。flag参数必须在O_RDONLY
,O_WRONLY
和O_RDWR
之中选且只能选一个。然后还有很多其他的可选flag,常见的有:O_APPEND
,O_CREAT
,O_EXCL
, O_DIRECTORY
等,使用man 2 open
就可以查看。
openat
vs open
open
和openat
返回的一定是最小的没有使用的文件描述符。可以利用这一点可以在标准输入,标准输出,或者标准错误上打开新的文件。一个应用程序可以先关闭标准输出,然后打开另一个文件,执行打开操作前就能了解到该文件一定会在文件描述符1上打开。
dirfd
参数是open
和openat
的区别,它们之间的关系有以下三种:
pathname
指定的是绝对路径名,dirfd
参数被忽略,open
和openat
一样。pathname
指定的是相对路径名,dirfd
制定了相对路径名在文件系统中的开始地址,dirfd
参数通过打开相对路径名的目录来获取。pathname
指定了相对路径名,dirfd
的参数是特殊值AT_FDCWD
,这种情况下,路径名是在当前工作目录中获取,openat
和open
在操作上类似。
openat
作用
为什么增加openat
函数,它的目的是解决两个问题:
- 让线程可以使用相对路径名打开目录中的文件,而不再只能打开当前工作目录。同一进程中的所有线程共享相同的当前工作目录。
- 避免time-of-check-to-time-of-use错误。它的基本思想是,如果有两个基于文件的系统调用,第二个调用的结果依赖于第一个调用的结果,那么程序是脆弱的。因为两个调用并不是原子操作,在两个函数调用之间,文件可能改变了,这就造成了第一个调用的结果不再有效,使得程序的最终结果是错误的。
文件名和路径名过长
当文件名和路径名过长时,是截断为系统允许的最长量还是返回出错信息?这个是由系统的历史形成的。通常BSD和Linux总是会返回出错,而System V和Solaris等不一定。
具体的可以根据POSIX.1定义的常量_POSIX_NO_TRUC
决定是截断还是出错。根据文件系统的类型,这个值可以变换。可以使用fpathconf
或者pathconf
查询目录具体支持哪种行为。
如果_POSIX_NO_TRUC
有效,当路径名超过PATH_MAX
或者路径名中的任一文件名超过NAME_MAX
时,返回出错,并将errno
设置为ENAMETOOLONG
。
create
和open
create
其实相当于指定了open
的flags为O_WRONLY|O_CREAT|O_TRUNC
。
为什么有了open
还要有creat
,在早期的UNIX版本中,flags
只能为0,1或者2。无法打开一个不存在的文件。因此需要另一个系统调用creat
创建新文件。现在的open
系统调用提供了O_CREAT
和O_TRUNC
选项,也就不需要creat
了。
creat
的不足:creat
以只写方式打开所创建的文件,即创建新文件之后,只能对新文件进行写操作,不能进行读操作。如果要创建一个临时文件,先写文件,然后再读文件。在open
的老版本时,即不能打开不存在的文件时,需要先使用creat
创建新文件,然后关闭该文件,然后使用open
读文件。现在的话,可以使用以下方式实现创建新文件并进行读写:1
open(path, O_RDWR|O_CREAT|O_TRUNC, mode);
close
函数
调用close
关闭一个已经打开的文件。函数原型:1
2
3
int close(int fd);
创建文件
-
创建一个只写文件(如果文件存在,将文件清零):
1
2creat(filename, mode);
open(filename, O_WRONLY|O_CREAT|O_TRUNC, mode); -
创建一个读写文件(如果文件存在,将文件清零):
1
open(filename, O_RDWR|O_CREAT|O_TRUNC);
-
检测并创建一个只写文件( 如果文件存在,错处,返回-1):
1
open(filename, O_WRONLY|O_CREAT|O_EXCL, mode);
-
检测并创建一个读写文件( 如果文件存在,错处,返回-1):
1
open(filename, O_RDWR|O_CREAT|O_EXCL, mode);
lseek
, read
, write
它们的原型如下所示:
1 |
|
当前文件偏移量
- 每一个打开文件都有一个和它相关联的当前文件偏移量(current file offset)。它通常是一个非负整数,用来度量从文件开始处的字节数。
- 通常情况下,读写操作都是从current file offset开始的,并且使偏移量增加读写的字节数。
- 除了指定
O_APPEND
选项外,打开一个文件时,默认的current file offset都是0。 whence
有三个取值,SEEK_SET
,SEEK_CUR
,SEEK_END
。lseek
成功执行,返回的offset等于whence+offset
,对于SEEK_CUR
和SEEK_END
来说,参数offset
可正可负,只要保证返回的current file offset非负即可。lseek
中的l
表示long
。- current file offset可以大于文件长度,这种情况会在文件中构成一个空洞。空洞不要求占据磁盘上的存储区。
read
1 | ssize_t read(int fd, void *buf, size_t count); |
read
函数从文件描述符标示的文件中读取至多count
个字节到buf
指定的位置。如果操作成功的话,返回读到的字节数,如果已经到了文件结尾,返回0。出错的话,返回-1,设置errno。
在以下几种情况下,读到的字节数可能少于count
:
- 读普通文件时,在读满
count
个之前就已经到了文件尾端。 - 某个信号造成中断时,而已经读取了部分数据时。
- 从终端设备读时,通常一次最多读一行。
- 从网络读时,网络中的缓冲机制。
- 从管道或者FIFO读取时,管道包含的字节数少于
count
。 - 从面向记录的设备读时,一次最多返回一个记录。
- 第二个参数
void*
表示通用指针。 - 返回值
ssize_t
是有符号类型,因为它需要返回正整数字节,0和-1。 - 第三个参数
size_t
是一个无符号类型。
write
1 | ssize_t write(int fd, const void *buf, size_t count); |
它的返回值通常和count
一样,否则就是出错了。出错的常量原因有:
- 磁盘满了,
- 超过了一个给定进程的文件长度限制。
- 如果
lseek
返回的当前文件偏移量不在文件结尾,write
会覆盖掉相应位置的数据。
对于普通文件,写操作从文件的当前偏移量开始,如果打开文件时指定了O_APPEND
选项,那么文件偏移量设置在文件结尾处。在一次写成功之后,文件偏移量增加实际写的字节数。
创建含有空洞的文件
当lseek
使得当前文件偏移量超过了现有文件长度,再继续进行write
之后,当前文件偏移量和文件长度之间的内容就是空洞,它一般不占用磁盘空间,但是使用ls
时,会把它计算成字节长度。
I/O的效率
进程终止时,UNIX系统内核会关闭所有打开的文件描述符,但是并不会关闭标准输入和输出。
在选取read
和write
的buffer大小时,也有一定技巧。大多数文件系统都使用了预读(read ahead)技术。当进行顺序读取时,系统试图读入比应用所要求的更多数据,并且假设应用很快就会读这些数据。
在使用ext4
文件系统时,它的磁盘块长度是4096,所以当BUFFER大于等于4096时,读写时间几乎不变。
文件共享
I/O数据结构
UNIX支持在不同进程之间共享打开文件。这需要使用到内核用于I/O的数据结构。内核使用三种数据结构表示打开文件:进程表记录项,文件表项和节点表项。
- 进程表记录项。每个进程在进程表中都有一个记录项,记录项中包含一张打开文件描述符表,每隔描述符占用一项,其中内容有:文件描述符标志和指向文件表项的指针。
- 文件表项。内核为所有打开文件维持一张文件表。每个表项包含:文件状态标志,当前文件偏移量和指向该文件节点表项的指针
- 节点表项。每个打开设备都有一个节点结构。包含文件的所有者,文件长度,指向文件实际数据块在磁盘上所在的指针等。
如果两个进程打开了同一个文件,每个进程都会获得各自相应文件的一个文件表项,这两个文件表项中的节点表项指针指向同一个节点表项。也有可能多个进程的文件描述符指向同一个文件表项。
自己的总结,每一个文件都有一个节点表项,记录文件长度和数据存储地址,而文件表项记录的是在节点表项的哪个位置进行什么操作,进程表记录项记录了每个进程打开了几个文件,每个文件的文件表项在哪里。
write
和lseek
对当前文件偏移量的影响
write
在写入完成后,在文件表项的当前文件偏移量上加上写入的字节数,如果当前文件偏移量超过了当前文件长度,更新节点表项中的文件长度,相当于文件长度增加了。lseek
只修改文件表项中的当前文件偏移量,不进行任何I/O操作。lseel
定位到文件尾端的时候,文件表项中当前文件偏移量被设置为节点表项中的当前文件长度。- 使用
O_APPEND
打开文件的时候,文件表项中的文件状态标志也会被修改,对于使用O_APPEND
操作打开的文件,进行write
操作相当于先将当前文件偏移量设置为节点表项中的文件长度,然后再write
,即使先使用lseek
将当前文件偏移量设置为SEEK_SET
也不行,也是进行追加。所以在每次append之前不用先进行lseek
,lseek
了也白做。但是read
可以使用lseek
正常进行。
原子操作
如果一个操作是原子操作,那么这个操作的所有步骤要么不执行,要不全部执行。
追加文件
指定open
的O_APPEND
选项实现追加操作,1
2fileno=open(filename, O_RDWR|O_APPEND);
write(file, buf, BUFSIZE);
追加文件是一个原子操作,如果不是原子操作的话,就相当于:1
2
3fileno = open(filename, O_RDWR);
lseek(fileno, 0, SEEK_END);
write(fileno, buf, BUFSIZE);
如果是单进程,上面两段代码是等价的,但是如果是多进程的话,下面代码就可能会出错。进程A lseek,进程B lseek,进程A write,进程B write。进程B的操作会覆盖进程A的操作。
所以这也就解释了使用选项O_APPEND
后的操作,因为这个append的write
是由两个系统调用组成的原子操作,先lseek
,再普通的write
。所以在调用write
之前不用lseek
,就算你lseek
了也是白lseek
。
读写原子操作
读写的原子操作原型如下:1
2
3
4
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);
调用pread
相当于调用lseek
和read
的原子操作,但是pread
不改变当前文件偏移量。
调用write
相当于调用lseek
和write
的原子操作,但是pwrite
不改变当前文件偏移量。
创建文件原子操作
检查文件是否存在和创建文件是一个原子操作。如果这个操作不是原子操作,比如说是由open
和creat
两个函数调用组成的一个操作,它们不是一个原子操作。当前进程确定一个文件不存在,决定创建该文件。在open
和creat
调用之间,另一个进程创建了这个文件,并写入了数据。当前进程会再次创建这个文件,覆盖掉另一个进程写入的数据。
dup
和dup2
复制文件描述符
UNIX系统提供了两个原子操作dup
和dup2
对一个指定的文件描述符进行复制。如果得到的新文件描述符和fd不同,那么这两个文件描述符共享同一个文件表项。
1 |
|
非原子操作的文件描述符复制可以通过fcntl
实现。
sync
,fsync
和fdatasync
UNIX系统在内核中设置了缓冲区高速缓存或者页高速缓存,大多数磁盘的I/O都通过缓冲区进行。当我们向文件写入数据时,内核通常先将数据复制到缓冲区中,然后排入队列,晚些写入磁盘,这方方式叫做延迟写。
等到内核需要使用缓冲区存放其他磁盘块数据时,它会把所有延迟写数据写入磁盘。为了保证磁盘上实际文件系统和缓冲区中内容的一致性,UNIX提供了三个函数,它们的原型如下:1
2
3
4
5
6
7
8
9
10
11
// 1.将所有修改过的块缓冲区排入队列,然后就返回,并不等待实际写磁盘操作结束。
// 通常情况下,update系统守护进程一般每隔30秒调用一次`sync`函数,这就保证了定期将内核块缓冲区的内容写入磁盘。命令sync(1)也会调用`sync`函数。
void sync(void);
// 2.只对文件描述符`fd`指定的一个文件起作用,等到写磁盘操作结束才返回。更新文件的数据和属性。
int fsync(int fd);
// 3.只对文件描述符`fd`指定的一个文件起作用,等到写磁盘操作结束才返回。只更新文件的数据。
int fdatasync(int fd);
fcntl
fcntl是文件控制函数,它的原型如下:1
2
3
4
int fcntl(int fd, int cmd, ... /* arg */ );
fcntl有很多种功能,这一节先介绍以下五种:
- 复制一个已有的描述符,设置
cmd
为F_DUPFD
或者F_DUPFD_CLOEXEC
。 - 获取和设置文件描述符标志,设置
cmd
为F_GETFD
或者F_SETFD
。当前只有一个文件描述符标志,就是FD_CLOEXEC
。 - 获取和设置文件状态标志,设置
cmd
为F_GETFL
或者F_SETFL
。
获取文件状态标志时,介绍open
时给出了许多文件状态标志。对于五个互斥的权限,需使用O_ACCMODE
取得访问方式位,然后与相应的权限比对。对于其他的权限,将返回值和相应的标志进行与操作,判断是否设置了相应位。
设置文件状态标志位时,可以更改的几个权限有,O_APPEND
,O_NONBLOCK
,O_SYNC
,O_DSYNC
,O_RSYNC
,O_FSYNC
,O_ASYNC
。 - 获取和设置异步I/O所有权,设置
cmd
为F_GETOWN
或者F_SETOWN
。 - 获取和设置记录锁,设置
cmd
为F_GETLK
,或者F_SETLK
或者F_SETLKW
。
在修改文件描述符标志或者文件状态标志时,必须要先获得现在的标志值,然后对它进行修改,获得新的标志值,然后进行设置。不能单单设置一个标志值,否则会关闭以前设置的标志位。
ioctl
这个有点看不懂。
/dev/fd
UNIX提供了/dev/fd
目录,其中包含了名为0, 1, 2的文件。打开/dev/fd/0
,/dev/fd/1
, /dev/fd/2
相当于复制描述符n。即:1
2fd = open("/dev/df/0", mode);
fd = dup(0);
上述两行代码是相等的。文件描述符0和fd共享同一个文件表项。在Linux中,文件描述符被映射成指向底层物理文件的符号链接。比如打开/dev/fd/0
时,实际上打开的是与标准输入关联的文件。返回的新文件描述符的mode和/dev/fd
文件描述符的mode并不相关。所以,即使我们使用O_RDWR
mode打开/dev/fd/0
,也不能对fd
进行写操作。
Linux下提供了/dev/stdin
,/dev/stdout
, /dev/stderr
,它们和/dev/fd/0
等都是一样的。在shell中,可以使用dev/fd作为参数,把标准输入和输出当做一个文件,可以像处理其他文件一样进行操作。
参考文献
1.《APUE》第三版