Linux — 进程控制

目录

一、进程创建

最常用的就是系统调用fork,它是由内核直接提供的,是用户态进程创建新进程的核心入口。

1.fork基础知识

函数原型:

c 复制代码
#include<unistd.h>
pid_t fork(void);

pid_t 进程ID类型,本质是整型
特点: 调用一次,返回两次
返回值:

  • pid<0:fork失败(系统进程数满/内存不足)
  • pid==0:当前是子进程
  • pid>0:当前是父进程,返回值是子进程的PID

核心特性:

1.子进程拷贝父进程的地址空间和页表,以及代码(共享)数据(写时拷贝)

2.子进程从fork()之后的代码开始执行

3.父子进程调度顺序是不确定的,谁先跑由OS决定

4.各自变量独立,互不影响

示例:fork创建子进程


fork刚完成时的状态:父子进程都有独立的task_struct(进程控制块)和struct mm_struct(虚拟地址空间管理结构)。两个进程的页表都指向同一份物理内存空间,没有直接复制物理内存,页表根据引用计数来标记权限,计数>1时,也就是有多个进程都指向这块空间,如果任何一方尝试修改数据,都会触发内核的写保护异常,内核启动写时拷贝。
写时拷贝内核干了啥?

内核为子进程在物理内存中分配一块新的内存空间,复制原来共享数据的内容,更新子进程的页表项,原来指向父进程物理页的条目,改为指向新分配的物理页(也就是0x7fd)。把新页表项标记为可写,解除只读权限。最终父子进程独立起来了,父进程依然指向原来的物理页,数据不受影响,而子进程的页表指向新的物理页,后续两个进程修改,不会影响另一个进程。

💡为什么代码段不发生写时拷贝?
代码段本身就是只读的,不会被进程修改,所以即使多个进程共享,也不会触发写保护异常,因此不需要复制物理页。

2.fork底层逻辑

c 复制代码
int main()
{
	pid_t id=fork();
	if(id==0)
	{
		printf("I am child process! pid=%d,ppid=%d\n",getpid(),getppid());
		exit(0);//子进程完就直接终止掉
	}
	else if(id>0)
	{
		printf("I am father process! pid=%d,ppid=%d\n",getpid(),getppid());
	}
	else
	{
		perror("fork");
	}
	return 0;
}

fork的执行流程 :父进程执行到fork时,调用fork进入内核态,在内核里创建子进程(分配PCB,拷贝父进程mm_struct、页表等),此时还未出fork函数,但是子进程已经创建好了,然后在fork内部return,父子进程都要从fork里面return出来,子进程退出返回0,父进程退出返回创建好的子进程id,两个进程都从fork函数里返回用户态,所以就有 一次调用,两次返回

二、进程终止

1.进程终止是在做什么?

先释放掉曾经的代码和数据所占据的空间,再释放内核数据结构
进程终止本质上就是:

1.释放资源 :释放进程的代码段、数据段、栈、堆占用的内存空间,还有文件描述符等系统资源

2.进入僵尸状态(Z) :进程退出后,会保留一个task_struct结构体,里面存着进程退出信息(退出码,退出信号),等待父进程回收。只有父进程调用了wait()/waitpid()读取了退出状态,进程才会彻底从系统中消失。

2.进程终止的三种情况

情况 场景 退出码是否有效 本质
a.代码跑完,结果正确 正常执行结束,return 0或者exit(0) 有效 主动正常退出
b.代码跑完,结果不正确 执行完了,但逻辑错误,比如exit(非0)return 非0 有效 主动正常退出,但结果不符合预期
c.代码执行中出现异常,提前退出 段错误/除零错误/被信号杀死,比如kill -9 代码执行异常,提前终止,退出码无效,由退出信号决定 被动异常终止

示例:解引用空指针,会引发段错误异常,OS会发送11号信号终止掉该进程,出现异常,只用看退出信号就OK,此时退出码无效

总结:程序编译运行的时候,崩溃了。操作系统发现你的进程做了不该做的事情,OS杀了进程,一旦出现异常,退出码就没有意义了!进程出异常:本质是因为进程收到了OS发送给进程的信号(kill -11 pid)

进程的终止信息要如何看呢?
step1 .先确认是否异常:异常的话,就不用看退出码了,直接看退出信号是多少,就可以判断进程为什么异常了
step2.不是异常,就一定是代码跑完了,看退出码就行了

3.退出码和退出信号

3.1 进程退出的两种方式

1.正常退出:进程执行完代码,主动调用return/exit()退出。

2.异常退出:进程被信号(如:SIGKILLSIGSEGV)终止,或运行时出错崩溃。

所以父进程要判断子进程的退出情况,只需要两个关键信息:
退出码(eixt_code) :正常退出时的返回值
退出信号(eixt_signal):异常退出时,导致进程终止的信号编号

3.2 什么是退出码?

1.退出码的含义

退出码,0表示成功,非0表示失败

非0值(1~255):表示进程执行失败,不同的数值代表不同的失败原因。退出码也可以自定义。

2.父进程如何获取退出码?

bash中,通过$?环境变量可以获取最近一个进程的退出码

3.3 为什么父进程要关心子进程的退出码

1.判断任务是否成功:父进程可以通过退出码,知道子进程的任务是否完成。

2.定位失败原因:不同的非0退出码,对应不同的错误场景,方便排查问题。

3.还有一点就是知道子进程的退出情况是为用户负责,要告诉用户该进程的情况。

4.如何终止进程?

1.main函数里的return

  • 只有在main函数里调用return,才表示终止整个进程
  • 普通函数中的return,值表示函数结束,不会终止进程
  • 本质上,main函数里的return n等价于调用exit(n),它会先执行清理工作再退出

2.exit()函数(C库函数)

  • 在代码的任意位置调用exit(),都表示进程退出
  • exit是C标准库提供的函数,会在退出刷新并关闭所打开的缓冲区 ,底层调用的是系统调用_exit()退出进程

3._exit()是系统调用

  • 直接进内核,不碰用户态任何东西
  • 所以不会刷新C标准库缓冲区
  • 直接释放进程资源,结束

exit()_exit()的区别

1.使用_exit(),刷新不了用户态的缓冲区

bash 复制代码
printf("hello world");
//注意这里不加/n,加了/n的话,标准输入stdout默认是行缓冲,遇到换行符会自动刷新缓冲区,所以hello world会立刻输出,不会被_exit()丢弃
_exit(0);

结果:hello world没有被打印,丢弃了

2.使用exit(),可以刷新用户态的缓冲区

bash 复制代码
printf("hello world");
exit(0);

结果:exit在调用_exit之前会刷新用户态缓冲区,所以hello world会被打印

💡总结
exit()能刷新缓冲区,因为它是库函数,做了用户态清理
_exit()不刷新缓冲区,因为它是内核级退出,不管用户态

三、进程等待

1.为什么需要等待?

任何子进程,在退出的情况下,一般必须要被父进程进行等待。进程在退出的时候,如果父进程没有回收它,子进程会变成僵尸进程(PCB留在系统里,占用PID资源),所以父进程要通过等待,来解决子进程退出的僵尸问题,回收系统资源,获取子进程的退出信息。

2.如何等待?

waitwaitpid就是父进程用来回收子进程、获取子进程退出信息的系统调用。

2.1 wait函数

c 复制代码
pid_t wait(int* status);

作用:阻塞等待任意一个子进程退出,并回收它

返回值:等待成功返回退出子进程的PID,失败返回-1
status:输出型参数,用来接收子进程的退出状态(退出码+退出信号),传NULL表示不关心退出状态

c 复制代码
#include<stdio.h>
#include<sys/types.h>
#include<unistd.h>
#include<wait.h>

int main()
{
	printf("I am father!,pid=%d,ppid=%d\n",getpid(),getppid());
	pid_t id=fork();
	if(id==0)
	{
		//child
		printf("I am child!,pid=%d,ppid=%d\n",getpid(),getppid());
		exit(0);//子进程执行完直接退出
	}
	//father 
	pid_t rid=wait(NULL);//阻塞等待任意子进程 等价于 waitpid(-1,NULL,0)
	if(rid>0)
		printf("wait success!,rid=%d\n",rid);
	else
		printf("wait failed!\n");
	return 0;
}

2.2waitpid函数

c 复制代码
pid_t waitpid(pid_t pid,int* status,int options);

比wait更灵活,支持回收指定对象和非阻塞模式
pid参数:

pid=-1:等待任意子进程(和wait完全等价)

pid>0:等待PID等于pid的特定子进程
options参数:

options=0:默认阻塞等待(和wait一样)

options=WNOHANG:非阻塞模式,如果子进程还没有退出,函数直接返回0,不阻塞父进程

例子:

返回值:
返回值>0 :成功回收了一个子进程,返回值就是这个子进程的pid
返回值==0 :设置了WNOHANG非阻塞模式,调用成功,但子进程还没有退出,需要父进程下次再检查
返回值<0:等待失败,比如没有对应的子进程

两种等待方式:阻塞vs非阻塞的区别
阻塞等待(options=0):父进程调用waitpid后,如果子进程没有退出,父进程会一直停在这里,CPU时间片被占用,没法做其他事情。
非阻塞等待(options=WNOHANG):父进程调用waitpid后,不管子进程退没退出,函数都会立刻返回。如果子进程退出了,则返回子进程的pid,父进程处理回收;如果子进程没退出,则返回0,父进程可以取做自己的事情,隔一段时间再来调用waitpid检查。

示例:非阻塞等待(WNOHANG)+循环=非阻塞轮询

c 复制代码
#define N 3
typedef void(*func_t)();//无参无返回值的函数指针类型
func_t tasks[N]={NULL};
 
void PrintLog()
{
	printf("执行PrintLog()任务...\n");
}
void DownLoad()
{
	printf("执行DownLoad()任务...\n");
}
void MysqlDataSync()
{
	printf("执行MysqlDataSync()任务...\n");
}
  
void LoadTask()
{
	tasks[0]=PrintLog;
	tasks[1]=DownLoad;
	tasks[2]=MysqlDataSync;
}

  
void HandlerTask()
{                                                                                                                                                                                                     
	for(int i=0;i<N;i++)
	{
		tasks[i]();//回调方式 -- 回调 = 把函数当参数传进去
	}
}

void DoOtherThing()                                                                                                                                                                                       
{
	HandlerTask();
}
void ChildRun()
{
	int cnt=5;
	while(cnt--)
	{
		printf("I am child process,pid:%d,ppid:%d,cnt:%d\n",getpid(),getppid(),cnt);
		sleep(1);
	}
}

int main()
{
	printf("I am father process,pid:%d,ppid:%d\n",getpid(),getppid());
	pid_t id=fork();
	LoadTask();
	if(id==0)
	{
		//child                                                                                                                                                                                               
		ChildRun();
		printf("child quit...\n");
		exit(0);
	}
	else 
	{
		//father
		while(1)
		{
			int status=0;
			pid_t rid=waitpid(-1,&status,WNOHANG);//非阻塞等待
			sleep(1);//隔一段时间检查一次
			if(rid>0)
			{
				//wait success
				if(WIFEXITED(status))
				{
					printf("等待成功,进程正常退出,退出码为:%d\n",WEXITSTATUS(status));
				}
				else 
				{
					printf("等待失败,进程异常退出\n");
				}
				break;
			}
			else if(rid==0)
			{
				//子进程还没有退出,父进程可以干其他事情
				printf("child if running,father check next time\n");
				DoOtherThing();
			}
			else 
			{
				//等待失败
				printf("father failed!\n");
				break;
			}
		}
	}
	return 0;
}

3.status是怎么存子进程的退出信息

status是一个32位正数,里面按位存储了两种信息:
低7位 :存退出信号(如果进程被信号杀死,这里是信号编号;正常退出则为0)
次低8位 :退出码(范围0~255,全0到全1)

这种提取方式需要对status有一定的了解,不友好,所以Linux中提供了提取信息的工具宏

3.1 提取信息的工具宏

它们都是用来解析wait/waitpid拿到的status变量的

作用 关键限制
WIFEXITED(status) 判断进程释放正常退出 返回0为假(异常终止),非0为真(正常退出)
WEXITSTATUS(status) 提取正常退出的退出码 仅在WIFEXITED(status)为真时有效,否则结果无意义
WIFSIGNALED(status) 判断进程释放被信号杀死 0为假(正常退出),非0为真(被信号终止)
WTERMSIG(status) 提取杀死进程的信号编号 仅在WIFSIGNALED(status)为真时有效,否则结果无意义

3.2 四个宏的使用顺序(先判断,再提取)

重点:
WIFEXITEDWIFSIGNALED是互斥的,一个进程要么正常退出,要么被信号杀死,不可能同时满足,所以先判断WIFEXITED还是先判断WIFSIGNALED最终执行的分支都是一样的,输出结果不会出错

c 复制代码
int main()
{
	pid_t id=fork();
	if(id==0)
	{
		//child
		//什么都不干,正常退出返回0
		exit(10);
	}
	else
	{
		//father
		int status=0;
		pid_t rid=waitpid(-1,&status,0);
		if(rid>0)
		{
			//wait success
			if(WIFEXITED(status))
			{
				//正常退出->获取有效退出码
				printf("进程正常退出,退出码为%d\n",WEXITSTATUS(status));
			}
			else if(WIFSIGNALED(status))
			{
				//被信号杀死的->获取有效的信号编号
				printf("进程被信号:%d异常终止\n",WTERMSIG(status));
			}
		}
	}
	return 0;
}

四、进程程序替换

1. exec系列函数的 "进程程序替换"

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

int main()
{
    printf("testexec ... begin!\n");
    execl("/usr/bin/ls", "ls", "-l", "-a", NULL);
    printf("testexec ... end!\n");
    return 0;
}

现象:

为什么execl后面的代码没有执行?execl到底做了什么?

exec系列函数的本质,就是把当前进程的"代码和数据",直接替换成磁盘上另一个程序(比如ls)的代码和数据。但进程的PID、PCB(进程控制块)、地址空间都不变,只有代码段、数据段、堆、栈的内容被完全替换了。所以说执行了execl之后,进程原本的代码已经被替换了,后续的代码不会被执行了。

进程的程序替换!有没有创建新的进程呢?

答案是没有,你可以抽象的理解该进程被 "夺舍" 了。

图形解释理解:

execl执行后,不是同一块物理内存。旧的物理内存会被释放,新程序会加载到新的物理内存中,页表也会被重新映射。

补充:exec系列函数的特性

如果execl执行成功,那么当前进程已经变成了ls进程,原来的程序代码已经被覆盖了,根本找不到printf("testexec...end!\n")这条指令了,只有当它调用失败时,它才会返回-1,程序才会继续往后执行。

2. 6个exec系列函数

6个exec函数其实都是基于execve系统调用函数封装的,这6个函数的核心区别就是参数传递方式和环境变量的处理不同

1.exec系列函数的作用是:用新的程序,替换当前进程的代码段、数据段和栈段,进程的PID不变,但执行的程序变成了新的文件

2.命名规则
exec系列函数的名字是又后缀字母组合而成的,每个字母都有固定含义:

字母 全称 含义
l list 命令行参数以列表形式传入(可变参数以NULL结尾)
v vector 命令行参数以数组形式传入(char* argv[],数组最后以NULL元素结尾)
p path 会自动在PATH环境变量中查找程序,不用写完整路径
e environment 允许自定义环境变量,传给新程序

3.用法演示

①execl: 路径+列表传参

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

path :必须写完整路径
arg:程序名+参数列表,最后必须NULL

c 复制代码
//执行 ls -l -a
 execl("/usr/bin/ls","ls","-l","-a",NULL);

②execv原型: 路径+数组传参

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

path :完整路径
argv:参数数组,数组最后以NULL结尾

c 复制代码
//执行 ls -l -a
char* argv[]={"ls","-l",NULL};
execv("/usr/bin/ls",argv);

③execlp原型: 自动搜索PATH+列表传参

c 复制代码
int execlp(const char* file,const char* arg);

file: 程序名,自动在PATH中搜

c 复制代码
//执行ls -l 
execlp("ls","ls","-l",NULL);
//不用写完整路径,只用告诉它程序名即可,带p会自动在PATH里找的,后面还是列表传参

④execvp原型: 自动搜索PATH+数组传参

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

file: 程序名,自动在PATH中搜

c 复制代码
char* argv[]={"ls","-l",NULL};
execvp("ls",argv);

⑤execle原型: 路径+列表传参+自定义环境/系统环境

c 复制代码
int execl(const char* path,const char*arg,...,char* const envp[]);

比execl多了最后一个参数:自定义环境变量数组

c 复制代码
char* env[]={"PATH=/bin","USER=root",NULL};
execle("/bin/ls","ls","-l",NULL,env);

⑥execvpe原型:自动搜索PATH+数组传参+自定义环境/系统环境

c 复制代码
int execvpe(const char* file,char* const argv[],char* const envp[]);

file: 程序名,自动在PATH里找
argv: 参数数组
envp: 自定义环境变量数组

相关推荐
JoneBB1 小时前
ABAP Webservice连接
运维·开发语言·数据库·学习
皮卡狮1 小时前
Linux开发专属工具
linux
weixin_421725262 小时前
Linux 编程语言全解析:C、C++、Python、Go、Rust 谁更强?
linux·python·go·c·编程语言
Tolalal2 小时前
Vmware Ubuntu虚拟机扩容
linux·运维·ubuntu
咚为2 小时前
比AccessLog更全面的原生Nginx 日志记录
运维·nginx·junit
我星期八休息2 小时前
Linux系统编程—基础IO
linux·运维·服务器·c语言·c++·人工智能·算法
Shingmc32 小时前
【Linux】数据链路层
linux·服务器·网络
a752066283 小时前
零基础实操:小龙虾 AI OpenClaw 接入 Kimi 详细步骤
运维·服务器
bksczm3 小时前
文件描述符
linux