讨论关于并发环境下,多个进程对同一文件写入的问题,我们会涉及到文件共享的知识。在开始之前,我们先讨论一些有关文件共享的知识。
1. 文件共享
Unix系统支持在不同进程间共享打开的文件。为此,我们先介绍一下内核用于所有I/O的数据结构。注意,下面的说明是概念性的,与特定的实现可能匹配,也可能不匹配。
内核使用三种数据结构表示打开的文件,它们之间的关系决定了在文件共享方面一个进程对另一个进程可能产生的影响。
(1) 每个进程在进程表中都有一个记录项,记录项中包含有一张打开文件描述符表,可将其视为一个矢量,每个描述符占用一项。与每个文件描述符相关联的是:
(a) 文件描述符标识(close_on_exec)。
(b)指向一个文件表项的指针。
(2)内核为所有的打开文件维持一张文件表。每个文件表项包含:
(a)文件状态标志(读、写、添加、同步和非阻塞等)。
(b)当前文件偏移量。
(c)指向该文件v节点的指针。
(3)每个打开文件(或设备)都有一个v节点(v-node)结构。v节点包含了文件类型和对此文件进行各种操作的函数的指针。对于大多数文件,v节点还包含了该文件的i节点(i-node,索引节点)。这些信息是在打开文件时从磁盘上读入内存的,所以所有关于文件的信息都是快速可供使用的。例如,i节点包含了文件的所有者,文件长度,文件所在的设备,指向文件实际数据块在磁盘上所在位置的指针等等。
注意:Linux没有使用v节点,而是使用了通用i节点结构。虽然两种实现有所不同,但在概念上,v节点与i节点是一样的。两者都指向文件系统特有的i节点结构。
我们忽略了默写实现细节,但这并不影响我们的讨论。例如,打开文件描述符表可存放在用户控件,而非进程表中。这些表也可以用于多种方式的实现,不必一定是数组;例如,可将它们实现为结构的连接表。这些细节并不影响我们在文件共享方面的讨论。
图1显示了一个进程的三张表之间的关系。该进程有两个不同的打开文件:一个文件打开为标注输入(文件描述符为0),另一个打开为标准输出(文件描述符为1)。从Unix系统的早期版本中[Thompson 1978]以来,这三张表之间的基本关系一直保持至今。这种安排对于在不同进程之间共享文件的方式非常重要。
图1 打开文件的内核数据结构
- 注意:创建v节点结构的目的是对在一个计算机系统上的多文件系统类型提供支持。这一工作是有Peter Weihberger(贝尔实验室)和Bill Joy(Sun公司)分别独立完成的。Sun称此种文件系统为虚拟文件系统(Virtual File System),称与文件系统类型无关的i节点部分为v节点[Kleiman 1986].当哥哥制造商的实现增加了对Sun的网络文件系统(NFS)的支持时,它们都广泛采用了v节点结构。在BSD系统中首先提供v节点的是4.3BSD Reno版本,其中加入了NFS。
- 在SVR4中,v节点代换了SVR3中与文件系统类型无关的i节点结构。Solaris是从SVR4发展而来的,他也是用了v节点。
- Linux没有将相关的数据结构分为i节点和v节点,而是采用了一个独立于文件系统的i节点和一个依赖于文件系统的i节点。
如果两个独立进程各自打开了同一个文件,则有图2中所示的安排。我们假设第一个进程在文件描述符3上打开该文件,而另一个进程则在文件描述4上打开该文件。打开该文件的每一个进程都得到一个文件表项,但对一个给定的文件只有一个v节点表项。每个进程都有自己的文件表项的一个理由是:这种安排使每一个进程都有它自己的对该文件的当前偏移量。
图2 两个独立进程各自打开同一个文件
给出了这种数据结构后,现在对前面所描述的操作做进一步说明。
- 在完成每个write后,在文件表项中的当前文件偏移量即增加所写的字节数。如果当前文件偏移量超过了当前文件长度,则在i节点表项中的当前文件长度被设置为当前文件的偏移量。
- 如果用O_APPEND标志打开了一个文件,则相应标志也被设置到文件表项的文件状态标志中。每次对这种具有添写标志的文件执行写操作时,在文件表项中的当前文件偏移量首先被设置为i节点表项中的文件长度。这就使得每次写的数据都添加到文件的当前尾端处。
- 若一个文件用lseek定位到文件当前的尾端,则文件表项中的当前文件偏移量被设置为i节点表项中的当前文件长度。注意,这与用O_APPEND标志打开文件是不同的。
- sleek函数只修改文件表项中的当前文件偏移量,没有进行任何文件I/O操作。
可能有多个文件描述符指向同一个文件表项。在下一小节中讨论dup函数时,我们将能看见这一点。函数调用fork后产生的父子进程中,它们共享相同的i或v节点和同一个文件表项。(通过在现代linux系统中进行测试得到的,测试程序和结构见下文(更正:测试结果分析有误!已更正!))。
注意,文件描述符标志和文件状态标志在作用域方面的区别,前者只用于一个进程的一个描述符,而后者适用于指向该给定文件表项的任何进程中的所有描述符。上面所述的一切对多个进程读同一个文件都能正确工作。每个进程都有它自己的文件表项,其中也有它自己的当前文件偏移量。但是,当多个进程写同一个文件时,则可能产生预期不到得结果。为了说明如何避免这种情况,我们需要理解原子操作的概念。
2. 原子操作
2.1 添写至一个文件
考虑一个进程,它要将数据添加到一个文件尾端。早期的UNIX系统版本并不支持open的O_APPEND选项,所以程序被编写成下列形式:
- if (lseek(fd, 0L, 2) < 0) /* position to EOF */
- err_sys("lseek error");
- if (write(fd, buf, 100) != 100) /* and write */
- err_sys("write error");
对单个进程而言,这段程序能正常工作,但若对多个进程同时使用这种方法将数据添加到同一文件,则会产生问题。(例如,若此程序由多个进程同时执行,各自将消息添加到一个日志文件中,就会产生这种情况。)
假定有两个独立的进程A和B都对同一个文件进行操作,给个进程都已打开了该文件,但未使用O_APPEND标志。此时,各数据结构之间的关系如图2所示。每个进程都有自己的文件表项,但是共享一个v节点表项。假定进程A调用了lseek,它将进程A的该文件当前偏移量设置为1500字节(当前文件尾端处)。然后内核调度进程使进程B运行。进程B执行sleek,也将其对该文件的当前偏移量设置为1500字节(当前文件尾端处)。然后B调用write函数,它将B的该文件当前文件偏移量增值1600.引文该文件的长度已经增加了,所以内核对v节点中的当前文件长度更新为1600.然后,内核又进行进程切换使进程A恢复运行。当A调用write时,就从其当前文件偏移量(1500字节)处将数据写到文件中去。这样就代换了进程B刚写到该文件中的数据。
问题出在逻辑操作“定位到文件尾端处,然后写”上,它使用了两个分开的函数调用。解决问题的方法是使这两个操作对于其他进程而言成为一个原子操作。任何一个需要多个函数调用的操作都不可能是原子操作,因为在两个函数调用之间,内核有可能会临时挂起该进程。
UNIX系统提供了一种方法是这种操作成为原子操作,该方法是在打开文件时设置O_APPEND标志。正如前面所述,这就是内核每次对这种文件进行写之前,都将进程的当前偏移量设置到文件的尾端处,于是在每次写之前就不在需要调用sleek了。
2.2 pread和pwrite函数
Single UNIX Specification包括了XSI扩展,该扩展允许原子性地定位搜索(seek)和执行I(/O。pread和pwrite就是这种扩展。
- #include <unistd.h>
-
- ssize_t pread(int filedes, void *buf, size_t nbytes, off_t offset);
- 返回值:读到的字节数,若已到文件结尾则返回0,若出现错误返回-1
- ssize_t pwrite(int filedes, const void *buf, size_t nbytes, off_t offset);
- 返回值:若成功则返回已写的字节数,若出错则返回-1
调用pread相当于顺序调用lseek和read,但是pread又与这种顺序调用有下列重要区别:
- 调用pread时,无法中断其定位和读操作。
- 不更新文件指针。
调用pwrite相当于顺序调用lseek和write,但也和它们有类似的区别。
2.3 创建一个文件
在对open函数的O_CREAT和O_EXCL选项进行说明时,我们已经见到另一个有关原子操作的例子。当同时指定这两个选项,而该文件又已经存在时,open将失败。我们曾提及检查该文件是否存在以及创建该文件这两个操作是作为一个原子操作执行的。如果没有这样一个原子操作,那么可能会编写下面的程序段:
- if ((fd = open(pathname, O_WRONLY)) < 0) {
- if (errno == ENOENT) {
- if ((fd = creat(pathname, mode)) < 0)
- err_sys("create error");
- } else {
- err_sys("open error");
- }
- }
如果在open和creat之间,另一个进城创建了该文件,那么就会引起问题。例如,若在这两个函数调用之间。另一个进程创建了该文件,并且写进了一些数据,然后,原先的进程执行这段程序中的creat,这时,刚由另一个进程写上去的数据就会被擦除掉。如若将这两者合并在一个原子操作中,这种问题就不会存在了。
一般而言,原子操作(atomic operation)指的是由多步组成的操作,如果该操作原子地执行,则要么执行完所有步骤,要么一步也不执行,不可能只执行所有步骤的一个子集。
3. dup和dup2函数
下面两个函数都可用来复制一个现存的文件描述符:
- #include <unistd.h>
-
- int dup(int filedes);
- int dup2(int filedes, int filedes2);
- 两函数的返回值:若成功则返回新的文件描述符,若出错则返回-1
由dup返回的新文件符一定是当前可用文件描述符中的最小数值。用dup2则可以用filedes2参数指定新描述符的数值。如果filedes2已经打开,则先将其关闭。如若filedes等于filedes2,则dup2返回filedes2,而不关闭它。
这些函数返回的新文件描述符与参数参数filesdes共享同一个文件表项。如图3所示。
图3 执行dup之后的内核数据结构
在图3中,我们假定进程执行了:
- newfd = dup(1);
当此函数开始执行时,假定下一个可用的文件描述是3(这是非常有可能的,因为0、1、2是由shell打开的)。因为两个描述符指向同一文件表项,所以它们共享同一个文件状态标志(读、写、添加等)以及同一文件当前偏移量。
每个文件描述符都有它自己的一套文件描述符标志。新描述的执行时关闭(close-on-exec)标志总是由dup函数清除。
复制一个描述符的另一种方法是使用fcntl函数,实际上,调用
- dup(filedes);
等效于
- dup2(filedes, F_DUPFD, 0);
而调用
- dup2(filedes, filedes2);
等效于
- close(filedes2);
- fcntl(filedes, F_DUPFD, filedes2);
在后一种情况下,dup2并不完全等同于close()加上fcntl.它们之间的区别是:
- dup2是一个原子操作,而close及fcntl则包含两个函数调用。有可能在close和fcntl之间插入执行信号捕获函数,它可能修改文件描述符。
- dup2和fcntl有某些不同的errno。
4. 测试结果
带有O_APPEND标志的测试代码和结果数据如下: o_append_text.rar
没有带O_APPEND标志的测试代码和结果数据如下: test.rar
可以看到,o_append_text.rar里面的测试结果数据文件大小是test.rar里面的2倍。分析其原因,是因为函数调用fork时,在父子进程中没有为他们采取必要的同步措施,因此在写文件时有竞争发生,导致结果混乱。另外,文件大小的不同是因为,文件表项中的文件偏移量的值类型并不是一个易失形变量类型,从而导致在写文件时读取的偏移值变量的值不是最新的值,从而导致文件大小会不同的结果。
另外,从结果数据中(可能数据没有充分表现出如下所说的情况,但是你可以通过调整测试程序里的参数,并多运行几次测试程序就可以得到如下所述的结果)可以得出:当以O_APPEND标志打开文件时,write将执行原子操作,read亦然。而没有使用O_APPEND标志打开文件时,父子进程的数据输出将出现乱序的情况。