原子操作和竞争条件
所有系统调用都是以原子操作方式执行的。内核保证了某系统调用中的所有步骤会作为独立操作而一次性加以执行,期间不会被其他进程或线程中断。原因是它规避了竞争状态(操作共享资源的两个进程的结果取决于CPU的先后执行顺序)。
举两个例子
一. 以独占的方式创建一个文件
对于open()创建文件时,当同时指定O_EXCL与O_CREAT作为标志位时,如果文件存在则open()将返回一个错误。这提供了一种机制,保证进程是打开文件的创建者,对文件是否存在的检查和创建文件属于同一原子操作。
假设当不以独占的方式打开文件时,会发生什么情况?
可以看到,若内核调度器判断出分配给A进程的时间片已经耗尽,并将CPU使用权交给B进程,B进程创建完后时间片耗尽CPU使用权又转交给了A进程,在这一场景下,A进程会错误的以为目标文件是由自己创建的,因为无论目标文件存在与否,进程A对open()的第二次调用都会成功。
由于第一个进程在检查文件是否存在和创建文件之间发生了中断,造成两个进程都声称自己是文件的创建者。结合O_EXCL和O_CREAT标志来一次性地调用open()可以防止这种情况,这确保了检查文件和创建文件的步骤属于一个单一的原子操作。
二. 向文件尾部追加数据
当多个进程同时向一个文件尾部添加数据时,如果第一个进程执行到lessk()和write()之间,被执行相同代码的第二个程序所中断,那么这两个程序会在写入数据前,将文件偏移量设为相同的位置,而当第一个进程再次获得调度时,会覆盖第二个进程所写入的数据,此时再次出现了竞争状态。
为了规避这一问题,O_APPEND标志可以将文件偏移量的移动与数据写入操作纳入同一原子操作。
文件控制
1. fcntl()
fcntl()的用途之一是针对一个打开的文件获取或修改其访问模式和状态标志。要获取这些设置,应将fcntl的cmd参数设置为F_GETFL。
例如
int flags;
flags = fcntl(fd, F_GETFL); //第三个参数不是必须
对于上述代码得到的flags,可以用来测试文件是否以同步写方式打开。
if(flags & O_SYNC){
//是同步写
}
要判定文件访问模式,需使用文件掩码O_ACCMODE与flags相与,将结果与三个常量进行对比。
accessMode = flags & O_ACCMDOE;
if(accessMode == O_WRONLY || accessMode == O_RDWR){
//文件可写
}
而fcntl()也可以用F_SETFL命令来修改打开文件的某些标志状态。允许修改的标志状态有:O_APPEND、O_NONBLOCK、O_NOATIME、O_ASYNC、O_DIRECT。
int flags;
flags = fcntl(fd, F_GETFL);
flags |= O_APPEND;
fcntl(fd, F_SETFL, flags);
使用fcntl()修改文件标志状态,尤其适用于以下场景:
- 文件不是由调用程序打开的,所以程序也无法使用open()调用来控制文件的状态标志。(例如标准输入输出描述符在程序启动之前就被打开)
- 文件描述符的获取是通过open()之外的系统调用。(pipe()、socket())
2. 文件描述符
首先来看内核维护的三个数据结构:
- 进程级的文件描述符表
- 系统级的打开文件表
- 文件系统的i-node表
针对每个进程,内核为其维护打开文件的描述符表,该表的每一条目都记录了单个文件描述符的相关信息。
- 控制文件描述符操作的一组标志。
- 对打开文件句柄的引用。
内核对所有打开的文件维护有一个系统级的描述表格,也称为打开文件表,并将表中的各个条目称为打开文件句柄。一个打开文件句柄存储了与一个打开文件的全部消息。
- 当前文件偏移量
- 打开文件时所使用的状态标志(open()的flags参数)
- 文件访问模式
- 与信号驱动IO相关的设置
- 对该文件i-node对象的引用
每个文件系统都会为驻留其上的所有文件建立一个i-node表,具体信息如下。
- 文件类型和访问权限
- 一个指针,指向该文件所持有的锁的列表
- 文件的各种属性,包括文件大小以及与不同类型操作相关的时间戳(此处忽略i-node在磁盘和内存中的表示差异,磁盘上的i-node记录了文件的固有属性,比如说文件类型、访问权限、时间戳等)
下面说明文件描述符、打开的文件句柄和i-node之间的关系
在上图中,两个进程拥有诸多打开的文件描述符。
在进程A中,文件描述符1和30都指向同一个打开的文件句柄,这可能是通过调用了dup()或fcntl()形成的。
进程A的文件描述符2和进程B的文件描述符2都指向同一个打开的文件句柄,这种情形可能是通过调用fork()后出现的。
此外,进程A的描述符0和进程B的描述符3分别指向不同的打开的文件句柄,但是这些句柄都指向i-node表中的统一条目,也就是指向同一文件。发生这种情况是因为每个进程各自对同一文件发起了open()调用。同一个进程两次打开同一文件,也会发生类型情况。
上述讨论揭示出:
- 两个不同的文件描述符,若指向同一打开文件句柄,将共享同一文件偏移量。因此,如果通过其中一个文件描述符来修改文件偏移量,那么从另一文件描述符中,也会发生相应变化,无论这两个文件描述符是不是属于同一个进程。
- 要获取和修改打开的文件标志,可以执行fcntl()的F_GETFL和F_SETFL操作。
- 文件描述符标志(close-on-exec)为进程和文件描述符所私有,对这一标志的修改将不会影响同一进程或不同进程中的其他文件描述符。
有时候,我们需要对文件描述符进行复制,可以通过调用dup()等函数实现此功能。
假设发起如下调用:
newfd = dup(1);
再假设在正常情况下,shell已经替程序打开了文件描述符0、1、2,且没有其他描述符在同,dup()调用会创建文件描述符1的副本,返回的文件描述符编号值为3。
如果想获得所期望的文件描述符,可以调用dup2(),dup2()系统调用会为oldfd参数所指定的文件描述符创建副本,其编号由newfd参数指定。
(如果newfd所指定的文件描述符已经打开,那么dup2()会首先将其关闭,但是会忽略关闭期间的出现的任何错误)
调用成功函数返回newfd。dup3()系统调用完成的工作与dup2()相同,只是增加了一个flags位,这是一个可以更改系统调用行为的位掩码。
(目前只支持O_CLOEXEC标志,此标志促使内核为新文件描述符设置close-on-exec标志)
fcntl()的F_DUPFD操作是复制文件描述符的另一接口,更具有灵活性。
newfd = fcntl(oldfd, F_DIPFD, startfd);
该调用为oldfd创建一个副本,且将大于等于startfd的最小未用值作为描述符编号。
文件描述符的正副本之间共享同一打开文件句柄所含的文件偏移量和状态标志。然而,新文件有自己的一套文件描述符标志,且其close-on-exec标志总属于关闭状态(可以使用dup3()设置)
文件操作
1. read&write
系统调用pread()和pwrite()可以在offset参数指定位置进行文件IO操作,对于它们而言,fd所指代的文件必须是可定位的。
pread()调用等同于将如下调用纳入同一原子操作
off_t orig;
orig = lseek(fd, 0, SEEK_CUR);
s = read(fd, buf, len);
lseek(fd, orig, SEEK_SET);
上面说到,进程下辖的所有线程将共享同一文件描述符表,当调用pread()或pwrite()时,多个线程可同时对一文件描述符执行IO操作,且不会因为其他线程修改文件偏移量而受到影响。
执行实际的IO要远大于执行系统调用,执行单个pread()或pwrite()系统的成本要低于执行lseek()和read()两个系统调用。
-
分散输入和集中输出:
readv()和writev()系统调用分别实现了分散输入和集中输出的功能,一次即可传输多个缓冲区的数据。
数组iov定义了一组用来传输数据的缓冲区。整型数iovcnt则指定了iov的成员个数,iov中的每个成员都是如下形式的数据结构。
struct iover{
void *iov_base;
size_t iov_len;
};
如下图说明iov、iovcnt以及iov指向缓冲区之间的关系
readv()系统调用实现了分散输入的功能,从文加年描述符fd所只代的文件中读取一片连续的字节,然后将其散置(“分散放置”)于iov指定的缓冲区中。
原子性是readv()的重要属性。当调用readv()时,在fd所值代的文件与用户内存之间一次性地完成了数据转移。
调用readv()成功返回字节数,文件结束返回0。
write()系统调用实现集中输出,将iov所指定的所有缓冲区中的数据拼接(“集中”)起来,然后以连续的的字节序列写入文件描述符fd所指代的文件中。
像readv()调用一样,writev()将所有数据一次性的从用户内存传输到fd所指代的文件中,而writev()也可能存在部分写的错误,所以一定要验证返回值。
同样,也可以在指定的文件偏移量处执行分散输入/集中输出preadv()与pwritev()。
2. 文件更改
truncate()和ftruncate()系统调用将文件大小设置为length参数指定的值。
若文件大小大于当前参数length,调用将丢弃超出部分,若小与参数length,调用将在文件尾部添加一系列空字节或是一个文件空洞。
两个系统调用之间的区别在于如何指定操作文件
truncate()以路径名字符串来指定文件,并要求可访问该文件(这两个系统调用不会影响文件的当前偏移量),且对文件拥有写权限。若文件名为符号链接,那么调用将对其进行解引用。
ftruncate()调用之前,需以可写方式打开才在以文件,获取其文件描述符以指代该文件。
(注意,truncate()系统调用不用打开文件就可以修改文件内容)
-
有些程序需要创建一些临时文件,仅供其在运行期间使用,程序中之后立即删除。
mkstemp()函数生成一个为以文件名并打开该文件,返回一个可用于IO调用的文件描述符。模版采用路径名形式,其中最后6个字符必须为XXXXXX,这6个字符将被替换,已保证文件名的唯一性,且修改后的字符串将通过temlate参数返回。
因为会对传入的template进行修改,所以必须将其指定为子字符数组,而非字符串常量
tmpfile()函数也会创建一个名称为以的临时文件,并以读写方式将其打开。
tmpfile()函数执行成功,将返回一个文件流供stdio库函数使用。