【Linux】进程控制深度了解

> 作者简介:დ旧言~,目前大二,现在学习Java,c,c++,Python等

> 座右铭:松树千年终是朽,槿花一日自为荣。

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

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

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

🌟前言

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

⭐主体

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

🌙进程创建

💫初识fork函数

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

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

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

举个栗子:

cpp 复制代码
#include <stdio.h>
#include <stdlib.h>
#include <sys/types.h>
#include <unistd.h>

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;
}

运行结果:

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

过程分析:

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

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

💫fork函数返回值

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

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

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

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

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

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

💫写时拷贝

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

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:

cpp 复制代码
#include <stdio.h>

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

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

cpp 复制代码
echo $?

过程分析1:

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

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

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

举个栗子2:

cpp 复制代码
#include <stdio.h>
#include <string.h>

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

运行结果2:

过程分析2:

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

💫进程常见退出方法

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

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

异常退出:

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

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

2.exit

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

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

举个栗子:

cpp 复制代码
#include <stdio.h>
#include <stdlib.h>

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

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

运行结果:

结果分析:

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

3._exit函数

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

举个栗子:

cpp 复制代码
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>

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

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

运行结果:

结果分析:

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

💫进程异常退出

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

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

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

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

🌙进程等待

💫进程等待的必要性

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

💫获取子进程状态status

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

如何理解参数status?

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

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

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

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

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

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

💫进程等待的方法

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

举个栗子:

cpp 复制代码
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/wait.h>
#include <sys/types.h>
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;
}

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

cpp 复制代码
while :; do ps axj | head -1 && ps axj | grep proc | grep -v grep;echo "######################";sleep 1;done

运行结果:

结果分析:

  • 创建子进程后,父进程可使用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会被设置成相应的值以指示错误所在。

举个栗子:

cpp 复制代码
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/wait.h>
#include <sys/types.h>
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;
}

运行结果:

结果分析:

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

🌙进程程序替换

💫替换原理

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

1.创建子进程的目的?

执行父进程磁盘代码的一部分;

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

2.程序替换的本质

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

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

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

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

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

💫替换函数

cpp 复制代码
一、int execl(const char *path, const char *arg, ...);

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

举例:

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

cpp 复制代码
二、int execlp(const char *file, const char *arg, ...);

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

举例:

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

cpp 复制代码
三、int execle(const char *path, const char *arg, ..., char *const envp[]);

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

举例:

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

execle("./mycmd", "mycmd", NULL, myenvp);

cpp 复制代码
四、int execv(const char *path, char *const argv[]);

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

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

execv("/usr/bin/ls", myargv);

cpp 复制代码
五、int execvp(const char *file, char *const argv[]);

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

举例:

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

execvp("ls", myargv);

cpp 复制代码
六、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):表示可以传入自己设置的环境变量。

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

下图为exec系列函数族之间的关系:

🌟结束语

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

相关推荐
Lovyk1 小时前
Linux 正则表达式
linux·运维
Fireworkitte2 小时前
Ubuntu、CentOS、AlmaLinux 9.5的 rc.local实现 开机启动
linux·ubuntu·centos
sword devil9002 小时前
ubuntu常见问题汇总
linux·ubuntu
ac.char2 小时前
在CentOS系统中查询已删除但仍占用磁盘空间的文件
linux·运维·centos
淮北也生橘124 小时前
Linux的ALSA音频框架学习笔记
linux·笔记·学习
华强笔记7 小时前
Linux内存管理系统性总结
linux·运维·网络
十五年专注C++开发8 小时前
CMake进阶: CMake Modules---简化CMake配置的利器
linux·c++·windows·cmake·自动化构建
phoenix09818 小时前
ansible部署lnmp-allinone
linux·运维·ansible
winds~9 小时前
【git】 撤销revert一次commit中的某几个文件
linux·c++
iY_n9 小时前
Linux网络基础
linux·网络·arm开发