一,fork和vfork

#include <sys/types.h>
#include <unistd.h>

pid_t fork(void);
pid_t vfork(void);

fork用于创建一个进程,该函数调用一次,返回两次。两次返回的区别是子进程的返回值是0,而父进程的返回值是新子进程的进程号。出错时,返回-1。子进程是父进程的一个副本——子进程获得父进程的数据空间、堆和栈的副本(不是共享存储空间),但是父子进程共享正文段,因为这一部分一般都是只读的。fork出来的子进程和父进程谁先执行是不确定的

vfork和fork一样,也用于创建一个新的进程。但是它和fork有两个区别:

  • vfork保证子进程先运行,在子进程调用exec或者exit之后父进程才会被调度运行。
  • vfork一般要和exec族函数配合使用。因为vfork创建的进程并不复制父进程的地址空间,它直接在父进程的进程空间里面运行,直到调用exit或者exec族函数。这也就是为什么vfork会保证子进程会先运行,因为二者共享一个进程空间,同时运行就会有问题。

下面我们分析几个有趣的程序:

例程1:

#include <stdio.h>
#include <stdlib.h>
#include <sys/types.h>
#include <unistd.h>

int glob = 6;
char    buf[] = "a write to stdoutn";

int main()
{
    int     var;
    pid_t   pid;

    var = 88;
    if (write(STDOUT_FILENO, buf, sizeof(buf)-1) != sizeof(buf) -1)
    {
        printf ( "write errorn" );
        return 1;
    }

    printf ( "before forkn" );

    if ((pid = fork()) < 0)
    {
        printf ( "fork errorn" );
        return 1;
    }
    else if (pid == 0)
    {
        glob++;
        var++;
    }
    else
    {
        sleep(2);  // 让子进程先运行,但这样并不能完全保证
    }

    printf ( "pid = %d, glob = %d, var = %dn", getpid(), glob, var);

    exit(0);
}

程序执行结果:

allan@NYC:~/test$ ./test1 
a write to stdout
before fork
pid = 16752, glob = 7, var = 89
pid = 16751, glob = 6, var = 88
allan@NYC:~/test$ ./test1 > test1.out
allan@NYC:~/test$ cat test1.out 
a write to stdout
before fork
pid = 16778, glob = 7, var = 89
before fork
pid = 16777, glob = 6, var = 88

从结果我们可以看出:

  • 子进程中堆glob和var的改变并未影响到父进程中glob和var的值,因为二者不在一个进程空间中。
  • 有趣的是同一个程序,两种运行方式得到的结果不一样,第二次的执行多输出了一个“before fork”。这里是和I/O函数有关系。write函数本身是不带缓冲区的(见之前《Linux文件I/O》),因为在fork之前调用write,所以其数据写到标准输出一次。但是,标准I/O库是带缓冲的。如果标准输出连到终端设备,则它是行缓冲的,否则它是全缓冲的。当以交互方式运行该程序时,只得到该printf输出的行一次,其原因是标准输出缓冲区由换行符冲洗。但是当标准输出重定向到一个文件时,去得到printf输出行两次。其原因是在fork之前调用了printf一次,但当调用fork时,该行数据仍在缓冲区中,然后在将父进程数据空间复制到子进程中时,该缓冲区也被复制到子进程中。于是,那时父子进程各自有了带该行内容的标准I/O缓冲区。在exit之前的第二个printf将其数据添加到现有的缓冲区中。当每个进程终止时,最终会冲洗其缓冲区中的副本。

例程2:

#include <stdio.h>
#include <sys/types.h>
#include <unistd.h>
#include <stdlib.h>

int     glob = 6;

int main(void)
{
    int     var;
    pid_t   pid;

    var = 88;
    printf ( "before vforkn" );
    if ((pid = vfork()) < 0)
    {
        printf ( "vfork errorn" );
        _exit(1);
    }
    else if (pid == 0)
    {
        glob++;
        var++;

        _exit(0);
    }

    /* parent continue here */
    printf ( "pid = %d, glob = %d, var = %dn", getpid(), glob, var );

    exit(0);
}

程序运行结果:

allan@NYC:~/test$ ./c-7 
before vfork
pid = 7589, glob = 7, var = 89

可以看到,使用vfork后,子进程里面对glob和var进行的加1操作,影响到了父进程里面glob和var的值(最后的打印是父进程打印的,子进程已经调用_exit退出了)。这就是之前说的,vfork出来的进程并不会复制父进程的进程空间,而是直接在父进程的进程空间去运行的。

例程3:

#include <stdio.h>
#include <sys/types.h>
#include <unistd.h>
#include <stdlib.h>

static void f1(void), f2(void);

int main(void)
{
    f1();
    f2();
    _exit(0);
}

static void f1(void)
{
    pid_t   pid;

    if ((pid = vfork()) < 0)
    {
        printf ( "vfork errorn" );
        return;
    }
    /* child and parent both return */
}

static void f2(void)
{
    char    buf[1000];
    int     i;

    for (i = 0; i < sizeof(buf); i++)
        buf[i] = 0;
}

程序运行:

allan@NYC:~/test$ ./bug_exp 
段错误

可见这个程序是无法正常运行的。我们知道每个函数调用的栈帧是存储在栈里面的,当函数f1调用vfork时,父进程的栈指针指向f1函数的栈帧。然后vfork后,子进程先执行然后从f1返回,接着子进程调用f2,并且f2的栈帧覆盖了f1的栈帧,在f2中子进程将自动变量buf的值置为0,即将栈中的1000个字节的值都置为了0.从f2返回后子进程调用_exit,这时栈中main以下的内容已经被f2修改了。然后,父进程从vfork调用后继续,并从f1返回。返回信息虽然保存于栈中,但是多半可能已经被子进程修改了(这依赖于系统对于栈的实现,比如返回信息保存在栈中的具体位置,修改动态变量时覆盖了哪些信息等)。不过这些都是因为父进程和子进程运行在同一个进程空间导致的,虽然有先后顺序,但是还是很危险。所以,vfork一般都是和exec族函数配合使用的。

上面是一些进程变量在父子进程中的变化关系,下面我们看一下文件共享方面父子进程的关系。

二,父子进程的文件共享

关于Linux的文件共享之前已经在《Linux文件I/O》中介绍过。利用fork产生一个子进程的时候,父进程所有打开的文件描述符都会被复制到子进程中。父子进程的每个相同的文件描述符共享一个文件表项。假设几个进程具有三个不同的打开文件,它们是标准输入、标准输出、标准出错。从fork返回时,我们就有下图所示的结构:

1365952916_7361

这种共享文件的方式使父子进程堆同一文件使用了一个文件偏移量。考虑下述情况:一个进程fork了一个子进程,然后等待子进程终止。假定,作为普通处理的一部分,父子进程都向标准输出进行写操作。如果父进程的标准输出已重定向,那么子进程写到该标准输出时,他讲更新与父进程共享的该文件的偏移量。在我们所考虑的例子中,当父进程等待子进程的时候,子进程写到标准输出;而在子进程终止后,父进程也写到标准输出上,并且知道其输出会添加在子进程所写的数据后。如果父子进程不共享同一文件偏移量,这种形式的交互就很难做到。

如果父子进程写到同一文件描述符,但又没有任何形式的同步(例如上面的使父进程等待子进程),那么它们的输出就会相互混合(假定所用的文件描述符是fork之前打开的)。所以,一般fork之后,处理文件描述符有两种常见的方式:

  • 父进程等待子进程完成。在这种情况下,父进程无需堆其描述符做任何处理。当子进程终止后,它曾进行过读、写操作的任一共享描述符的文件偏移量已执行了相应更新。
  • 父子进程各执行不同的程序段。在这种情况下,在fork之后,父子进程各自关闭它们需要使用的文件描述符,这样就不会干扰对方使用的文件描述符。这种方法是网络服务进程中经常使用的。