Linux下的Double Fork
引入
最近我在学习Linux下的网络编程,过程中实现了一个基于TCP协议的多进程EchoServer,在这个示例程序中我非常真切的体会到编程的设计哲学,很有感悟,所以连忙开了这篇文章迫切地想写点东西。
fork和waitpid
首先我想基于自己对Linux进程的浅薄认识,聊一下 fork 和 waitpid 这两个关于进程的重要接口
fork
fork函数的功能很好理解,让我们先看一下手册的描述:
fork() creates a new process by duplicating the calling process. The new process is referred to as the child process. The call‐ing process is referred to as the parent process.
The child process and the parent process run in separate memory spaces…
The child process is an exact duplicate of the parent process except for the following points:
- The child has its own unique process ID…
- The child’s parent process ID is the same as the parent’s process ID.
- …
On success, the PID of the child process is returned in the parent, and 0 is returned in the child. On failure, -1 is returned in the parent, no child process is created, and errno is set appropriately
简单来说,(父)进程调用fork函数的时候,fork函数会创建一个子进程并且返回pid:
1 | pid_t id = fork(); |
创建成功后,父进程拿到的id是子进程的pid,子进程拿到的id是0。如果id为负数说明创建失败了,因此很容易得到下面的结构:
1 | if(id == 0) |
进程创建还有很多细节内容,但是以上这些内容在这篇blog里已经足够用了
waitpid
这个接口长这样:
1 | pid_t waitpid(pid_t pid, int *wstatus, int options); |
再看一眼手册的描述:
This system call is used to wait for state changes in a child of the calling process, and obtain information about the child whose state has changed.
A state change is considered to be: the child terminated; the child was stopped by a signal; or the child was resumed by a signal. In the case of a erminated child, performing a wait allows the system to release the resources associated with the child; if a wait is not performed, then the terminated child remains in a “zombie” state.
总结一下,waitpid用于获取子进程的状态以及回收子进程的资源
描述的最后一句提到了一个很有意思的东西 “zombie”(僵尸进程)。试想一下,waitpid是用于回收子进程的资源的,如果子进程没有被父进程等待,那么子进程终止后资源就无法被回收,这是UNIX/Linux类系统都有的进程管理机制。
此外还有另一个机制叫做孤儿进程。因为我对手册使用的了解还不够,没能直接查找到关于孤儿进程的手册描述,不过没关系,我完全可以讲清楚这里的要点。僵尸进程是由于子进程还在执行的时候,父进程已经终止了,此时子进程的父进程会被设置为init进程(pid:1)成为孤儿进程,init会定期调用wait,回收所有的孤儿进程。
这里有些细节我认为有必要着重进行说明,那就是僵尸进程和孤儿进程产生条件的区别:
| 差异 | 僵尸进程 | 孤儿进程 |
|---|---|---|
| 进程终止顺序 | 子进程先于父进程+父进程不等待 | 父进程先于子进程 |
| 占用资源 | 进程表 | CPU、内存 |
| 危害 | 大量存在会耗尽进程表 | 一般无害 |
| 状态 | 已经终止,未回收 | 正常运行 |
很容易理解,进程等待本质就是一种回收机制
可以看到waitpid接口需要三个参数,第一个参数pid如果大于0就是等待该pid对应进程的子进程,第三个参数option可以控制等待的行为是否阻塞,第二个参数因为和本文主要内容无关所以不做解释。
这里需要解释一下阻塞的意思:当程序执行某个操作的时候如果条件不满足就会被迫进入等待的状态,直到这个操作完成才继续往下运行。形象理解就是,在饭店前台点餐后一直站在原地等待出餐,拿到餐品才去座位坐下,这就是阻塞;点餐之后直接去找座位,出餐之后再去取餐,这就是非阻塞。
说到这里,我认为前置内容已经足够了,接下来我们可以开始说核心内容。
使用场景
我遇到的应用场景是,基于TCP协议的多进程EchoServer。
在执行以下代码之前,服务器程序已经完成了创建socket套接字和绑定,并且进入监听状态。
1 | void Run() |
这个Run函数的主要功能就是在监听的状态下,在第9行accept函数处等待client端的请求。一旦建立链接,父进程就会将链接的socket交给子进程来完成后续的通信(即38行处的Service函数)。父进程立刻去到下一轮循环的accept等待连接。通过父进程不断的链接然后将任务交给子进程这样的多进程,可以实现一个服务端同时与多个客户端通信。
在前面章节我们谈论到,子进程应该要么被父进程回收释放,要么最终被init进程自动收养然后释放,否则最后成为僵尸进程就会造成危害,那么不免就要说到waitpid这个接口。
多进程是为了应对并发,如果直接让父进程阻塞式等待子进程,那么这个程序仍然是串行的,子进程的加入就没有意义了。如果是非阻塞式等待,执行通信的子进程仍然高度依赖父进程,并且父进程需要处理信号,所以父进程没有办法随时安全退出。非阻塞式的结构理性来说会显著增加程序逻辑的复杂度,不易维护;感性上来说,不够优雅~😄
Double Fork
终于说到了最核心的内容
让工作进程变成孤儿,利用init自动回收的机制,实现进程间的解耦
double fork的核心思想就是利用init进程(也可以称作守护进程)会自动收养孤儿进程并释放的功能,解除通信进程对主进程(父进程)的高度依赖。
具体的实现已经在前面的代码块中贴出了,这里我们直接上逻辑。
- 父进程在建立连接后执行第一次fork创建子进程,然后父进程阻塞等待,子进程拷贝所有资源。
- 子进程关闭自己不需要的文件后,执行第二次fork,创建孙子进程,随后子进程立即终止,被父进程等待。
- 父进程等待成功,立刻进入下一轮的监听;同时孙子进程执行通信(Service)。
- 孙子进程在子进程终止后,成为孤儿进程,被init进程接管,实现了与父进程解除依赖。
- 子进程创建之后几乎立刻创建孙子进程,然后终止,这段时间父进程的等待过程短到几乎可以忽略不记
由此我们得到了一个高效率的、优雅的多进程通信服务器,这个结构的名字就来自于最重要的两次fork
思考总结
Double Fork结构对于这个示例项目来说已经是很高效优雅的设计了,但是放在工业级项目面前依旧十分逊色。工业级服务器更多使用的是一种叫做IO多路复用的技术,不过Double Fork足以应对小型并发的业务,而且足够优雅~🥰
实际上如果仅仅是为了实现子进程自动回收可以使用信号机制:
1 | signal(SIGCHLD, SIG_IGN); // 显式忽略子进程退出信号 |
这样的做法固然简单,然而在移植性、并发控制、进程状态获取这些方面远不如double fork的设计思路,而且可能产生一些信号处理的问题。最重要的是(孙)子进程仍然没有做到独立。
这些内容只是从很简单的一个示例引申出来的,却在程序的设计哲学上给了我不小的启发和思考。Double Fork不是高并发的最优解,但是展现出了优秀的代码逻辑与对系统特性的巧妙运用的完美结合,这种逻辑与特性的完美闭环,是我在这次学习中收获的最宝贵的哲学思考。