fork函数详解

前言

在《对进程和线程的一些总结》已经介绍了进程和线程的区别,但是在C/C++中如何创建进程呢?或者说如何编写多进程的程序呢?

什么时候需要fork进程

一种可能见到的场景是在服务器程序中,一个请求到来后,为了避免服务器阻塞,fork出一个子进程处理请求,父进程仍然继续等待请求到来。但这种方式无疑开销会稍大。

另一种最常见的就是执行一个不同的程序,例如我们在shell终端执行一条命令,实际上就是bash(或者其他)调用fork之后,在执行exec族函数。

fork

一个现有的进程可以通过fork函数来创建一个新的进程,这个进程通常称为子进程。fork函数原型如下:

1
2
#include<unistd.h>
pid_t fork(void);

如果调用成功,它将返回两次,子进程返回值是0;父进程返回的是非0正值,表示子进程的进程id;如果调用失败将返回-1,并且置errno变量。

有的朋友可能常常会记不住返回0的时候到底是子进程还是父进程。这里教给大家一个方法。一个进程可以有多个子进程,但是一个子进程同一时刻最多只有一个父进程。子进程可以通过getppid获取父进程的进程id,但是父进程却没法获取,因此需要在fork后就得到子进程的进程id。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
//公众号【编程珠玑】,博客 https://www.yanbinghu.com
#include<stdio.h>
#include<unistd.h>
int main(void)
{
pid_t pid;
char testVal[128] = {0};
FILE *fp = fopen("test.txt","w");
if(NULL == fp)
{
printf("open test.txt failed\n");
return 0;
}
if(-1 == (pid = fork()))//等于-1时表明fork出错
{
perror("fork error");
return -1;
}
else if(0 == pid)//子进程
{
snprintf(testVal,128,"I am child,father pid is %d\n",getppid());
fprintf(fp,"%s",testVal);

}
else //父进程
{
snprintf(testVal,128,"I am parent,child pid is %d\n",pid);
fprintf(fp,"%s",testVal);
}
printf("fork over,testVal is %s",testVal);
//为了避免马上退出sleep一段时间
sleep(100);
fclose(fp);
return 0;
}

并在同目录下创建一个test.txt文件,运行结果:

1
2
fork over,testVal is I am parent,child pid is 13008
fork over,testVal is I am child,father pid is 13007

需要注意的是,不要对父进程先执行还是子进程先执行做任何假设,因为都有可能。所以,可能出现的运行结果并不一样。

fork到底做了什么

fork被调用后,子进程拥有父进程的副本,因此它拥有父进程的数据空间,堆栈等。但是由于fork之后通常会调用exec函数去执行与原进程不想关的程序,因此fork时直接拷贝父进程的副本显得没有必要。为了提高fork的效率,采用了一种写时复制的技术。即fork之后,子进程名义上拥有父进程的副本,但是实际上和父进程共用,只有当父子进程中有一个试图修改这些区域时,才会以页为单位创建一个真正的副本。

所以我们看到前面的示例程序中,父子进程都对testVal进程了修改,但是互不影响。因为它们修改了不同的区域。

子进程继承了父进程哪些属性?

由于子进程是父进程的一个副本,所以父进程有的属性,子进程也都有,这些属性包括

  • 打开的文件描述符
  • 会话ID
  • 根目录
  • 资源限制
  • 工作目录
  • 进程组ID
  • 控制终端
  • 环境

我们运行前面的示例程序之后,重新打开一个终端,找到打开test.txt文件的进程:

1
2
3
$ lsof test.txt
fork 9919 root 3r REG 252,1 0 396427 test.txt
fork 9920 root 3r REG 252,1 0 396427 test.txt

lsof命令的用法可以参考《如何查看Linux中文件打开情况

也可以观察进程打开的文件描述符:

1
2
3
4
5
$ ls -l /proc/9919/fd
lrwx------ 1 root root 64 Aug 10 15:38 0 -> /dev/pts/1
lrwx------ 1 root root 64 Aug 10 15:38 1 -> /dev/pts/1
lrwx------ 1 root root 64 Aug 10 15:38 2 -> /dev/pts/1
lr-x------ 1 root root 64 Aug 10 15:38 3 -> /data/workspaces/practices/c/test.txt

为什么这里要特别说明打开的文件描述符呢?试想以下两点:

  • 父子进程对同一个文件进行写,将共享文件偏移
  • 如果该描述符是一个socket描述符,父进程退出后,子进程仍然打开着,父进程再次启动,将会出现端口被占用的问题。

所以如果父子进程的其中一个使用了fclose关闭了文件描述符,实际上还有另外一个进程打开了test.txt文件。

与前面testVal不同的是,如果父子进程都对文件进行写,并不会产生两个不同的文件,而是会对同一个文件进行写,因此运行后会在同一个文件里出现父子进程写的内容:

1
2
3
$ cat test.txt
I am parent,child pid is 13008
I am child,father pid is 13007

父子进程有哪些不同?

  • fork之后的返回值不同,进程ID也不同
  • 子进程未处理信号设置为空
  • 子进程不继承父进程设置的文件锁
  • 一般子进程会执行与父进程不完全一样的代码流程

总结

fork用于创建进程,但是需要注意的是,子进程继承了很多父进程的东西,如果子进程不需要可以进行修改或“丢弃”,例如子进程关闭父进程打开的文件描述符等等。理解了fork的写时复制思想,也就会明白,实际上fork的速度是非常快的。本文总结点如下:

  • fork调用一次,返回两次
  • 一个进程可以有多个子进程,但同一时刻最多只有一个父进程
  • 子进程继承了父进程很多属性
  • 父子进程执行的先后顺序不一定

本文仅仅简单介绍了fork,实际上得到子进程之后,还需要对子进程的状态进行“监控”,否则会出现其他意想不到的问题。

守望 wechat
关注公众号[编程珠玑]获取更多原创技术文章
出入相友,守望相助!