【Linux】进程控制深度了解

> 作者简介:დ旧言~,目前大二,现在学习Java,c,c++,Python等
> 座右铭:松树千年终是朽,槿花一日自为荣。

> 目标:熟练掌握Linux下的进程控制

> 毒鸡汤:在等待的日子里,刻苦读书,谦卑做人,养得深根,日后才能枝叶茂盛。

> 望小伙伴们点赞收藏✨加关注哟 

前言

        最早的时候我们学习了进程的状态,进程优先级和进程切换,当时不把进程控制加在里面,这里我们单独把它拉出来讲解,学习完本章对进程的板块算是熟练掌握了,咱们话不多说,直接进入今天的主题:【Linux】进程控制深度了解。

【Linux】进程控制深度了解_第1张图片

⭐主体

我们从以下学习【Linux】进程控制深度了解。

【Linux】进程控制深度了解_第2张图片

进程创建

初识fork函数

  • fork函数的作用:是从已存在进程中创建一个新进程,新进程为子进程,而原进程为父进程。
  • fork函数的返回值:在子进程中返回 0 ,父进程中返回子进程pid,子进程创建失败返回 1 。
#include 
pid_t id = fork(void);

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

  • 分配新的内存块和内核数据结构给子进程。
  • 将父进程部分数据结构内容拷贝至子进程。
  • 添加子进程到系统进程列表当中。
  • fork返回,开始调度器调度。

举个栗子:

#include 
#include 
#include 
#include 

int main(void)
{
	pid_t pid;
	printf("Before: pid is %d\n", getpid());
	if ((pid = fork()) == -1)
	{
		perror("fork()");
		exit(1);
	}
	printf("After:pid is %d, fork return %d\n", getpid(), pid);
	sleep(1);
	return 0;
}

运行结果:

【Linux】进程控制深度了解_第3张图片

可以看到fork前只输出了一次,fork之后输出了两次。

过程分析:

  • Before是由父进程打印的。
  • 调用fork函数之后打印的两个After,则分别由父进程和子进程两个进程执行。
  • fork之前父进程独立执行,而fork之后父子两个执行流分别执行。

注意: fork之后,父进程和子进程谁先执行完全由调度器决定。

fork函数返回值

  • 子进程返回0。
  • 父进程返回的是子进程的pid。

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

        一个父进程可以创建多个子进程,而一个子进程只能有一个父进程,因此对于子进程来说父进程是不需要被标识的,而对于父进程来说,子进程是需要标识的,因为父进程创建子进程的目的是让其指向对于的任务,只有直到了各个子进程的PID,才能更有效率的工作。

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

        父进程调用fork函数后,为了创建子进程,fork函数内部会进行一系列复杂的操作,包括创建子进程PCB,虚拟地址空间,创建子进程对应的页表等等。子进程创建完毕后,操作系统还需要将子进程的进程控制块添加到系统进程列表中。

【Linux】进程控制深度了解_第4张图片

        在fork函数内部执行return语句之前,子进程就已经创建完毕了,那么之后的return语句不仅父进程需要执行,子进程也同样需要执行,这就是fork函数有两个返回值的原因。

写时拷贝

  • 当子进程刚刚被创建时,子进程和父进程的数据和代码是共享的。
  • 此时父子进程的代码和数据通过页表映射到物理内存的同一块空间。
  • 当父进程或子进程需要修改数据时,才将父进程的数据在内存当中拷贝一份,再进行修改。

【Linux】进程控制深度了解_第5张图片

1、为什么数据要进行写时拷贝?

多进程运行需要独享各种资源,多进程运行期间是互不干扰,不能让子进程的修改影响到父进程。

2、为什么不在创建子进程的时候就进行数据的拷贝?

子进程不一定会使用父进程的所有数据,并且在子进程不对数据进行写入的情况下,没有必要对数据进行拷贝,我们应该按需分配,在需要修改数据的时候再分配(延时分配),这样可以高效的使用内存空间。

3、代码会不会进行写时拷贝?

90%的情况下是不会的,但这并不代表代码不能进行写时拷贝,例如在进行进程替换的时候,则需要进行代码的写时拷贝。

问题1. 地址空间不隔离
所有程序都直接访问物理内存,程序使用的物理空间不是相互隔离的。万一进程越界进行非法操作,这样是非常不安全的。

问题2. 内存使用效率低
没有有效的内存管理机制,通常执行一个程序时,监控程序需要将其整个程序装入内存然后开始执行。

问题3. 程序运行的地址不确定
因为每次需要装入运行时,我们都需要给他分配一块足够大的物理空间,而这个物理空间是不确定的。

fork常规用法

  • 一个父进程希望复制自己,使父子进程同时执行不同的代码段。例如,父进程等待客户端请求,生成子进程来处理请求。
  • 一个进程要执行一个不同的程序。例如子进程从fork返回后,调用exec函数。

fork调用失败原因

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

进程终止

进程退出场景

  • 代码运行完毕,结果正确。
  • 代码运行完毕,结果不正确。
  • 代码异常终止(进程崩溃)。

进程退出码

  • 我们都知道main函数是代码的入口,但实际上main函数只是用户级别代码的入口,main函数也是被其他函数调用的,例如在VS2013当中main函数就是被一个名为__tmainCRTStartup的函数所调用,而__tmainCRTStartup函数又是通过加载器被操作系统所调用的,也就是说main函数是间接性被操作系统所调用的。
  • 既然main函数是间接性被操作系统所调用的,那么当main函数调用结束后就应该给操作系统返回相应的退出信息,而这个所谓的退出信息就是以退出码的形式作为main函数的返回值返回,我们一般以0表示代码成功执行完毕,以非0表示代码执行过程中出现错误,这就是为什么我们都在main函数的最后返回0的原因。
  • 当我们的代码运行起来就变成了进程,当进程结束后main函数的返回值实际上就是该进程的进程退出码,我们可以使用echo $?命令查看最近一次进程退出的退出码信息。

举个栗子1:

#include 

int main()
{
	printf("hello linux\n");
	return 0;
}

使用指令查看进程退出码1:

echo $?

【Linux】进程控制深度了解_第6张图片

过程分析1:

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

因为代码执行成功只有一种情况,成功了就是成功了,而代码执行错误却有多种原因,例如内存空间不足、非法访问以及栈溢出等等,我们就可以用这些非0的数字分别表示代码执行错误的原因。

C语言当中的strerror函数可以通过错误码,获取该错误码在C语言当中对应的错误信息:

举个栗子2:

#include 
#include 

int main()
{
	int i = 0;
	for(i = 0;i < 100;i++)
	{
		printf("%d:%s\n",i,strerror(i));
	}
	return 0;
}

运行结果2:

【Linux】进程控制深度了解_第7张图片

过程分析2:

        实际上Linux中的ls、pwd等命令都是可执行程序,使用这些命令后我们也可以查看其对应的退出码。可以看到,这些命令成功执行后,其退出码也是0。

【Linux】进程控制深度了解_第8张图片

进程常见退出方法

正常终止(可以通过 echo $? 查看进程退出码):

  • 从main返回,return退出
  • 调用exit
  • _exit

异常退出:

  • ctrl+c,信号终止
1.return

return是一种常见的退出进程方法。执行return n等同于执行exit(n),因为调用main的运行时函数,会将main的返回值当做 exit的参数。

【Linux】进程控制深度了解_第9张图片

2.exit

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

  1. 执行用户通过atexit或on_exit定义的清理函数。
  2. 关闭所有打开的流,所有的缓存数据均被写入。
  3. 调用_exit函数终止进程。

举个栗子:

#include 
#include 

void show()
{
	printf("hello linux\n");
	exit(1);
}

int main()
{
	show();
	return 0;
}

运行结果:

【Linux】进程控制深度了解_第10张图片

结果分析:

exit终止进程前会将缓冲区当中的数据输出。

3._exit函数

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

举个栗子:

#include 
#include 
#include 

void show()
{
	printf("hello linux");
	_exit(2);
}

int main()
{
	show();
	return 0;
}

运行结果:

【Linux】进程控制深度了解_第11张图片结果分析:

使用_exit终止进程,则缓冲区当中的数据将不会被输出。

【Linux】进程控制深度了解_第12张图片

进程异常退出

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

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

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

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

进程等待

进程等待的必要性

  • 子进程退出,父进程如果不读取子进程的退出信息,子进程就会变成僵尸进程,进而造成内存泄漏。
  • 进程一旦变成僵尸进程,那么就算是kill -9命令也无法将其杀死,因为谁也无法杀死一个已经死去的进程。
  • 对于一个进程来说,最关心自己的就是其父进程,因为父进程需要知道自己派给子进程的任务完成的如何。
  • 父进程需要通过进程等待的方式,回收子进程资源,获取子进程的退出信息。

获取子进程状态status

        下面会介绍有关进程等待的两个函数wait和waitpid,它们都有一个status参数,改参数是一个输出型参数,由操作系统进行填充。如果对该参数传入NULL,表示不关心子进程的退出状态信息。反之,操作系统会通过该参数,将子进程的退出状态信息反馈给父进程。

如何理解参数status?

        status实际上是一个整型变量,但是status不能当作整型里看待,因为status的不同的比特位所代表的信息不同(只研究低16位比特位)。在status的低16位比特位中,高8位表示进程的退出状态,即退出码。进程若是被信号所杀,低7位表示终止信号,而第8位是core dump标志。需要注意的是,当一个进程非正常退出时,说明该进程是被信号所杀,那么该进程的退出码也就没有意义了。

【Linux】进程控制深度了解_第13张图片

我们通过一系列位操作,就可以根据status得到进程的退出码和退出信号。

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

对于此,系统当中提供了两个宏来获取退出码和退出信号。

  • WIFEXITED(status):用于查看进程是否是正常退出,本质是检查是否收到信号。
  • WEXITSTATUS(status):用于获取进程的退出码。
exitNormal = WIFEXITED(status);  //是否正常退出
exitCode = WEXITSTATUS(status);  //获取退出码

需要注意的是,当一个进程非正常退出时,说明该进程是被信号所杀,那么该进程的退出码也就没有意义了。

进程等待的方法

1.wait方法
  • 函数原型:pid_t wait(int* status);
  • 作用:等待任意子进程。
  • 返回值:等待成功返回被等待进程的pid,等待失败返回-1。
  • 参数:输出型参数,获取子进程的退出状态,不关心可设置为NULL。

举个栗子:

#include 
#include 
#include 
#include 
#include 
int main()
{
	pid_t id = fork();
	if (id == 0) 
	{	
		int count = 10;
		while (count--) 
		{
			printf("I am child...PID:%d, PPID:%d\n", getpid(), getppid());
			sleep(1);
		}
		exit(0);
	}
	int status = 0;
	pid_t ret = wait(&status);
	if (ret > 0) 
	{	
		printf("wait child success...\n");
		if (WIFEXITED(status)) 
		{		
			printf("exit code:%d\n", WEXITSTATUS(status));
		}
	}
	sleep(3);
	return 0;
}

利用指令对进程进行实时监控:

while :; do ps axj | head -1 && ps axj | grep proc | grep -v grep;echo "######################";sleep 1;done

运行结果:

【Linux】进程控制深度了解_第14张图片

结果分析:

  • 创建子进程后,父进程可使用wait函数一直等待子进程,直到子进程退出后读取子进程的退出信息。
  • 这时我们可以看到,当子进程退出后,父进程读取了子进程的退出信息,子进程也就不会变成僵尸进程了。
1.waitpid方法
  • 函数原型:pid_t waitpid(pid_t pid, int* status, int options);
  • 作用:等待指定子进程或任意子进程。
  • 返回值:
  1. 等待成功返回被等待进程的pid。
  2. 如果设置了选项WNOHANG,而调用中waitpid发现没有已退出的子进程可收集,则返回0。
  3. 如果调用中出错,则返回-1,这时errno会被设置成相应的值以指示错误所在。
  • 参数:
  1. 等待成功返回被等待进程的pid。
  2. 如果设置了选项WNOHANG,而调用中waitpid发现没有已退出的子进程可收集,则返回0。
  3. 如果调用中出错,则返回-1,这时errno会被设置成相应的值以指示错误所在。

举个栗子:

#include 
#include 
#include 
#include 
#include 
int main()
{
	pid_t id = fork();
	if (id == 0){
		//child          
		int count = 10;
		while (count--){
			printf("I am child...PID:%d, PPID:%d\n", getpid(), getppid());
			sleep(1);
		}
		exit(0);
	}
	//father           
	int status = 0;
	pid_t ret = waitpid(id, &status, 0);
	if (ret >= 0){
		//wait success                    
		printf("wait child success...\n");
		if (WIFEXITED(status)){
			//exit normal                                 
			printf("exit code:%d\n", WEXITSTATUS(status));
		}
		else{
			//signal killed                              
			printf("killed by siganl %d\n", status & 0x7F);
		}
	}
	sleep(3);
	return 0;
}

运行结果:

【Linux】进程控制深度了解_第15张图片

结果分析:

  • 创建子进程后,父进程可使用waitpid函数一直等待子进程(此时将waitpid的第三个参数设置为0),直到子进程退出后读取子进程的退出信息。
  • 被信号杀死而退出的进程,其退出码将没有意义。

进程程序替换

替换原理

        用fork 创建子进程后执行的是和父进程相同的程序 ( 但有可能执行不同的代码分支 ), 子进程往往要调用一种 exec 函数 以执行另一个程序。当进程调用一种exec 函数时 , 该进程的用户空间代码和数据完全被新程序替换 , 从新程序的启动 例程开始执行。调用exec 并不创建新进程 , 所以调用 exec 前后该进程的 id 并未改变。
【Linux】进程控制深度了解_第16张图片

1.创建子进程的目的?

执行父进程磁盘代码的一部分;
让子进程加载磁盘上指定的程序到内存中,执行新程序的的代码和数据。

2.程序替换的本质

将指定程序的代码和数据加载到指定的位置

3.进程替换的时候,有没有创建新的进程?

        我们知道一个进程被创建出来,OS会给它分配进程PCB,mm_struct,页表等信息,同时会将程序的代码和数据加载到物理内存。要知道进程程序替换之后,该进程的PCB,进程地址空间,页表等信息都不会发生改变,仅仅是把一个新的程序的数据和代码替换了原来进程的代码和数据,只是物理内存当中的数据和代码发生了改变,所以并没有创建新的进程,而且进程程序替换前后该进程的pid也没有改变。

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

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

替换函数

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

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

举例:

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

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

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

举例:

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

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

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

举例:

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

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

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

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[]);

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

举例:

char* myargv[] = { "mycmd", NULL };

char* myenvp[] = { "MYVAL=2021", NULL };

execve("./mycmd", myargv, myenvp);

函数解释

  • 这些函数如果调用成功,则加载指定的程序并从启动代码开始执行,不再返回。
  • 如果调用出错,则返回-1。
  • 所以 exec 函数只有出错的返回值而没有成功的返回值。

命名理解

  • l(list):表示参数采用列表的形式,一一列出。
  • v(vector):表示参数采用数组的形式。
  • p(path):表示能自动搜索环境变量PATH,进行程序查找。
  • e(env):表示可以传入自己设置的环境变量。

【Linux】进程控制深度了解_第17张图片

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

下图为exec系列函数族之间的关系:
【Linux】进程控制深度了解_第18张图片

   结束语

       今天内容就到这里啦,时间过得很快,大家沉下心来好好学习,会有一定的收获的,大家多多坚持,嘻嘻,成功路上注定孤独,因为坚持的人不多。那请大家举起自己的小手给博主一键三连,有你们的支持是我最大的动力,回见。

【Linux】进程控制深度了解_第19张图片

你可能感兴趣的:(linux)