LInux: fork函数究竟是如何工作的?为何一个变量能够接受两个返回值?
前言
在Linux中,创建进程有两种方式:
- 在命令行中直接启动进程。例如在命令行中输入pwd、tar等命令后,操作系统会直接加载,运行相应的进程。
- 通过代码创建进程 —— fork()函数。
在Linux中,查看进程最常用的两种方式:
- 使用
ps axj 或 ps aux
指令查看进程。 - 使用
ls /proc
指令查看当前已加载到内存中所有的进程。
一、fork()用法
fork()是一个系统调用接口函数
。
fork()的原型是pid_t fork(void)
,其中pid_t是一个整型int的别名。
使用fork(),系统会以当前进程为模板,创建子进程(创建子进程时,OS会将当前进程的大部分属性来初始化子进程,而对于子进程的pid、ppid等属性则是单独实现)。同时对于父进程,fork()函数会返回子进程的pid;而对于子进程来说,fork()函数直接返回0!并且文档中明确表示fork()函数创建进程通常是成功的,所有返回值为负数一般忽略。
二 、fork()应用实例展示
下面我们在process.c
中有这样一段代码:
#include <stdio.h>
#include <sys/types.h>
#include <unistd.h>
int main()
{//getpid()、getppid()都是系统调用,分别返回当前进程的pid和ppid
printf("我是一个进程,我的pid:%d, ppid:%d\n", getpid(), getppid());
pid_t id = fork();
if(id == 0)
{
while(1)
{
printf("我是子进程,我的pid:%d, ppid:%d\n", getpid(), getppid());
sleep(1);
}
}
else if(id > 0)
{
while(1)
{
printf("我是父进程,我的pid:%d, ppid:%d\n", getpid(), getppid());
sleep(1);
}
}
else
{//返回失败
return 1:
}
return 0;
}
我们用Makefile、make对上述代码进行编译、运行看看会发生什么?
【动画展示】:
我们发现fork()前的代码只执行了一次(一定是父进程执行),但fork()后的两个判断条件中的代码都执行了(即两个while循环)这也进一步说明了fork()后,操作系统会创建一个子进程!!
tips:
- 上述展示视频中左边是一个监视进程的脚本,可以不断循环打印目标进程的相关属性信息。脚本如下:(如果需要监视其他进程信息,只需将脚本中的myprocess改成目标可执行程序即可!
while :; do ps axj | head -1 && ps axj | grep myprocess | grep -v "grep"; sleep 1; echo "#########################################################"; done
三、fork()工作原理
3.1 为什么要创建子进程?
父进程创建子进程通常是希望子进程来协助父进程来完成一些工作,这些工作是单进程无法实现的。
例如:用户在下载某一款软件时,同时存在播放该款软件的官方宣传视频的需求。这就需要子进程来协助父进程来达到边下载边播放的目的。即通过一定手段(通常时fork()的返回值),让父子进程分别执行不同的代码!
3.2 fork()究竟干了些什么?
fork()创建进程后,OS中会多一个进程(子进程)。在创建过程中,Linux操作系统会为子进程创建对应的PCB,并用父进程的大部分属性来初始化子进程的相关属性(如子进程的pid、ppid、所在路径等属性信息则是单独实现)。最后将该进程链入到运行队列中,等待CPU的调度!!
而进程 = 代码 + 内核数据结构和数据,并且进程间时是相互独立的。而进程中的代码和数据等是操作系统在创建该进程时,从磁盘上加载拷贝到内存中的。但创建的子进程是直接在内存中创建的,子进程并没有相应的代码和数据,那要怎么解决这个问题呢?
实际上,代码只是用于读取的。所以Linux中fork()创建的子进程和父进程共用同一段代码。但对于数据来说,父/子进程间必然会存在差异(比如pid、ppid等)。同时为了保证父/子进程间的独立性,必须在父/子进程中各自独立私有一份。而在Linux中采用了写时拷贝的方式来解决这个问题。
下一个问题就是为啥我们在前面动画展示中,fork()创建出的子进程只执行fork()后的代码?在fork()前的代码子进程能否“看见”呢?
答案是子进程能看见fork()前的所有代码!至于为啥子进程只执行fork()后的代码,这是由于代码运行过程中,存在诸如epi、pc等寄存器。这些寄存器中会保存当前指令要执行的下一条指令的地址。而fork()创建子进程过程中,父进程中pc、epi等寄存器的结果也“继承”给了子进程。所以才出现子进程只运行执行fork()函数后的代码!
3.3 fork为什么会存在两个返回值?
fork()是一个函数,其存在返回值。fork()在执行是,操作系统内核做了如下工作:分配新的内存块和数据结构给子进程、将父进程的部分内核数据结构拷贝给子进程、添加子进程到系统进程列表中,调度器开始调度。
fork()函数执行完后,已经完成了创建子进程、将子进程添加到调度队列中等工作。当父进程和子进程在调度队列中被调度时,两个进程都需要执行return
语句,都需要返回一个值!所以fork存在两个返回值!(操作系统通过寄存器来实现返回值返回两次)(真正原因其实在于地址空间的实现)
3.5 为何fork函数中父进程返回子进程的pid、子进程返回0?
对于一个进程,其父进程是唯一确定的,但其子进程可能存在多个。就像我们生活中,一个孩子的爹是唯一确定的;但对于一个父亲,其可能存在多个孩子。
而父进程和子进程之间是管理和被管理的关系。父进程为了更好的管理好子进程,所以fork函数在创建子进程后返回子进程pid;对于子进程来说,其只需管理好自身即可,所以返回0。
3.5 父进程和子进程谁先运行?
我们已经看到了fork()函数会创建一个子进程。创建完子进程后,子进程会被加载链接到系统进程列表中等待CPU调度运行。
至于父进程和子进程谁先被CPU调度是不确定的。进程被调度的先后顺序由各自的PCB中的调度信息(时间片 + 优先级等)+ 调度器算法确定。换句话说,父进程和子进程谁先运行是由操作系统决定的!
3.6 为何同一个变量接收两个返回值
我们前面已经提到过了进程是相互独立的,为例保存fork()创建出的子进程和父进程之间的独立性,我们所采用的解决办法是:父子代码共享,父子再不写入时,数据也是共享的,当任意一方试图写入,便以写时拷贝的方式各自一份副本。具体见下图:
操作系统在创建子进程时,会先将父进程页表中的访问权限大部分改为只读权限。然后以父进程为模板,将数据拷贝给子进程。当子进程或父进程对数据试图修改数据,由于页表中的访问权限为只读。此时该行为会被操作系统识别到。并分为以下两种情况:
- 如果代码和数据所处区域本身就是只读的(如:代码区、字符常量区),此时OS认为该行为非法被拦截。
- 原本数据可读可写,但操作系统将对应页表中的访问权限设置为只读。当操作系统识别到该信息时,操作系统并不会将行为视为错误,而是作为重新申请内存拷贝内容的一种策略机制。此时操作系统会重新申请开辟空间,拷贝和修改数据,并修改页表中的映射关系。
四、fork创建子进程失败原因
在Linux中,fork()创建子进程失败通常由有以下两种原因:
- 系统中有太多的进程。操作系统内存不足。
- 实际上,用户使用fork()创建子进程是有次数限制的。当超过该范围时,fork创建子进程失败!