Linux进程控制

一、进程创建

fork函数初识

在Linux中,fork函数是非常重要的函数,它从已存在进程中创建一个新的进程,新进程为子进程,原进程为父进程。

fork函数在子进程中返回0,父进程中返回子进程PID,子进程创建失败返回-1(接下来会细讲)。

进程调用fork,当控制转移到内核中的fork代码后,内核做:

1.分配新的内存块和内核数据结构给子进程。

2.将父进程部分数据结构内容拷贝至子进程。

3.添加子进程到系统进程列表当中。

4.fork返回,开始调度器调度。

当运行到fork之后,父子进程代码共享。如下代码:

运行结果如下:

我们再来回顾一下整个过程:当fork函数还没执行时,由父进程执行第8行代码,此时Before由父进程单独打印,当运行fork函数后,子进程被创建出来,接下来的代码由父子进程分别运行,如果子进程创建成功,则跳过if语句,此时父子进程同时打印After,那么可以看到运行结果打印了两次After,那么fork之后,父进程和子进程谁先执行代码完全由调度器决定。

fork函数返回值

请问,为什么fork函数要给子进程返回0,给父进程返回子进程的PID?

一个父进程可以创建多个子进程,而一个子进程只能有一个父进程。因此对于子进程来说,父进程不需要被标识,对于父进程来说,子进程需要被标识,方便管理子进程。

 为什么fork函数会有两个返回值?

fork函数内部会有很多个步骤,例如创建子进程的进程控制块,子进程的进程地址块,到最后一步才return pid;说明在return前子进程已经创建完成了,所以父子进程都会执行return,所以才会有两个返回值。

写时拷贝

当子进程最开始被创建的时候,子进程和父进程的数据和代码是共享的,即父子进程的代码和数据通过页表映射到物理内存的同一块内存,当父进程或者子进程需要修改数据时,才将父进程的数据在内存中拷贝一份,然后再进行修改,这就叫做写时拷贝。

也就是说,当父子进程其中一方需要进行数据修改时再进行拷贝的技术,称为写时拷贝技术。 

fork常规用法

1.一个进程希望复制自己,使子进程同时执行不同的代码段。例如父进程等待客户端请求,生成子进程来处理请求,生成子进程来处理请求。

2.一个进程要执行一个不同的程序。例如子进程从fork返回之后,调用exec函数。

fork调用失败的原因

1.系统中有过多的进程,导致内存不足,从而创建失败。

2.实际用户的进程数超过限制,从而创建失败。

二、进程终止

进程退出场景

1.代码运行完毕,结果正确。

2.代码运行完毕,结果不正确。

3.代码异常终止。

进程退出码

写了这么多代码,我们都知道main函数是代码的入口,但实际上main函数只是用户级代码的入口,main函数也是被其他函数调用的,,例如再VS2013当中main函数就是被一个名为__taminCRTStartup的函数所调用,而__taminCRTStartup函数又是通过加载器被操作系统给调用的

既然main函数是间接性被操作系统所调用的,那么当main函数调用结束后就应该给操作系统返回相应的退出信息,而这个所谓的退出信息就是以退出码的形式作为main函数的返回值返回,我们一般以0表示代码成功执行完毕,以非0表示代码执行出现错误,这就是为什么要在main函数最后return 0的原因

代码运行起来就变成了进程,进程结束后main函数的返回值就是进程的进程退出码,我们可以使用echo $?命令查看最近一次进程退出的退出码信息。

例如下列代码:

 代码运行结束后,可以使用 echo $? 查看该进程的进程退出码。

此时说明main函数执行完毕。

 为什么用0表示执行成功,以非0表示代码执行错误?

因为代码成功只有一种情况,执行完毕成功,而错误有很多种原因,比如内存空间不足,非法访问以及栈溢出等等,我们可以用非0的数字分别表示代码执行错误的原因:

下面再来一段例子,我们输入错误的命令,可以查看到进程退出码为127。 

进程正常退出

return退出

在main函数中使用return退出进程是最常用的方法。

exit函数

使用exit函数退出进程也是我们常用的方法,exit函数可以在代码中的任何地方退出进程,并且exit函数在退出进程前会做一系列工作:

1.执行用户通过atexit或on_exit定义的清理函数。

2.关闭所有打开流,所有的缓存数据均被写入。

3.调用_exit函数终止进程。

_exit函数

使用_exit函数退出进程的方法并不常用,_exit函数也可以在代码中任何地方退出进程,但是_exit函数会直接终止进程,并不会在退出进程前做任何收尾工作。 

return、exit和_exit之间的联系

执行return 0等同于执行exit(0),因为调用main函数运行结束后,会将main函数的返回值当做exit的参数来调用exit函数。

使用exit函数退出进程前,exit函数会先执行用户定义的清理函数、冲刷缓冲,关闭流等操作,然后再调用_exit函数终止进程。

进程异常退出

情况一:向进程发生信号导致进程异常退出。

在进程运行期间向进程发生kill -9信号使得进程异常退出,或是使用Ctrl+C使得进程异常退出等。

情况二:代码错误导致进程运行时异常退出。

代码当中存在野指针问题使得进程运行时异常退出,或是出现除0情况使得进程异常退出等。

三、进程等待

进程等待的必要性

1.子进程退出,父进程如果不读取子进程的退出信息,子进程就会变成僵尸进程,进而造成内存泄漏。

2.进程一旦变成僵尸进程,就算用kill -9命令也无法将其杀死,因为谁也无法杀死一个已经死去的进程。

3.对于一个进程来说,最关心自己的就是父进程,因为父进程需要知道自己派给子进程的任务完成情况。

4.父进程需要通过进程等待的方式,回收子进程资源,获取子进程的退出信息。

获取子进程status

接下来要讲到的wait和waitpid函数,都有一个status函数,这个函数是一个输出型参数,由操作系统进行填充。

如果对status参数传入NULL,表示不关心子进程的退出状态信息。否则,操作系统会通过该参数,将子进程的退出信息反馈给父进程。

status是一个int变量,但status不能简单的当做整形来看待,status的不同比特位所代表的信息不同,具体细节如下(只研究status低16比特位)

在status的低16比特位中,高8位表示退出状态,也就是退出码。进程若是被信号所杀,则低7位为终止信号,第8位表示core dump标志。

考虑到status的16位比特位,我们可以通过位操作得到status的进程退出码和退出信号。

exitCode = (status >> 8) & 0xFF; //退出码
exitSignal = status & 0x7F;      //退出信号

在操作系统中,也提供了两个宏来获取退出码和退出信号:

WIFEXITED(status) :用于查看进程是否正确退出,检查是否收到信号。

WEXITSTATUS(status):用于获取进程的退出码。

exitNormal = WIFEXITED(status);  //是否正常退出
exitCode = WEXITSTATUS(status);  //获取退出码

需要注意的是:如果一个进程非正常退出,说明是被进程杀掉了,那么退出码就没有意义了。 

进程等待的方法

wait函数

函数:pid_t wait(int * status)

作用:等待任意子进程

返回值:等待成功返回被等待进场的pid,等待失败返回-1

参数:输出型参数,获取子进程的退出状态,不关心可设置为NULL

例如下列例子:创建子进程后,父进程可以使用wait函数一直等待子进程,直到子进程退出后读取子进程的退出信息。

waitpid函数

函数: pid_t waitpid(pid_t pid,int* status,int otions)

作用:等待指定子进程或任意子进程

返回值:

        1.等待成功返回被等待进程的pid

        2.如果选项为WNOHANG,而调用中waitpid发现没有已退出的子进程可收集,则返回0

        3.如果调用中出错,返回-1,这是errno会被设置成相应的值以指示错误所在

参数:

        1.pid:待等待子进程的pid,若设置为-1,则等待任意子进程。

        2.status:输出型参数,获取子进程的退出状态,不关心可设置为NULL

        3.option:当设置成WNOHANG时,若等待的子进程没有结束,则waitpid函数直接返回0,不会继续等待;若正常结束,则返回子进程的pid。

在下图也可以看到,使用kill命令杀掉进程的话,父进程也可以等待成功

多进程创建以及等待的代码模型

上面的例子都是父进程创建及等待一个子进程的,其实我们还可以同时创建多个子进程,然后让父进程等待子进程退出,这叫做多进程创建以及等待的代码模型。

例如,以下代码中同时创建了10个子进程,同时将子进程的pid放入到ds数组中,并将这10个子进程退出时的退出码设置为该子进程pid在数组idx中的下标,之后父进程再使用waitpid函数指定等待这10个子进程。

#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/wait.h>
int main()
{
	pid_t idx[10];//用来保存子进程pid
	for (int i = 0; i < 10; i++){
		pid_t id = fork();
		if (id == 0){
			//child
			printf("child process created successfully...PID:%d\n", getpid());
			sleep(3);
			exit(i);//将退出码设置为pid在数组的下标
		}
		//father
		idx[i] = id;//将pid保存至数组
	}
	for (int i = 0; i < 10; i++){
		int status = 0;
		pid_t ret = waitpid(idx[i], &status, 0);//等待子进程返回的pid
		if (ret >= 0){
			//wait child success
			printf("wiat child success..PID:%d\n", idx[i]);
			if (WIFEXITED(status)){//如果退出正常
				//exit normal
				printf("exit code:%d\n", WEXITSTATUS(status));
			}
			else{//如果退出不正常
				//signal killed
				printf("killed by signal %d\n", status & 0x7F);
			}
		}
	}
	return 0;
}

基于非阻塞接口的轮询检测方案

上面的例子都是父进程在等待子进程却不能做别的事情,这叫做阻塞等待。

实际上我们可以让父进程不要一直等待子进程退出,即非阻塞等待。

#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/wait.h>
int main()
{
	pid_t id = fork();
	if (id == 0){
		//child
		int count = 3;
		while (count--){
			printf("child do something...PID:%d, PPID:%d\n", getpid(), getppid());
			sleep(3);
		}
		exit(0);//子进程退出
	}
	//father
	while (1){
		int status = 0;
		pid_t ret = waitpid(id, &status, WNOHANG);//获取子进程返回pid
		if (ret > 0){//子进程已经成功结束
			printf("wait child success...\n");
			printf("exit code:%d\n", WEXITSTATUS(status));
			break;
		}
		else if (ret == 0){//子进程还未结束,父进程可以做别的事情
			printf("father do other things...\n");
			sleep(1);
		}
		else{//等待失败
			printf("waitpid error...\n");
			break;
		}
	}
	return 0;
}

 

四、进程替换

替换原理 

用fork创建子进程后,子进程执行的是和父进程相同的程序(但有可能执行不同的代码分支),若想让子进程执行另一个程序,往往需要调用一个exec函数。

当进程调用exec函数时,该进程的用户空间代码和数据完全被新程序替换,并从新程序的启动例程开始执行。

当进行进程程序替换时,有没有创建新的进程?

进程程序替换之后,该进程对应的PCB、进程地址空间以及页表等数据结构都没有发生改变,只是进程在物理内存当中的数据和代码发生了改变,所以并没有创建新的进程,而且进程程序替换前后该进程的pid并没有改变。

子进程进行进程程序替换后,会影响父进程的代码和数据吗? 

子进程刚被创建时,与父进程共享代码和数据,但当子进程需要进行进程程序替换时,也就意味着子进程需要对其数据和代码进行写入操作,这时就需要将父子进程共享的代码和数据进行写时拷贝,此后父子进程的代码和数据也就分离了,因此不会影响。

替换函数

替换函数有六种以exec开头的函数,统称为exec函数:

一、int execl(const cahr *path, const char 8arg, ...);

第一个参数是要执行程序的路径,第二个参数是可变参数列表,表示你要如何执行这个程序,并以NULL结尾。

例如,要执行ls程序:

execl("/usr/bin/ls", "ls", "-a", "-i", "-l", NULL);

二、int execlp(const char *file, const char *arg, ...);

第一个参数是要执行程序的名称,第二个参数是可变参数列表,表示你要如何执行这个程序,并以NULL结尾。

 例如,要执行ls程序:

execlp("ls", "ls", "-a", "-i", "-l", NULL);

三、int execle(const char *path, char *const arg, ...char *const envp[ ]);

第一个参数是要执行路径的路径,第二个参数是可变参数列表,表示你要如何执行这个程序,以NULL结尾,第三个参数是你自己设置的环境变量。

例如,设置了MYVAL环境变量,在mycmd程序内部就可以使用该环境变量。

char *myenvp[] = {"MYVAL=2024",NULL};
execle("./mycmd","mycmd",NULL,myenvp);

四、int execv(const char *path, char *const argv[ ]);

第一个参数是要执行程序的路径,第二个参数是一个指针数组,数组当中的内容表示你要如何执行这个程序,数组以NULL结尾。

例如,要执行的是ls程序。

char* myargv[] = { "ls", "-a", "-i", "-l", NULL };
execv("/usr/bin/ls", myargv);

五、int execvp(const char *file, char *const argv[ ]);

第一个参数是要执行程序的名字,第二个参数是一个指针数组,数组当中的内容表示你要如何执行这个程序,数组以NULL结尾。 

char* myargv[] = {"ls", "-a", "-i", "-l", NULL};
execvp("ls",myargv);

六、 int execve(const char *path, char *const argv[ ], char *const envp[ ]);

1

第一个参数是要执行程序的路径,第二个参数是一个指针数组,数组当中的表示你要如何执行这个程序,数组以NULL结尾,第三个参数是你自己设置的环境变量。

例如,设置了MYVAL变量,在mycmd程序内部就可以使用该环境变量。

char* myargv[] = { "mycmd", NULL };
char* myenvp[] = { "MYVAL=2021", NULL };
execve("./mycmd", myargv, myenvp);

函数解释

1、这些函数如果调用成功,那么加载指定的程序并从启动代码开始执行,不再返回。

2、如果调用出错,返回-1

所以, exec函数只要返回了,那么意味着调用失败。

命名理解

l(list):表示列表采用列表形式

v(vector):表示参数采用数组形式

p(path):表示能自动搜索环境变量PATH进行程序查找

e(env):表示可以传入自己设置的环境变量。

函数名参数格式是否带路径是否使用当前环境变量
execl

       列表

execlp列表
execle列表否,自己配置环境变量
execv数组
execvp数组
execve数组否,自己配置环境变量

实际上,只有execve才是真正的系统调用,其它五个函数最终都调用的是execve,所以execve在man手册的第2节,而其他五个函数在man手册的第3节,也就是说其他五个函数实际上是对系统调用execve进行了封装,以满足不同用户的不同调用场景。

下图为exec函数间的关系:

制作一个简单的shell 

shell就是一个命令行解释器,运行原理是:当有命令需要执行时,shell创建子进程,让子进程执行命令,而shell只需等待子进程退出即可。

 所以shell的逻辑很简单,循环下列五个步骤即可:

1.获取命令行。

2.解析命令行。

3.创建子进程。

4.替换子进程。

5.等待子进程退出。

还记得我们知道了需要执行的程序和选项,需要调用什么函数吗:

int execvp(const char *file, char *const argv[ ]);

第一个参数是要执行程序的名字,第二个参数是一个指针数组,数组当中的内容表示你要如何执行这个程序,数组以NULL结尾。

在制作前,我们先熟悉一下下列函数:

1.getuid():获取当前用户ID;

2.getpwuid(用户ID):获取用户的用户名、主目录、登录shell等信息,并返回一个指向passwd结构的指针,指针内部包含上述的信息。

3.gethostname(char *name, size_t len):获取当前进程所在主机的主机名,并将其存储在提供的name中。

4.gewcwd(char *path,):获取调用进程的当前工作目录的绝对路径,并将其存放在提供的path路径中。

5.strtok(char *str, const char *delim):用指定的delim来分割str字符串,第一次输入的str,第二次则需要改为NULL,返回值为被切割的字符串指针。 

  #include<stdio.h>
  #include<pwd.h>
  #include<string.h>
  #include<unistd.h>
  #include<stdlib.h>
  #include<sys/types.h>
  #include<sys/wait.h>
  
  #define LEN 1024
  #define NUM 32
  
  int main()
  {
    char cmd[LEN];
    char* myargv[NUM];
    char hostname[32];
    char pwd[128];
    while(1)
    {
      //获取命令信息
      struct passwd* pass = getpwuid(getuid());
      gethostname(hostname,sizeof(hostname)-1);
      getcwd(pwd,sizeof(pwd)-1);
      int len = strlen(pwd);
      char *p = pwd + len -1;
      while(*p != '/')
      {
        --p;
      }
      p++;    
      //打印命令提示信息    
      printf("[%s@%s %s]$",pass->pw_name, hostname, p);
      
      //读取命令    
      fgets(cmd,LEN,stdin);                                                                                                                                                                 
      cmd[strlen(cmd)-1] = '\0';    
      
      //拆分命令    
      myargv[0] = strtok(cmd," ");//先将指令拆分    
        //再将选项拆分    
      int i = 1;    
      while(myargv[i] = strtok(NULL," "))    
      {    
        ++i;    
      }
      
      //创建子进程并读取命令
      pid_t id = fork();
      if(id == 0)
      {
        execvp(myargv[0],myargv);
        exit(1);
      }
      
      //shell
      int status = 0;
      pid_t ret = waitpid(id,&status,0);
      if(ret > 0)
      {
        //每次执行完都打印退出码,让用户知道这是一个自制的简易shell
        printf("exit code:%d\n",WEXITSTATUS(status));
      }
    }
    return 0;
  }

 下面是程序运行结果:

可以看到带$结尾的就是我们的简易shell,我们只实现了部分功能,可以看到ls命令,自带shell时有颜色,而我们的简易shell并没有,但是这也已经很好了。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值