【Linux】进程的控制①之进程创建与进程退出

目录

​编辑

一 、进程的创建

1、fork函数

2.函数的返回值

①return 可以返回两次,使得父子进程读到的id有两个值

②写实拷贝,使得父子进程读到的值都对自身有意义

a.为什么要写实拷贝,而不是在创建子进程的时候直接就将空间给子进程开辟好?

b.为什么给子进程分配空间的时候,老的数据要拷贝过来,而不是直接写入新的数据,开辟空白空间?

c.写实拷贝是如何做到的?怎么做到当子进程需要,操作系统就过来开辟空间的?

3.一般情况下创建子进程的情形:

循环查看当前运行进程的指令:

4.fork调用失败原因 

二、进程退出

1 函数返回值与进程退出码

①我们可以使用系统自带的 方法,将错误码:ernum进行转化为错误原因:

②可以自己定义我们的错误原因:

③普通函数的return:errno

进程退出的操作部分 

① mian函数中return

②exit:进程终止

③_exit


 

一 、进程的创建

1、fork函数

fork函数功能:从已经存在的进程中创建一个新进程。新进程为子进程,原进程为父进程。

fork函数创建进程过后,父子进程代码和数据是共享的。在前面也讲过。

2.函数的返回值

如果进程创建成功,给父进程返回子进程的pid(进程标识符),给当前的进程返回0,创建失败返回-1.返回值类型是pid_t类型。

下面我们来写一段代码手动创建一个进程:

 #include<stdio.h>
  2 
  3 #include <sys/types.h>
  4 #include <unistd.h>
  5 
  6 
  7 int main()
  8 {
  9   pid_t id = fork();//创建子进程
 10   if(id == 0)
 11   {
 12     //子进程
 13     printf("i am a child process,mypid:%d  myppid:%d \n",getpid(),getppid());
 14     sleep(1);
 15   }
 16   //父进程
 17 
 18     printf("i am a father process,mypid:%d  myppid:%d \n",getpid(),getppid());                                                                                                             
 19     sleep(1);
 20 
 21 
 22   return 0;
 23 }

创建进程过后,父进程与子进程是两个独立的进程,但是代码和数据共享,如果当其中一个数据改变或者写入数据的时候,会发生写实拷贝。父子进程的代码区域是共享的,但是每个进程维护自己的数据区域。也就是说子进程会继承父进程的大部分属性进程的pcb,进程的进程地址空间,进程的页表等等,这些使得父子进程几乎共同拥有数据区和代码区。

fork函数的返回值由一个变量接收,但是却保存两个值原因:

①return 可以返回两次,使得父子进程读到的id有两个值

进程调用 fork ,当控制转移到内核中的 fork 代码后,内核做:
  • 分配新的内存块和内核数据结构给子进程
  • 将父进程部分数据结构内容拷贝至子进程
  • 添加子进程到系统进程列表当中
  • fork返回,开始调度器调度
  • fork 之前父进程独立执行, fork 之后,父子两个执行流分别执行。注意, fork 之后,谁先执行完全由调度器 决定。
那么这里就很好理解,我们知道的一个函数当执行到return的时候,这个函数的主要工作或者主要逻辑就已经完成了,fork也是函数,在执行返回pid之前,就已经以调用进程也就是父进程为模版创建好了子进程,将子进程对应的pcb放入了运行队列里面,等待调度,那么此时,父子进程共享代码,分别执行一次return,就可以返回两次,而父子进程的细微差异性让return的值不同,那么当我们使用同一个变量来接收,为什么一块地址会保存两个值呢,这就是第二个原因,写实拷贝和进程的地址空间所做的工作。

②写实拷贝,使得父子进程读到的值都对自身有意义

我们创建进程,就是为了让进程独立的去帮助我们完成工作,那么代码是只读的,父子进程共享代码执行没有问题,可以通过if语句来执行不同的功能,但是如果,两个进程要对代码中或者程序中的数据进行修改,那么势必会影响互相的工作,但是进程是互相独立的呀,怎么能够让彼此乱改数据呢,所以,为了防止这种情况,我们的操作系统内部就存在一种解决机制,我们称为写实拷贝:

当父子进程执行时,有一方要修改数据,就在内存重新开辟空间,然后修改子进程的页表映射关系,让父子进程拥有不同的数据区域,自此以后,两个进程就互相有自己的数据区域,随便修改互相不影响。这就是为什么我们的一个返回值接收变量里面可以存储两个值,并且两个值都有意义的原因。但是我们发现父子进程打印的这个id变量的地址都是一样的,这是因为我们弟弟进程有进程地址空间存在,我们用户所看到的地址,就是打印出来这些,并不是真正的地址,而是虚拟地址,操作系统中存在页表,建立了虚拟内存到实际内存空间的映射关系,由于子进程继续了父进程1页表也相应的继承了页表的映射关系,但是只修改了虚拟内存到实际内存的映射,所以父子进程打印出来的虚拟地址是一样的。

下面我们要来说一下关于写实拷贝的几个问题

a.为什么要写实拷贝,而不是在创建子进程的时候直接就将空间给子进程开辟好?

我们为什么非要等到子进程要进行数据写入了操作系统才给我们的子进程去开辟空间,建立映射呢?直接在创建的时候就做好这些工作呢?那么我们说,子进程创建出来是为了帮助我们完成工作,但是这个工作需不需要新的数据,会不会产生新的数据,要不要访问内存等等都还是不确定的,那么如果子进程不使用,操作系统提前给子进程分配内存,甚至单独给子进程拷贝一份代码,造成资源浪费,所以,当子进程在尝试写入的时候,此时会发生缺页中断,操作系统介入,创建内存,建立映射。其次,如果我们在创建子进程时就给子进程分配空间,那么创建的过程也会变长,因为除了拷贝进程的pcb、页表、进程地址空间还要拷贝进程数据,fork函数的成本增加,效率变得低下。

b.为什么给子进程分配空间的时候,老的数据要拷贝过来,而不是直接写入新的数据,开辟空白空间?

当我们的子进程要进行数据写入的视乎,此时子进程要写入新的数据了,操作系统给子进程拷贝老的数据过来干什么,多此一举。直接给空间就好了,写实,给子进程开辟空间没有问题,但是为什么要将父进程数据进行拷贝?首先,我们要理解写入数据的本质就是对数据进行增删查改,写入数据不一定能够做到对原来的数据进行完全覆盖,如果只开辟空间,怎么能够知道原来的数据是多少,就比如我们子进程要对父进程的某一个数值进行++操作,不知道原来的数据怎么行。所以拷贝也是操作系统为了增加确定性的策略,能在应用层规避很多问题。

c.写实拷贝是如何做到的?怎么做到当子进程需要,操作系统就过来开辟空间的?

页表除了有虚拟地址和实际地址外,实际上还有这每一个映射条目对应的权限。

有时当我们写代码的时候,要对内存进行数据写入的时候,此时程序会报错不不让我们写入,就是因为有权限在限制。

比如执行以下代码:

char * str = "hello world";
*str = 'H';

 代码的本意是想将原先字符串的首字母改成大写,但是我们执行带啊时候:

是异常的,可以编译通过,但是运行挂掉。我们在学习语言的时候我们说,字符串常量是具有常属性的,在字符常量区不能够对常量数据进行更改。那么为什么对我们的常量区就不可以改呢,换句话说常量区是怎么维护你的常性的。

char* str = "hello world";

str里面保存的地址是虚拟地址,当我们要对这个地址空间里面的内容进行修改,也就是赋值,那么必然要伴随着虚拟地址到物理地址的转换,赋值的本质就是写入数据,当写入时,转换映射的条目位的权限为只读,所以也就不可以修改。 那么,我们在写代码的时候有时候会在这些不可以改变的常量前去加const来修饰,不是因为我们加了const使得它不可修改,而是其本身的映射权限就是不可修改,我们加上const,只是为了提前帮我们发现问题,就是说编译器在编译的时候,如果遇到这个const修饰的值被修改,那么编译器就知道这个值不可修改,就报错,而不是等到进程运行起来了崩溃再去找原因,这是一种防御性策略。

 那么我们的子进程写实拷贝的过程粗略过程是怎么样的呢:

首先,代码区域为只读权限没有问题,而对于数据区域,在创建子进程的时候,操作系统会将父子进程数据映射在页表中的相应权限修改为r,都是只可以读。当子进程或者父进程尝试进行写入数据的时候,由于权限不允许,此时就会出问题报错,操作系统就会过来查看,此时操作系统对报错进行种类判断,发生缺页中断,那么之前将数据区域的映射条目权限设置为r就是为了触发报错。让os过来,知道我们的进程要写入数据,此时就可以根据进程需要及时为进程开辟内存建立映射,将映射条件由r改为rw,此时进程可以写入数据,而操作系统也就完成了写实拷贝。

3.一般情况下创建子进程的情形:

循环查看当前运行进程的指令:

  while :; do ps axj | head -1 && ps axj | grep 可执行程序名 | grep -v grep; sleep 1; done

4.fork调用失败原因 

  • 系统中有太多的进程
  • 实际用户的进程数超过了限制

二、进程退出

1 函数返回值与进程退出码

我们写C语言或者c++的时候,总喜欢在main函数结束时写上:

                             return 0;

这样的语句,那么可以不可以return 1,return 2 呢?首先我们来说一下为什么会有return 0,main函数也是一个函数,会有返回值,也会被调用,当我们的代码运行起来变成进程,我们自然而然会关心,这个进程运行的结果,我们编写代码很多时候是希望代码给我们返回一个结果的,那么我们或者操作系统怎么知道这个进程运行得怎么样,运行的效果好不好,我们的bash怎么知道当前运行的指令对不对。所以main函数的返回值,作为程序执行的最后一条语句,也叫作进程的退出码,可以由用户自定义,由main函数的返回值来判断进程的执行情况,所以这个返回值叫做进程的退出码,为0表示成功,非0表示失败。当我们程序执行失败,我们最关心的东西是错误的原因是什么,所以非0的数字:1,2,3.。。。。不同的数字代表了不同的错误原因。这个数字由系统去判别。 

可以使用一个命令来查看最近一个进程退出时的退出码:

echo $?:记录最忌一次进程退出时候的退出码

 

那么作为用户我们是不懂数字背后的意义,计算机知道的话,我们想看见错误码对应的原因:

①我们可以使用系统自带的 方法,将错误码:ernum进行转化为错误原因:

C 库函数 char *strerror(int errnum) 从内部数组中搜索错误号 errnum,并返回一个指向错误消息字符串的指针。strerror 生成的错误字符串取决于开发平台和编译器。 

写一段代码:

25 #include<string.h>
 26 int main()
 27 {
 28 
 29   int i = 200;
 30   for(i = 0;i<200;i++)
 31   {
 32     printf("error[%d]:%s\n",i,strerror(i));                                                                                                                                                
 33   }                          
 34   return 0;                  
 35 }                            
~
  

 当前操作系统有133个错误,错误码的个数由系统决定

②可以自己定义我们的错误原因:

enum{

success = 0.
open_err,
malloc_err
};

可以这样来使用:

 

const char* errorToDesc(int code)
{
 switch(code)
{
case success;
   return "success"
case open_err:
    return  "file open error"
case malloc _error:
  return "malloc error!"
defult :
return "unknown error";
}

 那么当我们要使用的时候直接调用这个接口就好。

结论:mian函数return返回时,表示进程已经退出,return后跟的数字是进程退出码,可以自行设置退出码的字符串意义。

③普通函数的return:errno

void Print()
{

printf("%d\n");
return 0;
}

普通函数的return 仅仅表示函数调用完毕。所以除了进程退出还有函数退出,与strerrno相对的还有errno.函数退出时,调用这个函数的地方肯定是想要知道调用这个函数的执行情况,成功还是失败,失败原因是什么,所以一般情况下,函数都会有返回值,但是不是所有函数都能够用函数返回值来表示执行结果成功或者执行情况。

比如fopen函数:

 我们平时调用函数实际上是想要得到两种结果,第一种是函数的执行结果(对应的就是fopen打开文件时要返回的文件指针,也就是说能不能拿到指针),第二种是函数的执行情况(执行成功还是失败,失败的原因是什么)。一般不区分是因为平时函数的返回值已经告诉成功还是失败了,一般只是大家不关心原因。上述图中也就说明了errno中还有错误的原因。

我们来打开一个不存在的文件:

#include<stdio.h>
    2 #include<errno.h>
    3 #include<string.h>                                                            
    4 
    5 int main()
    6 
    7 {
W>  8 FILE* fp = fopen("datahddhsj.txt","r");
    9 printf("%d:%s\n",errno,strerror(errno));
   10 
   11 
   12   return 0;
   13 }

 所以函数在退出时也可以和进程一样有自己的退出原因,我们将函数的退出原因叫做错误码。这个错误码和退出码本质是一模一样的。一般函数的返回值会告诉使用者函数的执行情况,但是如果想要了解失败原因,可以自己设置错误码也可以通过库函数调用errno告诉失败的原因。为什么我们自己写函数的时候不修改errno呢,errno是C语言的库给我们提供的,一般只有调用C语言的库函数的时候,调用失败了告诉调用者失败原因。

所以:

进程退出就只有三种场景:

①进程代码执行完毕,结果正确。

②进程代码执行完毕,结果不正确。

③代码没有跑完,中间出异常。

 一般要排除③,才能来到①和②的情况,就是说进程执行没有出异常,结果才有意义。就像作弊考试得了100分,过程是异常的,结果就没有意义。所以,对于进程要先判断过程有没有出异常,再判断结果正确与否。

什么叫做:进程异常

进程出异常,在进程执行过程中,作为用户层次,我们可能知道进程是出现了除0,野指针等等,但是在操作系统层面,本质是该进程收到了异常信号。

看一下:我们编写一个程序运行成为进程,然后给这个程序发送信号:

int main()                                                                      
 19 {           
 20             
 21   while(1)                                            
 22   {                                                   
 23     printf("process is running,pid: %d\n",getpid());  
 24   }                                      
 25   return 0;  
 26 }  
~      

 程序出异常然后停下:kill-8,8号信号代表除0错误

所以代码出异常,本质 是进程收到了异常信号。我们再看一下这段代码:

 #include <sys/types.h>
 16 #include <unistd.h>
 17 int main()
 18 {
 19  int *p = NULL;
 20   while(1)
 21   {
 22     printf("process is running,pid: %d\n",getpid());
 23     sleep(1);
 24 
 25   }
 26   *p = 100;//野指针
 27   return 0;
 28 }                

这是段 错误,对应十一号信号,查看所有的信号:kii -l 命令

结论: 一但程序出现异常,本质是进程会收到信号。

对于上图中,数字代表信号编号,后面的字符是大写的宏也就是宏名称。每个进程信号都有不同的编号,所以进程出异常,我们最关心的还是进程出现异常的原因,不同的信号编号,表明不同的经常异常原因。

所以进程终止场景就分三种情况,其中进程是否出异常可以通过信号不同来表明出异常的原因,那么对于,程序执行过程中没有出异常的情况,执行的结果对不对由进程的1退出码来决定。

结论:

        任何进程的的执行情况,可以由两个数字来表明,具体的执行情况就是程序有没有出现异常由(信号编号表示),执行结果对不对(进程的退出码表示)。

2.进程退出的操作部分 

控制进程退出的方式:

① mian函数中return
②exit:进程终止

函数作用:终止进程

函数参数:int status:相当于main函数返回值 ,即是进程退出时候的退出码。

我们来看这样一段代码

#include<stdlib.h>                                                              
 32 int main()                           
 33 {                                    
 34   while(1)                                   
 35   {                                          
 36     printf("i am a process : %d\n",getpid());
 37     sleep(1);                        
 38                                      
 39                                      
 40     exit(2);                         
 41   }                                  
 42 }        

所以:exit函数为终止进程函数(3号手册是C语言接口)

status为进程退出时候的进程退出码

我们再来写这样一段代码:我们并不在main函数中调用exit函数

#include<stdlib.h>     
 32                        
 33 void Print()           
 34 {                      
 35   printf("hello print\n");
 36   exit(4);             
 37 }                      
 38 int main()             
 39 {                      
 40   while(1)             
 41   {                    
 42     printf("i am a process : %d\n",getpid());
 43     sleep(1);          
 44     Print();                                                                    
 45                                                             
 46    // exit(2);                                              
 47   }                                                                        
 48 }           

进程照样退出了,并且退出码是4. 

 

 结论:exit可以用来终止进程,exit(退出码),在我们的进程代码中任意地方调用exit都表示进程退出。

③_exit

二号手册,系统调用

 #include<stdlib.h>                                                           
 32                                                                              
 33 void Print()                                                                 
 34 {                                                                            
 35   printf("hello print\n");                                                   
 36  // exit(4);                                                                 
 37 }                                                                            
 38 int main()                                                                   
 39 {                                                                            
 40   while(1)                                                                   
 41   {                                                                          
 42     printf("i am a process : %d\n",getpid());                                
 43     sleep(1);                                                                
 44     Print();                                                                 
 45    _exit(3);                                                                    
 46                                                
 47    // exit(2);                                 
 48   }                                            
 49 }            

 结论:exit和_exit一样都可以在代码的任意地方调用都可以表示程序退出。但是exit是三号手册代表的是C语言接口,_exit是二号手册代表的是系统调用接口。

二者区别:

int main()
 52 {
 53   printf("hello linux, hell\n");
 54   sleep(1);
 55 
 56   exit(1);
 57 }

 int main()
 52 {
 53   printf("hello linux, hell");
 54   sleep(3);                                                                     
 55 
 56   exit(1);
 57 }

现象:前三秒程序没有输出没有停止1,三秒后输出程序停止。 

 

原因: 平时输出的消息字符不是直接打印在屏幕上而是输出到了缓冲区里面。

结论:说明exit本身支持将消息从缓冲区刷新出来。

1 int main()
 52 {
 53   printf("hello linux, hell");
 54   sleep(3);
 55 
 56   _exit(1);                                                                     
 57 }
~

现象:前三秒没有输出,程序没有停止。后续三秒程序停止,没有输出。

结论: _exit不支持刷新缓冲区内容。推荐exit。

梳理一下二者的关系:

在整个计算机系统中,,只有操作系统有资格来终止一个进程,因为操作系统是进程的管理者。而我们说我们可以通过exit这样的函数来将进程终止,也仅仅是因为操作体系给我们提供了这样的接口,我们才可以做到关闭进程。但是操作系统不知道用户什么时候想要来终止进程。就像只有银行可以将钱财放入金库和拿出金库,但是并不能确定客户什么时候会来取钱。所以银行会开窗口应对随时来的客户。操作系统也是如此,操作系统要终止进程,但是并不知道什么时候要终止进程,为什么要终止进程。就算程序死循环,对于操作系统来说,为什么要终止呢,要终止也是用户的请求。所以就要给用户提供可以终止进程的接口_exit.

所以,用户要终止一个进程,必须调用系统调用。我们的_exit本是就是一个系统调用,而exit是我们的C语言函数,所以我们可以肯定一点:exit底层肯定是封装了_exit的接口。因为用户要结束进程,必须通过系统调用。所以我们的printf函数,scanf函数等底层也是封装了系统调用接口的。

为什么要对系统调用接口进行封装,而不是直接调用?

①为了支持语言级别的语言特性

②为了用户使用方便,可能有些用户对于系统调用接口比较陌生,但是对于语言级别的接口熟悉。封装后接口变简单。

③不同的操作系统提供的接口是不一样的,windows提供的是windows的接口,linux提供的是linux的接口,甚至函数名和参数、返回值都不一样对于相同功能的接口来说。如果我们直接在任何情况下使用不封装的的系统调用,这样可以吗是可以的,但是可能这个程序在linux下可以运行,但是绝对在windws下就跑不起来了。所以封装的意义就是:用户可以统一调用,但是不用关心底层的接口。这就使得程序可以在不同的操作系统下运行,这个也就实现了所谓的代码的可移植性。这也是C语言叫做跨平台性语言的原因。库封装屏蔽了平台接口的差异。

缓冲区的位置

缓冲区的位置绝对不在缓冲区里面,或者绝对不是操作系统里面的

原因:如果缓冲区在操作系统里面,那么无论后续调用exit或者-exit来说,都会将内容刷新出来,这就和我们上面的实验现象相反了。对于调用_exit来说,首先操作系统不会做任何浪费空间,降低效率的事情,如果缓冲区在操作系统内部,只要写入了数据在缓冲区里面,就一定会刷新。不刷新就不会写入。而缓冲区是在库中,在操作系统的上层。

 

进程退出的时候,操作系统做了些什么呢:

释放地址空间、页表、开辟的代码和数据空间进行释放,等调用此进程的父进程读取了这个进程的结束状态信息过后,pcb再被释放。 

以上就是关于进程创建和退出的所有内容整理。 

  • 62
    点赞
  • 26
    收藏
    觉得还不错? 一键收藏
  • 3
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 3
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值