进程创建
操作系统允许一个进程创建另一个进程,并且允许子进程继承父进程所拥有的资源,当子进程被终止时,其在父进程处继承的资源应当还给父进程。同时,终止父进程同时也会终止其所有子进程。
注意:Linux操作系统对于终止有子进程的父进程,会把子进程交给1号进程接管。
进程创建:1、命令行启动命令(程序、指令等) 2、通过程序自身,fork出子进程
创建进程的过程:
- 操作系统为新进程分配一个唯一的进程标识号,并申请一个空白的PCB,PCB是有限的,若申请失败则创建失败。
- 为进程分配资源,此处如果资源不足,进程就会进入等待状态,以等待资源。
- 初始化PCB
- 如果进程的调度队列能够接纳新进程,那就将进程插入到就绪队列,等待被调度运行。
fock函数
父进程通过调用fork函数创建一个新的运行的子进程。
新创建的子进程几乎但不完全与父进程相同。子进程得到与父进程用户级虚拟地址空间相同的(但是独立的)一份副本,包括代码和数据段、堆、共享库以及用户栈。父进程和新创建的子进程之间的最大区别在于它们有不同的PID
#include <unistd.h>
pid_t fork(void);
//返回值:子进程中返回0,父进程返回子进程id,出错返回-1
进程调用fork,当控制转移到内核中的fork代码后,OS做:
- 分配新的内存块和内核数据结构给子进程
- 将父进程部分数据结构内容拷贝至子进程
- 添加子进程到系统进程列表当中
- fork返回,开始调度器
1 #include <stdio.h>
2 #include <unistd.h>
3
4 int main()
5 {
6 const char *str = "hello world";
7
8 pid_t pid = fork();
9 //之后才会运行
10 if(pid == 0){
11 while(1){
12 printf("child: ppid: %d, pid: %d, str: %s\n", getppid(), getpid(), str);
13 sleep(1);
14 }
15 }
16 else if(pid > 0){
17 while(1){
18 printf("father: ppid: %d, pid: %d, str: %s\n", getppid(), getpid(), str);
19 sleep(1);
20 }
21 }
22 else{
23 perror("fork");
24 }
25 return 0;
26 }
注意:虽然父子进程代码共享,但fork之后才有子进程,所以子进程是执行fork之后的代码。
fork常规用法:
1、一个父进程希望复制自己,使父子进程同时执行不同的代码段。例如:父进程等待客户端请求,生成子进程来处理请求。
2、一个进程要执行一个不同的程序。例如子进程从fork返回后,调用exec函数。
1.为什么fork有两个返回值?
2.一个变量里面,怎么会有两个不同的值,从而让父子进入不同的业务逻辑。
> fork后父进程返回时,本质是把返回值写入变量pid,而此时子进程已经创建好了,必定发生了写时拷贝。
所以这一个变量名,内容是不同的,而本质是父子页表映射数据到了不同的内存区域。所以接下来父子进程读取pid拿到的值就不一样。
写时拷贝
通常,父子代码共享,父子在不写入时,数据也是共享的,当任意一方试图写入,便以写时拷贝的方式各自一份副本。
写时拷贝的过程实现是由OS参与完成的。
为什么要有写时拷贝(数据的)?
保证父子进程的“独立性”
1.节省资源。父子进程创建时,拷贝不需要写入修改的数据(只读)是没有意义的,如果直接把数据各自拷贝一份,就浪费了内存和系统资源。
2.提高fork创建的效率。fork时创建数据结构,如果还要将数据拷贝一份,fork效率降低
3.减少fork失败的概率。fork本身就是向系统要更多的资源,而要越多的资源就越容易导致fork失败。
进程终止
进程退出的情况分类:
1.代码跑完,结果正确。退出码:0
2.代码跑完,结果不正确。逻辑问题,但是没有导致程序崩溃。退出码:!0
3.代码没有运行完毕,程序崩溃了,退出码没有意义。
进程常见退出方法:
正常终止(可以通过
echo $?
查看进程退出码):
1.main函数return
2.任何函数exit
异常退出:
ctrl+c,信号终止
main函数中,return的值(退出码)代表进程退出,结果是否运行正确。0代表成功。而return的0是给系统看的,以此确认进程执行结果是否正确。如果我们想看最近一次执行的一个程序运行结束时的退出码,可以用echo $?
来查看
退出码:可以认为定义,也可以使用系统的错误码list
当程序运行失败时,最关心的是失败的原因。而计算机擅长处理整数类型的数据(0, 1, 2, 3…)。 int(整数)-> string(错误码描述)
父进程一般需要知道子进程退出的结果,即进程的退出码。但父进程也可以不关心子进程的运行结果。
进程非正常结束:野指针、/0、越界等,此时退出码无意义。(此时是由信号来终止的)
main函数return。非main函数的return不是终止进程,而是结束函数。例如:
int show()
{
return 0;
}
int main()
{
show();
return 0;
}
这里main函数中调用完show,这个进程并不会终止。
exit:在任何函数中exit都表示直接终止进程
exit:在退出时会执行用户定义的资源清理函数,包括刷新缓冲区,关闭流等。
_exit:在退出时不会进行后续资源处理,直接终止进程。
可以看到使用_exit时,退出码照样是11。
站在OS角度,如何理解进程终止?
核心思想:归还资源
1."释放"曾经为了管理进程所维护的所有的数据结构对象。
2."释放"程序代码和数据占用的内存空间。
3.取消曾经该进程的链接关系。
释放:不是真的把数据结构对象销毁,而是设置为不用状态,然后保存起来。如果不用的对象多了,就有了一个"数据结构的池"。
内存池:先申请分配一定大小的空间,在需要使用时再使用内存池中的空间,就不需要每次需要内存时都进行new/malloc申请空间,提高了用户的效率。
释放数据结构对象:当要创建进程时,需要将内存池中拿出一块空间,并将这块空间强转成task_struct*,再进行访问。但如果每次都要强转就太麻烦。当一个pcb没人用时,可以将该pcb取出并链接到数据结构池中,该过程就是释放不用的数据结构对象,而需要用时再从池中取出,就不用进行强转了。这种释放规则叫做Slab分派器。
释放代码:不是将代码和数据结构清空,而是把内存设置为无效即可。
例如我们在下载电影资源时,所需下载拷进电脑的时间很多,删除却很快,说明写入和删的逻辑是不同的。写入时需要开辟空间,而删的本质是标识数据对应在磁盘上无效,一旦标识无效即意味着可以被覆盖,在写入新数据时,将该无效数据被覆盖也就是被清除了。
进程等待
如果一个父进程终止了,内核会安排init进程成为它的孤儿进程的养父。init进程的PID为1,是在系统启动时由内核创建的,它不会终止,是所有进程的祖先。如果父进程没有回收它的僵死子进程就终止了,那么内核会安排init进程去回收它们。不过,长时间运行的程序,比如shell或者服务器,总是应该回收它们的僵死子进程。即使僵死子进程没有运行,它们依然消耗系统的内存资源。
一个进程可以通过调用waitpid函数来等待它的子进程终止或停止。
等待的必要性:
- 回收僵尸,解决内存泄漏。僵尸状态无法被杀死
- 父进程需要获取子进程的运行结束状态(不是必须的)
- 父进程要尽量晚于子进程退出,可以规范化进行资源回收。(编码相关)
进程等待的方法:
wait/waitpid
- 如果子进程已经退出,调用wait/waitpid时,wait/waitpid会立即返回,并且释放资源,获得子进程退出信息。
- 如果在任意时刻调用wait/waitpid,子进程存在且正常运行,则进程可能阻塞。
- 如果不存在该子进程,则立即出错返回。
**wait:**等待任意一个子进程。当子进程退出,wait就可以返回。
#include <sys/types.h>
#include <sys/wait.h>
pid_t wait(int* status);
返回值:成功则返回被等待进程pid,失败返回-1
参数:输出型参数,获取子进程退出状态,不关心则可以设置为NULL
1 #include <stdio.h>
2 #include <stdlib.h>
3 #include <unistd.h>
4 #include <sys/types.h>
5 #include <sys/wait.h>
6
7 int main()
8 {
9 pid_t id = fork();
10 if(id < 0){
11 perror("fork");
12 return 1;//自定义
13 }
14 else if(id == 0){
15 //child
16 int count = 5;
17 while(count){
18 printf("child is running: %d, ppid: %d, pid: %d\n", count--, getppid(), getpid());
19 sleep(1);
20 }
21 printf("child quit...\n");
22 exit(0);
23 }
24 else{
25 printf("father is waiting...\n");
26 pid_t ret = wait(NULL);
27 printf("father is