UNIX高级环境编程(2)FIle I/O - 原子操作、共享文件描述符和I/O控制函数

时间:2024-03-20 18:36:07

引言:

本篇通过对open函数的讨论,引入原子操作,多进程通信(共享文件描述符)和内核相关的数据结构。

还会讨论集中常见的文件IO控制函数,包括:

  • dup和dup2
  • sync,fsync和fdatasync
  • fcntl
  • ioctl
  • /dev/fd

一、文件共享

这里所说的文件共享主要指的是进程间共享打开的文件。

这一节主要讨论文件在进程间共享的理论基础和数据结构,不涉及具体的技术实现,不同的系统可能会有不同的实现。

每一个打开的文件,涉及内核中的三种数据结构,这三种数据结构也是文件在进程间共享的基础。

  • an entry in the process table: 每一个打开的文件描述符对应一个entry,entry中的内容包括文件描述符标志位(file descriptor flags)和一个指向file table entry的指针;
  • file table:内核为所有打开的文件维护一个file table。每一个file table entry包括有:文件状态标志位(file status flag, such as read, write, append, sync和nonblocking)。
  • v-node和i-node: 每一个开打的文件都有一个v-node结构体,包括文件类型,指向操作函数的指针。对于大部分的文件,v-node还包含一个i-node结构。i-node的内容为打开文件时从硬盘上读取的信息,包括文件所有者,文件大小,文件内容存储在磁盘上的具体位置等。

下图表明了这三种内核数据结构的关系:

UNIX高级环境编程(2)FIle I/O - 原子操作、共享文件描述符和I/O控制函数

v-node是打开的文件在进程间共享的关键数据结构。如下图所示,两个进程打开同一个文件时的数据结构关系:

UNIX高级环境编程(2)FIle I/O - 原子操作、共享文件描述符和I/O控制函数

上图中,第一个进程打开文件,文件描述符为3, 第二进程打开同一个文件,文件描述符为4。

两个进程都有自己的file table entry,因为每个进程都需要维护自己的当前文件偏移量(current file offset)。

但是,也有可能多个独立进程的文件描述符指向同一个file table entry。这种情况发生在调用dup方法和fork系统调用时,父进程和子进程共享同一个file table entry。

我们还需要区分文件描述符标志位(file descriptor flag)和文件状态标记位(file status flag)。前者只在当前的进程的该文件描述符有效,而后者对于所有进程指向该file table entry的文件描述符都有效。这两个标志位的控制由函数fcntl控制。

上面我们所讨论的主要是多进程读同一个文件时所涉及的原理和数据结构,那么当多个进程同时写一个文件时,又是如何保证一致性呢?由此引出了原子操作(atomic operation)的概念。

二、原子操作(Atomic Operation)

老的版本的write函数并不支持O_APPEND标志。因此,追加写模式的实现如下代码所示:

 if (lseek(fd, 0L, ) < )       /* position to EOF */
err_sys(“lseek error");
if (write(fd, buf, ) != ) /* and write */
err_sys(“write error");

在单进程环境下,这段代码当然可以正常工作。

但是当在多进程环境下,由于进程切换的发生,并且各个进程独有的当前文件偏移量(存在file table entry中)并不会随时更新,在lseek和write调用中间,进程A被切换到另外一个进程B,进而往文件追加写了一部分数据,导致进程A的当前文件尾偏移量实效,当切换回进程A进行写时,覆盖了进程B所写的内容。

问题的原因在于,得到文件结尾处和写操作是由两个独立的函数调用完成的。

问题的解决方案是使得两个操作组成一个原子操作。

比较新的内核提供的O_APPEND标志位,可以让write每一次进行写操作前定位到文件尾,不需要单独调用lseek函数。

pread和pwrite函数

函数声明:

 #include <unistd.h>
ssize_t pread (int fd, void *buf, size_t nbytes, off_t offset);
ssize_t pwrite (int fd, const void *buf, size_t nbytes, off_t offset);

函数返回值和read、write函数相同。

调用pread相当于先调用lseek然后调用read,需要注意的两点是:

  • pread是原子操作;
  • 当前文件偏移量并不会被更新。

调用pwrite相当于原子性地先调用lseek,然后调用write。

三、常用的IO控制函数

1 dup和dup2函数

复制文件描述符。

函数声明:

 #inlcude <unistd.h>
int dup (int fd);
int dup2 (int fd, int fd2) 

返回值:

  • 非负整数:新的文件描述符,OK
  • -1:Error

功能说明:

  • dup:函数返回新的文件描述符,并且保证该描述符是最小可用的描述符。
  • dup2:使用fd2作为新的文件描述符。如果fd2已经被打开,则先关闭fd2。如果fd等于fd2,那么dup2返回不关闭fd2,直接返回fd2。

新旧文件描述符共享file table entry,如下图所示:

 newfd = dup();

假设下一个最小可用fd为3,则复制完成后:

UNIX高级环境编程(2)FIle I/O - 原子操作、共享文件描述符和I/O控制函数

新旧文件描述符共享file table entry, file status flag和current file offset。

每一个文件描述符都有自己的文件描述符标志位(file descriptor flag)。

新的文件描述符的标志位(file descriptor flag)会被dup函数清空。

另一种复制文件描述符的方式,使用fcntl:

dup(fd);    // ==
fcntl(fd, F_DUPFD, );
dup(fd, fd2); // ==
close(fd2);
fcntl(fd, F_DUPFD, fd2); 

2 sync, fsync, and fdatasync函数

先介绍一个机制:延迟写(delay write).

当磁盘发生IO时,系统内核会维护一个buffer cache或page cache。当我们向一个文件写数据时,数据先会被拷贝到内核的buffers中,在队列中等待写入到磁盘。这个机制叫延迟写(delay write)。

当需要使用buffer时,内核会把所有延迟写的block写回到磁盘中。为了保证磁盘和buffer中数据的一致性,Unix提供了函数sync, fsync和fdatasync。

函数声明:

 #include <unistd.h>
int fsync(int fd);
int fdatasync(int fd);
void sync(void);

功能说明:

sync:将buffer中所有被修改的块(block)放入队列中,等待写入,并立刻返回,它并不等待数据落盘。该函数往往周期性(常常为30s)地被调用。

fsync:根据fd指定单个文件,并且等待数据完全落盘才返回。

fdatasync:仅指定文件的数据部分,其他和fsync功能相似。

3 fcntl函数

fcntl函数用于修改已经打开文件的属性。

函数声明:

#include <fcntl.h>
int fcnt (int fd, int cmd, … /* int arg */);

功能说明:

函数fcntl有五种不同的功能:

  1. 复制一个已打开的文件描述符(cmd = F_DUPFD or F_DUPFD_CLOEXEC);
  2. 获取或设置文件描述符标志位(cmd = F_GETFD or F_SETFD);
  3. 获取或设置文件状态描述符(cmd = F_GETFL or F_SETFL);
  4. 获取或设置异步IO所属权(cmd = F_GETOWN or F_SETOWN);
  5. 获取或设置记录锁(cmd = F_GETLK, F_SETLK, or F_SETLKW);

在这里并不赘述这些cmd11个取值的具体含义,需要的时候可以自行查询。

函数返回值:

fcntl的返回值由cmd的取值决定,所有cmd均以返回-1为错误,其他值为OK。

有四个cmd的返回值需要注意:

  • F_DUPFD:返回新的文件描述符;
  • F_GETFD,F_GETFL:返回对应的flag;
  • F_GETOWN:返回一个活动进程号或者挂起状态进程组号(a positive process ID or a negative process group ID?)。

Example 01:

程序功能:返回制定文件描述符的权限状态。

源码:

 #include "apue.h"
#include <fcntl.h>
int
main(int argc, char *argv[])
{
int val;
if (argc != )
err_quit("usage: a.out <descriptor#>");
if ((val = fcntl(atoi(argv[]), F_GETFL, )) < )
err_sys("fcntl error for fd %d", atoi(argv[]));
switch (val & O_ACCMODE) {
case O_RDONLY:
printf("read only");
break;
case O_WRONLY:
printf("write only");
break;
case O_RDWR:
printf("read write");
break;
default:
err_dump("unknown access mode");
}
if (val & O_APPEND)
printf(", append");
if (val & O_NONBLOCK)
printf(", nonblocking");
if (val & O_SYNC)
printf(", synchronous writes"); #if !defined(_POSIX_C_SOURCE) && defined(O_FSYNC) && (O_FSYNC != O_SYNC)
if (val & O_FSYNC)
printf(", synchronous writes");
#endif putchar('\n');
exit();
}
测试:

UNIX高级环境编程(2)FIle I/O - 原子操作、共享文件描述符和I/O控制函数

第三条命令的执行结果和书上并不完全相同,系统之间的差异。

Example 02:

如果我们要修改文件描述符标志位(flag)或状态标志位(status flag),我们必须先获取文件描述符的当前状态值,按期望修改之后,重新为文件描述符标志位赋值。

源码:

 #include "apue.h"
#include <fcntl.h>
void
set_fl(int fd, int flags) /* flags are file status flags to turn on */
{
int val;
if ((val = fcntl(fd, F_GETFL, )) < )
err_sys("fcntl F_GETFL error");
val |= flags; /* turn on flags */
if (fcntl(fd, F_SETFL, val) < )
err_sys("fcntl F_SETFL error");

4 ioctl函数

ioctl函数是对所有IO操作函数的统称。在后面的章节会有更详细的说明,这里只是简要介绍一下。

函数声明:

 #include <unistd.h>    /*  System V */
#include <sys/ioctl.h> /* BSD and Linux */
int ioctl( int fd, int request, ... );

简单来说,ioctl负责那些难以用前一篇介绍的基本io操作(read, write, lseek等)来定义和实现的io操作。

后面的章节我们会遇到ioctl函数的使用场景,这里先略过。

5 /dev/fd

/dev/fd是一个文件夹,其中的文件为0,1,2 …,每一个文件都代表一个文件描述符。

打开(open)一个文件n,就相当于复制该文件描述符n,前提是该文件描述符已经被打开。

函数调用:

 fd = open(“/dev/fd/”, mode);

相当于:

 fd = dup();

再介绍一个命令行中使用的符号 “ - ”,代表标准输入(standard input)。这是一个很拙劣的设计,所以现在使用/dev/fd/0来代替。所以下面两条命令的作用是相同的。

 ls /dir/ | cat b.cpp -
ls /dir/ | cat b.cpp /dev/fd/ 

四、小结

这一篇主要介绍了几个常用的IO控制函数:

  • dup和dup2
  • sync、fsync和fdatasync
  • fcntl
  • iocntl

我们还讨论了多进程场景下的对同一个文件的读写和原子操作的概念。

这些知识点在后面的章节中还会遇到。

参考资料:

《Advanced Programming in the UNIX Envinronment 3rd》