UNIX 文件IO

文件描述符

在进程中指向一个唯一绑定的文件(设备),通过引用文件描述符可以操作特定的目标文件(设备)。
文件描述符是一个非负整数,UNIX系统shell把文件描述符0与进程的标准输入(STDIN_FILENO)关联,把文件描述符1与进程的标准输出(STDOUT_FILENO)关联,把文件描述符2与进程的标准错误(STDERR_FILENO)关联,通常定义于头文件unistd.h中。

文件描述符的作用域被进程限制(Q:多进程打开同一文件),文件描述符与文件/设备绑定,与进程绑定,相当于文件/设备与进程间数据流通的端口。

函数openopenat

#include 
int open(char *path, int oflags,.../* mode_t mode */);
int openat(int fd, char *path, int oflags,.../* mode_t mode */);

IOS C中,最后一个参数用...表示余下参数的数量和类型可变。
path: 打开文件路径
oflags: 打开方式

  • O_APPEND:每次写时追加到文件尾端。

  • O_CLOEXEC:把ED_CLOEXEC常量设置为文件描述符的标志。

  • O_CREAT:若文件不存在,则创建它。使用此选项时,open/openat需要说明mode参数指明创建文件的访问权限。

  • O_DIRECTORY:如果path引用的不是目录则出错。

  • O_EXCL:如果同时指定了O_CREAT,且文件已经存在,则出错。

  • O_SYNC:使得每次write等待物理I/O操作完成,包括由该write操作引起的文件属性更新所需的I/O。

  • O_TRUNC:如果此文件存在,且为只写或者读写打开,则将其长度截断为0.

  • O_TTY_INIT:如果打开一个还未打开的终端设备,设置非标准的termios参数值,使其符合Single UNIX Specification。

  • O_DSYNC:使每次write要等待物理I/O操作完成,但是如果该写操作并不影响读取刚写入的数据,则不需要文件属性被更新。

  • O_RSYNC:使每一个以文件描述符作为参数的read操作等待,直到所有对文件同一部分挂起的写操作都完成。

openopenat返回的文件描述符一定是最小的未使用的文件描述符数值。
fd参数把openopenat函数区分开,共有三种可能性:
(1) path参数指定的是绝对路径名,在这种情况下,fd参数被忽略,openat函数就相当于open函数。
(2) path参数指定的是相对路径名,fd参数指出了相对路径名在文件系统中的开始地址。fd参数是通过打开相对路径名所在的目录来获取的。
(3) path参数指定相对路径名,fd参数具有特殊值AT_FDCWD。在这种情况下,路径名在当前工作目录中获取,openat函数在操作上与open类似。
openat函数是POSIX.1最新版本中新增的一类函数之一,希望解决两个问题,一,让线程可以以相对路径名打开目录中的文件,而不再只能打开当前工作目录。在第11章中我们会看到,同一进程中的所有线程共享相同的当前工作目录,因此很难让同一进程的多个不同的线程在同一时间工作在不同的目录中。第二,可以避免time-of-check-to-time-of-use(TOCTTOU)的错误。
TOCTTOU错误的基本思想是:如果有两个基于文件的函数调用,其中第二个调用依赖于第一个调用的结果,那么程序是脆弱的,因为两个调用并不是原子操作,在两个函数调用之间文件可能改变了,这样也就造成了第一个函数调用的结果不再有效,使得程序最终的结果是错误的。

文件名和路径名截断

函数creat

也可调用creat函数创建一个新文件。

#include 
int creat(const char *path, mode_t mode);
// 返回值:若成功,返回为只写打开的文件描述符;若出错,返回-1

注意:此函数等效于:

open(path, O_WRONLY|O_CREAT|O_TRUNC, mode);
open(path, O_RDWR|O_CREAT|O_TRUNC, mode);

函数close

#include 
int close(int fd);
// 若成功,返回0;若出错,返回-1;

关闭一个文件时还会释放该进程加在该文件上的所有记录锁。
当一个进程终止时,内核会自动关闭它所有的打开文件。

函数lseek

每个打开文件都有一个与其相关联的“当前文件偏移量”。它通常是一个非负整数,用以度量从文件开始处计算的字节数(本节稍后将对“非负”这一修饰词的某些例外进行说明)。通常,读,写操作都是从当前文件的偏移量处开始,并使偏移量增加所读写的字节数。按系统默认的情况,当打开一个文件时,除非指定O_APPEND选项,否则该偏移量被设置为0。
可以调用lseek显式地为一个打开文件设置偏移量。

off_t lseek(int fd, off_t offset, int whence);
//返回值:若成功,返回新的文件偏移量;若出错,返回-1

对参数offset的解释与参数whence有关。

  • 若参数offset是SEEK_SET,则将该文件的偏移量设置为距文件开始处的offset个字节。
  • 若参数offset是SEEK_CUR,则将该文件的偏移量设置为当前值加offset,offset可为正负。
  • 若参数offset是SEEK_END,则将该文件的偏移量设置为文件长度加offset,offset可为正负。

这种方法也可以用来确定所涉及的文件是否可以设置偏移量。如果文件描述符指向的是一个通道、FIFO或者网络套接字,则lseek返回-1,并将errno设置为ESPIPE。

通常,文件的偏移量是一个非负整数,但是,某些设备也可能允许负的偏移量。但对于普通文件,其偏移值必须为非负值,所以在返回lseek的返回值时应当谨慎,不要测试他是否小于0,而要测试他是否等于-1;。
lseek将当前的文件偏移量记录在内核中,他不引起任何的IO操作。然后,该偏移量用于下一个读或者写操作。
文件的偏移量可以大与文件的当前长度,在这种情况下,对文件的下一次写将加长该文件,并在文件中形成一个空洞,这一点是允许的,位于文件中但没有写过的字节都被读为0。
文件中的空洞并不要求在磁盘上占有存储空间。具体的处理方式与文件系统的实现有关。,当定位超过文件尾端之后写的,对新写的数据需要分配磁盘块,但是对于原文件尾端和新开始写位置之间的部分不需要分配磁盘块。

函数read

#include 
ssize_t read(int fd, void *buf, size_t nbytes);
// 返回值:读到的字节数,若已到文件尾,返回0;若出错,返回-1;

read成功,则返回读到的字节数。若已到达文件的尾端,则返回0。
有多种情况可使实际读到的字节数少与要求读的字节数:

  • 读普通文件时,在读到要求字节数之前已到达文件尾端。
  • 当从设备终端读时,通常一次最多读一行。
  • 当从网络读时,网络中的缓冲机制可能造成返回值小于所要求读的字节数。
  • 当从管道或FIFO读时,如若管道包含的字节少于所需的数量,那么read将只返回实际可用的字节数。
  • 当从某些面向记录的设备(如磁带)读时,一次最多返回一个记录。
  • 当一信号造成中断,而已经读了部分数据时。

读操作是从文件的当前偏移偏移量处开始,在成功返回之前,该偏移量将增加实际读到的字节数。POSIX.1从几个方面对read函数原型做了更改,经典的原型定义是:
int read(int fd, char *buf, unsigned nbytes);

  • 首先,为了与ISO C一致,第二个参数有char *改为void *。在ISO C中,类型void *用于表示通用指针。
  • 其次,返回值必须是以个带符号的整形(ssize_t),以保证能够返回正整数字节数、0(表示文件尾端)或者-1(出错)。
  • 最后,第三个参数历史上是一个无符号整型,这允许一个16位的实现一次读或写的数据可以多达65534字节。

函数write

调用write函数向打开文件写数据。

#include 
ssize_t write(int fd, const void *buf, size_t nbytes);
// 返回值:若成功,返回已写字节数;若出错,返回-1;

其返回值通常与参数nbytes的值相同,否则表示出错。write出错的一个常见原因是磁盘已写满,或者超过一个给定进程的文件长度限制。
对于普通文件,写操作从文件的当前偏移量处开始。如果在打开该文件时,指定了O_APPEND选项,则每次写操作之前,将文件偏移量设置在文件的当前结尾处。在一次成功写之后,将文件的偏移量增加实际写的字节数。

I/O效率

预读/高速缓存

文件共享

UNIX系统支持在不同进程中共享打开文件。在介绍dup函数之前,先要说明这种共享。为此先介绍内核用于所有I/O的数据结构。
内核使用三种数据结构表示打开文件。
(1) 进程的进程表项中都有一个记录项,这个记录项中包含一张打开的文件描述符表,每个描述符占一项。与每个描述符想关联的是:
a. 文件描述符标志(close_on_exec)。
b. 指向一个文件表项的指针。
(2) 内核为所有打开文件维持一张文件表。每个文件表包含:
a. 文件状态标志(读,写,添写,同步和非阻塞等)
b. 当前文件的偏移量。
c. 指向该文件v节点表项的指针。
(3) 每个打开的文件(设备)都有一个v节点(v-node)结构。v节点包含了文件类型和对此文件进行各种操作函数的指针。对于大多数文件,v节点还包含了该文件的i节点(i-node,索引节点)。这些信息是在打开文件时从磁盘上读入内存的,所以,文件的所有相关信息都是随时可用的。例如,i节点包含了文件的所有者,文件长度,指向文件实际数据块在磁盘上所在位置的指针等。

虚拟文件系统(Virtual File System),把与文件系统无关的i节点部分称为v节点。目的是对在一个计算机系统上的多文件系统类型提供支持。

假定第一个进程在文件描述符3上打开该文件,而另一个进程在文件描述符4上打开该文件。打开该文件的每个进程都获得各自的一个文件表项,但对一个给定的文件只有一个v节点表项。之所以每个进程都获得自己的文件表项,是因为每个进程操作文件的方式不同。

  • 在完成每个write后,在文件表项中的当前文件偏移量增加所写入的字节数。如果这导致当前文件偏移量,超过当前文件长度,则将i节点表项中的当前文件长度设置为当前文件偏移量(在内存中改写,未写入磁盘)。
  • 如果用O_APPEDN标志打开一个文件,则相应标志也被设置到文件表项的文件状态标志中。每次对于这种具有追加标志的文件执行写操作时,文件表项中的当前文件偏移量首先会被设置成i节点表项中的文件长度。这就使得每次写入的数据都追加到文件的当前尾端处。
  • 若一个文件用lseek定位到文件当前的尾端,则文件表项中的当前文件偏移量被设置成i节点表项中的当前文件长度。
  • lseek函数只修改文件表项中的当前文件偏移量,不进行任何的I/O操作。
    可能有多个文件描述符指向同一个文件表项。在使用dup函数时,我们就能看到这一点。在fork后,也发生相同的情况,此时父进程、子进程各自的每一个打开的文件描述符共享同一个文件表项。

说明进程表项在进程所占内存中,由进程管理。文件表项不再进程所占内存中,由内核管理?错误
注意文件描述符标志和文件状态标志在作用范围方面的区别,前者只用于一个进程的一个描述符,后真则应用于指向该文件表项的任何进程中的所有描述符。
多个进程读取同一个文件能正确工作。每个进程都有它自己的文件表项,也有它自己的当前文件偏移量。但是,当多个进程同时写一个文件时,则可能产生意想不到的结果。
每个进程对于同一个文件仅仅持有一个文件表项?错误

原子操作

任何要求多于一个函数调用的操作都不是原子操作,因为在两个函数调用之间,内核可能会临时挂起进程,在此期间,前一个函数调用的结果可能已经被修改或无效。
一般而言,原子操作(atomic operation)值的是由多步组成的一个操作,如果该操作原子的执行,则要么执行完所有步骤,要么一步也不执行,不可能只执行一个步骤的子集。

函数dupdup2

#include 
int dup(int fd);
int dup2(int fd, int fd2);
// 两函数的返回值:若成功,则反回新的文件描述符;若出错,返回-1

dup返回的文件描述符一定是当前可用的文件描述符中的最小数值。对于dup2,可以用fd2参数指定新描述符的值。如果fd2已经打开,则先将其关闭。若fd等于fd2,则dup2返回fd2,而不关闭它。否则,fd2FD_CLOEXEC文件描述符标志就被清除,这样fd2在进程调用exec时是打开状态。
这些函数返回的新文件描述符与参数fd共享同一个文件表项。
我们假定进程启动时执行了

newfd = dup(1);

当函数开始执行时,假定下一个可用的文件描述符是3。因为两个文件描述符指向同一个文件表项,所以它们共享同一个文件状态标志(读,写,追加等)以及同一个当前文件偏移量。
每个文件描述符都有它自己的一套文件描述符标志。正如我们将在下一节中说明的那样,新描述符的执行时关闭(close-on-exec)标志总是由dup函数清除。
复制一个文件描述符的另一种方法是使用fcntl函数。实际上,调用

dup(fd);

等效于

fcntl(fd, F_DUPFD, 0);

而调用

dup2(fd, fd2);

等效于

close(fd2);
fcntl(fd, F_DUPFD, fd2);

在后一种情况,dup2并不完全等同于close加上fcntl。它们之间的具体区别如下。
(1) dup2是一个原子操作,而closefcntl包括两个函数调用。有可能在closefcntl之间调用了信号捕获函数,它可能修改文件描述符。如果不同的线程改变了文件描述符的话,也会出现相同的问题。
(2) dup2fcntl有一些不同的errno

函数syncfsyncfdatasync

传统的UNIX系统实现在内核中设有缓冲区高速缓存页高速缓存,大多数的磁盘I/O都通过缓冲区进行。当我们向文件写入数据时,内核通常先将数据复制到缓冲区中,然后排入队列,晚些时候再写入磁盘。这种方式被称为延迟写(delayed write)。
通常,当内核需要重用缓冲区来存放其他磁盘块数据时,它会把所有延迟写数据块写入磁盘。为了保证磁盘上实际文件系统与缓冲区中内容的一致性,UNIX 系统提供了syncfsyncfdatasync

#include 
int fsync(int fd);
int fdatasync(int fd);
// 若成功,返回0,若出错,返回-1
void sync(void);

sync只是将所有修改过的块缓冲区排入写队列,然后就返回,它并不等待实际写磁盘的操作结束。
通常,称为update的系统守护进程周期性的调用(一般每隔30秒)sync函数。这就保证了定期冲洗(flush)内核的块缓冲区。命令sync(1)也调用sync函数。
fsync函数只对文件描述符fd指定的一个文件起作用,并且等待写磁盘操作结束才返回。fsync可用于数据库这样的应用程序,这种应用程序需要确保修改过的块立即写道磁盘上,
fdatasync函数类似与fsync,但它只影响文件的数据部分。而除数据外,fsync还会更新文件的属性。

Q: 为什么更新文件数据和更新文件属性异步?

函数fcntl

fcntl函数可以改变已经打开文件的属性。

#include 
int fcntl(int fd, int cmd, .../* int arg */);
// 返回值:若成功,则依赖于cmd;若出错,返回-1

在本节的各实例中,第三个参数总是一个整数,与上面所示的函数原型中的注释部分对应。但是在14.3节说明记录锁时,第三个参数则是指向一个结构的指针。
fcntl函数有以下5种功能。
(1) 复制一个已有的描述符(cmd = F_DUPFD或F_DUPFD_CLOEXEC)。
(2) 获取/设置文件描述符标志(cmd = F_GETFD 或 F_SETFD)。
(3) 获取/设置文件状态标志(cmd = F_GETFL 或 F_SETFL)。
(4) 获取/设置异步I/O所有权(cmd = F_GETOWN 或 F_SETOWN)。
(5) 获取/设置记录锁(cmd = F_GETLK、F_SETLK 或 F_SETLKW)。
这里将讨论进程表项中各文件描述符相关的文件描述符标志和文件表项中的文件状态标志。

  • F_DUPFD:复制文件描述符fd。新文件描述符作为函数值返回,它是尚未打开的各描述符中大于或等于第三个参数值(取为整型值)中各值的最小值。新描述符与fd共享同一个文件表项。但是,新描述符有它自己的一套文件描述符标志,其FD_CLOEXEC文件描述符标志已被清除(这表示该描述符在exec时仍保持有效)。
  • F_DUPFD_CLOEXEC:复制文件描述符,设置与新文件描述符关联的F_CLOEXEC文件描述符标志的值,返回新文件描述符。
  • F_GETFD:对应与fd的文件描述符标志值作为函数值返回。
  • F_SETFD:设置文件描述符fd的文件描述符标志。新标志值按第三个参数设置。
  • F_GETFL:对应于fd的文件表项的文件状态标志作为函数值返回。我们在说明open函数时,已描述了文件状态标志:
    • O_RDONLY 只读打开
    • O_WRONLY
    • O_RDWR
    • O_EXEC 只执行打开
    • O_SEARCH 只搜索打开目录
    • O_APPEND 追加打开
    • O_NONBLOCK 非阻塞模式
    • O_SYNC 等待写完成(数据和属性)
    • O_DSYNC 等待写完成,仅数据
    • O_RSYNC 同步读和写
  • O_SETFL:将文件状态标志设置为第三个参数的值。可以更改的时O_APPEND之后的文件状态标志。
  • F_GETOWN:获取当前接收SIGIOSIGUGR信号的进程ID或进程组ID。
  • F_SETOWN:设置接收SIGIOSIGURG信号的进程ID或进程组ID。

fcntl的返回值于命令有关。如果出错,所有命令返回-1,如果成功则返回某个其他值。
下列4个命令有特定的返回值:F_DUPFDF_GETFDF_GETFL以及F_GETOWN。第一个命令返回新的文件描述符,第二和第三个文件返回相应的标志,最后一个命令返回一个正的进程ID或负的进程组ID。
在修改文件描述符标志或者文件状态标志时必须谨慎,先要获取现在的标志值,然后按照期望修改它,最后设置新标志值。不能只是执行F_SETFD或者F_SETFL命令,这样会关闭以前设置的标志位。
开启同步写标志,将会使每次写操作都会阻塞,等待数据写入磁盘后,再返回。在UNIX系统中,通常write只是将数据排入队列,而实际的写磁盘操作则可能在以后的某个时刻进行(写入高速缓存->推入写队列)。而数据库系统则需要使用O_SYNC,这样依赖,当他从write返回时就知道数据已经确实的写到了磁盘上,以免在系统异常时丢失数据。

  • 在写磁盘文件时,系统时间增加了,因为内核从进程中复制数据,并将数据排入延迟写队列中,以便磁盘驱动器将其写入到磁盘中。
  • 在向某个文件写入新数据时,操作系统可能已经以前写入的数据都冲洗到了磁盘上了,所以在调用fcntl函数时只需要做很少的工作。

函数ioctl

除上述外,其他I/O操作。

#include 
#include 

int ioctl(int fd, int request, ...);
// 返回值:若出错,返回-1;若成功,返回其他值

/dev/fd

文件描述符被映射成指向底层物理文件的符号链接。

习题:

  1. 当读/写磁盘文件时,本章中描述的函数确实是不带缓冲机制的吗?请说明原因。

如果一个程序要从磁盘或网卡提取数据,那么CPU会发出一个指令通知磁盘和网卡,之后将进程阻塞,切换到其他进程,磁盘和网卡数据抵达内存的时候,会发出中断提醒CPU切回来。将进程阻塞,等待缓慢的I/O传送延迟时处理其他任务,依赖的是中断。
但是内存不是I/O,也不能用I/O来称呼内存,内存又叫主存储器,在冯氏体系中是独立于I/O的部分,当CPU执行一个进程时,从内存中该进程所在的空间连续不断的指令,也就是说,写程序和执行程序的时候,是不考虑什么内存延迟的。
内存延迟的概念出现在CPU主频大幅提升的时代,这时候物理上的CPU已经比内存快了很多,也就是说取指令会有延迟,但是CPU无法解决内存延迟,因为所有进程都在内存中,切哪一个都得等,不像I/O,它们比内存还要慢的多,在内存里切换进程等时间等时间对等待I/O时间而言是可以忽略不计的。所以对于内存延迟,CPU唯一的办法就是等待内存响应。没有其他办法。
为了进一步提高性能,减少内存等待时间,CPU发展出来复杂的高速缓存系统,从内存预先猜测哪些指令可能会被执行,预先将他们提取到速度与CPU差不多的高速缓存中存起来。高速缓存不命中,才会到内存中进行读取。
大多数文件系统为了改善性能都采用某种预读的方式,当检测到正在顺序读取时,就系统就试图读入比应用要求的更多的数据,并假想应用很快会读取到这些数据(减少物理I/O次数,尽量使磁盘读取,和内存读取速度相匹配)。
操作系统试图用高速缓存技术将相关文件放到主存中,重复读写同一文件,一般会从高速缓存中读写文件。
我们向文件写入数据时,内核通常将数据写入高速缓存中,再排入物理I/O写队列中,晚些时候再写入磁盘。

  1. 编写一个与dup2功能相同的函数,要求不调用fcntl函数,并要有正确的出错处理。

dup2(fd, new_fd);new_fdfd指向相同的文件表项。dup函数总是返回当前可用的最小的文件描述符。

  1. 假设一个进程执行下面3个函数调用:
fd1 = open(path, oflags);
fd2 = dup(fd1);
fd3 = open(path, oflags);

fd1,fd2,fd3分别文进程表中的一项文件描述符项,fd1和fd2指向相同的文件表项,fd3指向自己的文件表项,fd1,fd2,fd3的文件表项指向相同的文件v结点。
文件描述符项主要包含文件描述符,文件描述符标志,文件表项指针。文件表项主要包含文件状态标志,当前文件偏移量和文件i结点指针。文件的v结点主要包含文件属性,文件数据。

  1. 如果使用追加标志打开一个文件以便读,写,能否仍用lseek在任一位置开始读?能否用lseek更新文件中的任一部分数据?

能,不能

你可能感兴趣的:(unix,io)