fork()和fopen()

最近在看IO相关内容,忽然想起几年前遇到的一个查了很久的bug。

当时上线了一个地图路况瓦片服务器,根据请求中的瓦片ID号,从一个静态数据库文件读取对应记录并下发。

服务器基于C++写的,在Linux系统上开启了4进程运行。

上线一段时间后,有人报告系统日志中发现了崩溃调用栈。崩溃的复现概率极低,也没有找到稳定的复现方法。

因为服务器框架实现了自动重启机制,客户端也有重试机制,这个bug影响倒是很轻微。

直到几个月后经同事提醒,查阅服务器初始化代码时,才发现了一些端倪:

各个子进程共享了同一个只读文件,而文件的fopen()是在用fork()创建子进程之前执行的。

如果将fork()和fopen()互换,问题就解决了。

两者的顺序为什么会有影响?

做具体分析前,我们可以将问题抽象为以下代码片段:(为了简化,错误处理和reap暂不考虑)

 1 #include <assert.h>
 2 #include <stdio.h>
 3 #include <string.h>
 4 #include <unistd.h>
 5 
 6 static void _init() {
 7     FILE *f = fopen("a.txt", "w");
 8     fprintf(f, "helloworld\n");
 9     fclose(f);
10 }
11 
12 int main() {
13     _init();
14 
15     FILE *f = fopen("a.txt", "r");
16     int pid = fork();
17     for (;;) {
18         char s[6] = {};
19         if (pid) {
20             fseek(f, 0, SEEK_SET);
21             fread(s, 1, 5, f);
22             assert(strcmp(s, "hello") == 0);
23         } else {
24             fseek(f, 5, SEEK_SET);
25             fread(s, 1, 5, f);
26             assert(strcmp(s, "world") == 0);
27         }
28     }
29     fclose(f);
30     return 0;
31 }

程序很简单,先在磁盘写入一个包含"helloworld"10个字符的文件,然后两个进程分别读取这个文件的[0..5)和[5..10)片段,对应"hello"和"world"两个字符串,验证读到的结果是否正确。

运行后不出所料地触发了assert断言失败。

  • 文件在进程中的表示

fork()和fopen()

当我们用fopen()打开一个文件时,就新建了一个文件描述符(file descriptor),一个整数,对应descriptor table中的一项。

一般情况下,每个descriptor指向系统open file table中的一项纪录,里面存着文件当前的偏移、读写模式、引用计数等状态。

值得注意的是,同一个磁盘文件可以被多次fopen()打开,这样会创建多个descriptor,指向互相独立的状态。

上述代码执行完15行fopen()之后是这样的:

fork()和fopen()

  • fork()对文件的处理

fork()是Unix系统创建进程的函数,执行之后父进程和子进程各返回一次,通过返回值pid进行区分。fork会对当前进程的堆、栈地址空间都原样复制一遍,但对于打开的文件怎么处理呢?其实看man fork文档里面写得相当清楚:

o   The child process has its own copy of the parent's descriptors.  These descriptors reference
    the same underlying objects, so that, for instance, file pointers in file objects are shared
    between the child and the parent, so that an lseek(2) on a descriptor in the child process
    can affect a subsequent read or write by the parent.  This descriptor copying is also used by
    the shell to establish standard input and output for newly created processes as well as to
    set up pipes.

所以当我们执行完fork()之后是这样的:

fork()和fopen()

两个进程指向同一个文件状态,包括偏移,这就产生了竞态条件,引发bug了。

要解决这个bug,只需要调换15、16行,先fork()再fopen()即可:

fork()和fopen()

因为两个进程的文件状态互相独立了,各读各的,就没有问题了。

  • 引用计数

有的时候我们反而是希望两个进程共享一个文件状态轮流处理文件的,只要做好同步即可。

那么fork()之后两个文件都fclose()是否会有问题?

答案是不会。注意到open file table中有一项refcnt引用计数,表示该项被几个进程同时引用。刚开始引用计数为1,fork()之后就变成2了。最后某进程fclose()之后降为1,另一进程fclose()之后降为0,此时操作系统才真正关闭此文件。

 

上一篇:程序中运行其他程序,或者执行脚本


下一篇:Redis持久化