【从浅学到熟知Linux】进程控制上篇=>进程创建、进程终止与进程等待(含_exit与exit的区别、fork函数详解、wait与waitpid详解)

🏠关于专栏:Linux的浅学到熟知专栏用于记录Linux系统编程、网络编程等内容。

🎯每天努力一点点,技术变化看得见

文章目录


进程创建

fork函数

创建子进程时,需要使用系统调用函数fork。创建的新进程为子进程,而原进程为父进程。

该函数在子进程创建成功时,给父进程返回子进程pid,给子进程返回0;创建失败时,给父进程返回-1。

下面我们使用fork函数来创建子进程↓↓↓

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

int main()
{
	printf("before fork, pid = %d\n", getpid());
	pid_t id = fork();
	assert(id != -1);
	(void)id;

	printf("after fork, pid = %d, fork return %d\n", getpid(), id);
	return 0;
}

上面代码执行路径如下下图所示↓↓↓

进程调用fork。将会从用户空间转换到内核空间,内核做了如下内容:

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

②将父进程部分数据结构内容拷贝给子进程

③添加子进程到系统进程列表中

④fork返回,开始调度器调度

当进程调用fork后,就会有二进制代码相同的的两个进程,它们均运行到相同的地方,但两个代码相互独立,各自执行各自的。上面代码中,两个进程虽然执行了同一份代码,但由于父子进程的id值、pid值不同,故打印的结果不同。同时,这里到底是父进程先打印还是子进程先打印,取决于调度器的调度,哪个进程先被调度是不一定的。

★ps:创建子进程时,给子进程分配对应的内核结构,必须是子进程独有的,因为进程具有独立性。理论上,子进程也要有自己的代码和数据,但在子进程刚创建时,它的代码和数据是与父进程共享的。只有当子进程进行程序替换(修改代码)或对数据做修改等操作时,会发生写时拷贝,为子进程创建独立的存储空间。

fork的应用常见有哪些呢?

  • 一个父进程希望复制自己,使父进程同时执行不同的代码段。例如:父进程等待客户端请求,生成子进程来处理请求。
  • 一个进程要执行另一个完全独立的程序,类似于shell。

fork调用失败的原因有哪些?

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

写时拷贝

通常,父进程创建子进程后,在父进程不进行代码及数据的修改时,物理地址上的数据是父子进程共享的。

但当父进程或子进程尝试对代码或数据进行修改时,就会发生写时拷贝。若子进程尝试修改某个变量,此时发生写时拷贝,会给子进程开辟一个该变量的物理地址,子进程修改的结果保存在该物理地址上。

操作系统为什么选择写时拷贝呢?

①写时拷贝用的时候再分配空间,是一种延迟申请技术,是提高内存使用效率的表现,可以提高整机的内存使用效率;(不需要子进程复制父进程的全部数据,防止浪费空间)

②操作系统无法在代码执行前预知子进程哪些代码和父进程不同,因而无法预知子进程哪些代码和数据需要与父进程共享,哪些需要独立存储;

③因为写时拷贝的存在,父子进程不同的代码和数据得以分离,保证了进程的独立性。

进程退出

进程退出操作系统做了什么?

操作系统要释放进程申请的相关内核数据结构和对应的数据和代码(本质就是释放系统资源)。

进程退出场景

  • 代码执行完毕,结果正确
c 复制代码
#include <stdio.h>
int Add(int from, int to)
{
	int sum = 0;
	for(int i = from; i <= to; i++)
	{
		sum += i;
	}
	return sum;
}
int main()
{
	printf("Add 1 to 100 is %d\n", Add(0, 100));
	return 0;
}
  • 代码运行完毕,结果不正确
c 复制代码
#include <stdio.h>
int Add(int from, int to)
{
	int sum = 0;
	/*应该为i<=to*/
	for(int i = from; i < to; i++)
	{
		sum += i;
	}
	return sum;
}
int main()
{
	printf("Add 1 to 100 is %d\n", Add(0, 100));
	return 0;
}
  • 代码异常终止。即代码没有跑完,程序崩溃。
c 复制代码
#include <stdio.h>

int main()
{
	int* p = NULL;
	*p = 100;//野指针异常
	return 0;
}
  • 在程序执行结束时,我们会使用return语句返回一个数值作为main函数的返回值。这个返回值是做什么用的呢?

【例子1】小明参与了一场考试后,回家后给老爹汇报成绩。

如果小明考了100分(满分100),那么他的老爹并不会关心他为什么考了100分;但当小明考了3分,他的老爹会关心他考3分的原因。如果做出如下约定,每个数字标识不同的原因:

状态码 描述
1 生病了导致没考好
2 没好好学习导致没考好
... ...

在操作系统中,对于程序正常终止我们不做关心,但程序一旦出错,我们就需要知道程序出错的原因。在操作系统约定:如果进程返回的状态码为0,则标识进程正常结束;如果进程返回的状态码非0,则标识进程异常结束(不同的非零值标识不同的错误原因)。操作系统对于不同的状态码给了不同的错误描述信息,我们可以使用errno.h下的errno变量获取错误码,使用strerror(errno)获取错误码的错误描述。下面使用程序获取Linux的不同错误码及其描述↓↓↓

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

int main()
{
	for(int i = 0; i < 200; i++)
	{
		printf("[%d]->%s\n", i, strerror(i));
	}
	return 0;
}

★ps:我们自己写的程序可以不遵守操作系统提供的错误码与错误描述的对应关系,我们可以自定义错误码与错误码描述。

  • 那谁会关心当前进程的退出码呢?

子进程的退出码会返回给它的上一级进程,一般而言当前进程的父进程要关心当前进程的退出码,用于评判该子进程执行代码是否正确。例如我们登录Linux系统时使用的bash,它通过创建子进程程序来执行可执行文件(命令、用户程序等),它可以显示最近一次执行的子进程的退出码。我们可以使用echo $?来查看↓↓↓

  • 一旦程序异常终止,而不是正常终止的,此时的退出码还有意义吗?

【例子2】小明考试作弊

小明每门考试时都作弊考了100分。学校在颁发奖学金的时候会将小明计算在内吗?显然不会,因为小明的考试行为出现了异常,它的结果就不再被关心。

同理,如果程序出现了异常,此时的程序还来不及设置退出码,此时的退出码的数值是不确定的,故此时的退出码没有意义。

  • 程序出现异常,无法通过退出码评判程序异常原因,那要使用什么来查看程序的异常原因呢?

答案是信号。如果我们写出一个除0的错误程序,我们会看到什么的结果呢?

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

int main()
{
	int a = 8 / 0;
	return 0;
}

在该程序发生除0错误时,操作系统给执行该程序的进程发生了8号信号SIGFPE。我们可以使用kill -l查看所有的信号码与对应信号名↓↓↓

我们来验证一下,上面的程序就是收到8号SIGFPE才终止的。我们执行下面代码对应的可执行程序,并给对应的进程发送8号信号,验证上述进程确实收到了8号信号才异常终止↓↓↓

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

int main()
{
	while(1)
	{}
	return 0;
}


进程退出的常见方法

正常终止与异常终止

正常终止

  • 从main函数返回,即return+退出码
  • 调用exit
  • 调用_exit

异常终止

由于程序内部逻辑错误导致出现异常时,操作系统会给当前进程发送信号来终止程序,这种方式就是异常终止。当然,用户也可以通过kill -[信号编号] [进程pid]来给对应进程发送信号,使其异常终止。

★ps:ctrl + C本质是给当前bash中正在执行的前台进程发送2号信号,其他热键如ctrl + Z等本质也是通过给进程发送信号来实现的。

正常终止中的return+退出码及异常终止方式在上文已经有介绍了,下面我们来介绍一下exit、_exit方式如何使用,及它们的区别。

缓冲区的概念

在介绍exit与_exit前,我们要先理解一个概念------缓冲区↓↓↓

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

int main()
{
	printf("jammingpro is coding...");
	while(1)
	{}
	return 0;
}

从上面代码可以发现,在printf执行结束后,并没有打印字符串内容。这时因为,当我们使用C语言向显示器打印信息时,打印内容会被放入一片缓冲区中,如果缓冲区满了,或遇到\n时才会刷新缓冲区(C语言是行缓冲的,所以遇到\n会刷新缓冲区),将内容刷新到显示器中。当然,程序正常执行结束后(return 0),缓冲区的内容也会被刷新。

如果我们想让内容马上刷新到显示器上,可以使用stdio.h下的fflush手动刷新缓冲区↓↓↓

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

int main()
{
	printf("jammingpro is coding...");
	fflush(stdout);
	while(1)
	{}
	return 0;
}

exit与_exit的区别

_exit函数

status定义了进程的退出状态,父进程可以通过wait来获取status(关于wait,将在下文介绍)。

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

int main()
{
	printf("jammingpro");
	_exit(1);
	return 0;
}

从_exit的示例代码可以发现_exit执行并不会刷新缓冲区,故最后并没有打印"jammingpro"字符串。

exit函数

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

int main()
{
	printf("jammingpro");
	exit(1);
	return 0;
}

从_exit和exit对同一份代码的不同执行结果可以看出,_exit在退出时不会刷新缓冲区,而exit退出时会刷新缓冲区。

exit与_exit相同的是:它们两者均会调用内核的_exit;不同的是exit除了调用_exit,还做了其他工作:

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

②关闭所有打开的流,所有的缓存数据均被写入(即刷新缓冲区)

③再调用_exit

★ps:main函数中,return语句就是终止进程,即return+退出码;如果return语句在main函数中,则标识进程结束;如果return语句位于非main函数的其他函数中,则表示该函数执行结束,而不是进程结束。而exit可以在代码的任何地方调用,均表示终止当前进程。

进程等待

进程等待的必要性

当一个进程fork出子进程后,父进程没有对子进程的进行回收时,子进程就会变成僵尸状态。此时的子进程无法被任何信号终止(包括kill -9),因为信号是用于终止进程的,而处于僵尸状态的进程已经处于终止状态,只是对应的系统资源尚未被回收,谁也无法杀死一个已经死去的进程。

子进程的僵尸只能由其父进程解决,父进程通过进程等待的方式,回收子进程资源,获取子进程的退出信息(因为父进程需要知道自己派给子进程运行的结果是否正确,子进程是否正常退出等)。

★ps:C/C++程序执行完后,空间会被系统回收,则不会发生内存泄漏。但如果类似服务器程序,它一直跑,不会结束,则会发生内存泄漏。因为当前进程未执行结束,则系统资源不会被回收,而由于内存没有释放,导致大量无用的内存空间无法被申请使用,故产生内存泄漏。

进程等待方法

wait方法

当等待子进程成功时,会返回等待成功的子进程的pid,失败则返回-1。而传入的参数status为输出型参数,如果需要回去子进程的退出状态,则需要传入status;若不关心,则可以填写NULL。

c 复制代码
#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)
	{
		int cnt = 5;
		while(cnt)
		{
			printf("[%d]->I am child process! pid = %d, ppid = %d\n", cnt, getpid(), getppid());
			cnt--;
			sleep(1);
		}
		exit(0);
	}
	int status = 0;
	pid_t ret = wait(&status);
	if(ret > 0)
	{
		printf("wait %d success! status = %d\n", ret, status);
	}
	return 0;
}

从上面代码可以发现,当子进程在执行时,父进程并不会执行wait之后的if判断语句,而是阻塞在pid_t ret = wait(&status);处等待子进程退出。故wait是阻塞等待的。

这里的status表示什么含义呢?其实,status并不是像上面代码那样使用的。下面介绍status如何使用↓↓↓

status是一个输出型参数,它的值在调用wait及接下来将要介绍的waitpid时,由操作系统自动填充。如果传递NULL,表示不关心子进程的退出状态信息;否则,操作系统会通过该参数,将子进程的退出信息反馈给父进程。

status不能像上面的代码那样使用,即不能将status看作是整型,而要将status当作位图来看待。这里我们只研究status的低16位,它的低16位信息如下↓↓↓

①正常终止时,第8到第15位保存进程的退出状态

②异常终止时,第8到第15位保存进程的退出状态(但异常终止的退出状态不一定能正确标识进程的退出状态,这里的退出状态是无效的,直接忽略),第7位为coredump标志位,这里先忽略这一位,将于后面的文章中介绍,第0到7位存储的是当前进程收到的信号。

如果我们想获取子进程的退出码和退出信号,则上面的代码应该修改成下面这样↓↓↓

c 复制代码
#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)
	{
		int cnt = 5;
		while(cnt)
		{
			printf("[%d]->I am child process! pid = %d, ppid = %d\n", cnt, getpid(), getppid());
			cnt--;
			sleep(1);
		}
		exit(0);
	}
	int status = 0;
	pid_t ret = wait(&status);
	if(ret > 0)
	{
		printf("wait %d success! exitcode = %d sig = %d\n", ret, ((status >> 8) & 0xFF), (status & 0x7F));
	}
	return 0;
}

如果父进程创建了多个子进程,则wait等待的是哪一个进程呢?wait等待任意一个进程,只要等待到某个进程,则父进程不再阻塞于当前的wait语句上。下面代码演示了创建5个子进程,并用父进程循环回收5个子进程↓↓↓

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

int main()
{
	int i = 0;
	for(; i < 5; i++)
	{
		pid_t id = fork();
		if(id == 0)
		{
			int cnt = 5;
			while(cnt)
			{
				printf("[%d]->I am child process! pid = %d, ppid = %d\n", cnt, getpid(), getppid());
				cnt--;
				sleep(1);
			}
			exit(0);
		}
	}
	i = 0;
	while(; i < 5; i++)
	{
		int status = 0;
		pid_t ret = wait(&status);
		if(ret > 0)
		{
			printf("wait %d success! exitcode = %d sig = %d\n", ret, ((status >> 8) & 0xFF), (status & 0x7F));
		}
	}
	return 0;
}

waitpid方法

  • waitpid与wait相同,如果等待子进程成功,则会返回该子进程的pid,如果失败则返回-1;且一样可以传入status获取子进程的退出状态信息(用法与wait相同)。

waitpid多了两个参数,即pid和options。

  • 当指定pid为某个子进程的pid时,waitpid只会等待该子进程的pid,而不会等待其他子进程;如果将pid指定为-1,则表示等待任意一个子进程。
  • options设置为0时,表示阻塞等待,这与wait相同;如果设置为WNOHANG表示非阻塞式等待。关于阻塞等待和非阻塞等待,将于下文介绍。
c 复制代码
#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)
	{
		int cnt = 5;
		while(cnt)
		{
			printf("[%d]->child process, pid = %d\n", cnt, getpid());
			cnt--;
			sleep(1);
		}
	}
	int status = 0;
	pid_t ret = waitpid(id, &status, 0);
	if(ret == id)
	{
		printf("wait %d success! exitcode = %d, sig =%d\n", ret ,((status >> 8) & 0xFF), (status & 0x7F));
	}
	return 0;
}

下面我们来谈谈阻塞等待与非阻塞等待↓↓↓

【例子3】期末考前,临时抱佛脚的小明[阻塞等待篇]

因为明天要考操作系统,小明想要求助他们班的学霸李华,让他帮忙画重点并辅导一下,作为报酬,小明要请李华吃饭。小明来到李华家楼下,打电话给李华,李华告知他需要等一会儿,他还需要复习一会儿,需要50分钟左右。小明在李华家楼下不做其他事,他每1分钟给李华打电话,询问他是否可以出发去吃饭辅导了,知道李华下楼了,他才停止。

像这种循环等待,而不做其他事情,这种等待就称为阻塞式等待。上面的wait和waitpid的代码示例均是阻塞等待的,父进程在wait和waitpid函数上阻塞等待子进程退出,而不做其他事,等子进程执行结束才执行下面的if判断语句。

阻塞式等待可以及时回收子进程,但父进程花费大量时间阻塞等待子进程,只要CPU时间片轮到父进程,父进程就一直循环等待子进程,这样效率明显不高。

【例子4】考试前,临时抱佛脚的小明[非阻塞等待篇]

因为上次操作系统被李华辅导后,小明考了61分,至少没有挂科。操作系统考完的隔天要考数据结构,小明又找到李华帮他复习。小明来到李华家楼下,李华告诉他需要等待30分钟左右,这次小明每复习数据结构5分钟,才给李华打一次电话。

像这种再等待时,顺带做一下其他任务,这种称为非阻塞等待。非阻塞等待执行的其他任务不应花费过多时间,以免影响父进程对子进程的等待与回收(因为父进程等待子进程才是主要任务,执行其他代码只是顺带执行,不是重点)。

下面给出一个非阻塞式等待的代码↓↓↓

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

#define NUM 5

typedef void(*func_t)();
func_t funcSet[NUM];

void task1()
{
	printf("处理日志信息\n");
}

void task2()
{
	printf("安全检测任务\n");
}

void task3()
{
	printf("其他处理任务\n");
}

void loadTask()
{
	funcSet[0] = task1;
	funcSet[1] = task2;
	funcSet[2] = task3;
}

int main()
{
	loadTask();
	pid_t id = fork();
	if(id == 0)
	{
		int cnt = 5;
		while(cnt)
		{
			printf("[%d]->child process, pid = %d\n", cnt, getpid());
			cnt--;
			sleep(1);
		}
		exit(0);
	}
	int status = 0;
	while(1)
	{
		pid_t ret = waitpid(id, &status, WNOHANG);
		if(ret == id)
		{
			printf("wait %d success! exitcode = %d, sig =%d\n", ret ,((status >> 8) & 0xFF), (status & 0x7F));
			break;
		}
		else
		{
			int i = 0;
			for(; i < 3; i++)
			{
				funcSet[i]();
			}
		}
		usleep(500000);
	}
	return 0;
}

★ps:进程等待时,要保证父进程最后退出。

★ps:waitpid等待错误的时候会返回-1,当等待的子进程pid不是该进程的子进程时,则会返回-1;如果等待进程还未执行结束,则会返回0;等待成功则返回子进程的pid。

★ps:除了使用((status >> 8) & 0xFF),系统还提供了WEXITSTATUS(status)用于获取子进程的退出码;提供了WIFEXITED(status)判断子进程是否等待成功,还提供了其他宏函数,请使用man手册查阅。下面给出上面两个宏函数使用的代码↓↓↓

c 复制代码
#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)
	{
		int cnt = 5;
		while(cnt)
		{
			printf("[%d]->child process, pid = %d\n", cnt, getpid());
			cnt--;
			sleep(1);
		}
		exit(0);
	}
	int status = 0;
	pid_t ret = waitpid(id, &status, 0);
	if(WIFEXITED(status))
	{
		printf("wait %d success! exitcode = %d\n", ret ,WEXITSTATUS(status));
	}
	return 0;
}

wait/waitpid获取子进程信息原理

进程具有独立性,子进程的退出码等不也是子进程的数据吗?父进程凭什么拿到呢?wait/waitpid究竟是如何拿到子进程的退出状态的信息的?

当子进程处于僵尸状态时,系统至少要保留子进程的PCB,PCB中保存着子进程的退出码、收到的信号等退出信息。

当父进程在CPU上执行waitpid时,则进入内核态。处于内核态的执行语句有操作系统的权限,因此它可以读取子进程的退出信息,并将该退出信息填写入status。

🎈欢迎进入从浅学到熟知Linux专栏,查看更多文章。

如果上述内容有任何问题,欢迎在下方留言区指正b( ̄▽ ̄)d

相关推荐
Linux运维技术栈7 分钟前
Ansible(自动化运维)环境搭建及ansible-vault加密配置
运维·自动化·ansible
乔巴不是狸猫11 分钟前
第11周作业
linux
Bessssss1 小时前
centos权限大集合,覆盖多种权限类型,解惑权限后有“. + t s”问题!
linux·运维·centos
苹果醋32 小时前
Golang的文件加密工具
运维·vue.js·spring boot·nginx·课程设计
jwensh2 小时前
【Jenkins】Declarative和Scripted两种脚本模式有什么具体的区别
运维·前端·jenkins
silver6872 小时前
Linux 下的 GPT 和 MBR 分区表详解
linux
R-sz2 小时前
14: curl#6 - “Could not resolve host: mirrorlist.centos.org; 未知的错误“
linux·python·centos
大熊程序猿3 小时前
xxl-job docker 安装
运维·docker·容器
code_abc3 小时前
Shell 脚本编程基础:变量
linux
星如雨落3 小时前
Linux shell脚本对常见图片格式批量转换为PDF文件
linux·shell