进程管理详解

Posted 别呀

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了进程管理详解相关的知识,希望对你有一定的参考价值。

一、进程概念

1.1、进程与程序

程序只是一个普通文件,是一个机器代码指令和数据的集合,这些指令和数据存储在磁盘上的一个可执行映象中,所以,程序是一个静态的实体。简单的来说,程序是存放在磁盘文件中的可执行文件。

程序代表你期望完成某工作的计划和步骤,它还浮在纸面上,等待具体实现。而具体的实现过程就是有进程来完成的,进程可以理解人为是执行中的程序,它除了包含程序中的所有内容外,还包含一些额外的数据。简单的理解就是:进程是程序的执行实例


1.2、进程结构

Linux系统是一个多进程的系统,进程之间具有并行性、互不干扰的特点。

Linux中进程包含PCB(进程控制块)、程序以及程序所操纵的数据结构集,可分为“代码段”、“数据段”和“堆栈段”。

进程是程序的一次执行,是运行在自己的虚拟地址空间的一个具有独立功能的程序。进程是分配和释放资源的基本单位当程序执行时,系统创建进程,分配内存和CPU等资源;进程结束时,系统回收这些资源。进程由PCB(进程控制块)来描述。


1.3、进程三种基本状态

进程在运行中不断地改变其运行状态。通常,一个运行进程必须具有以下三种基本状态。

就绪(Ready)状态
当进程已分配到除CPU以外的所有必要的资源,只要获得处理机便可立即执行,这时的进程状态称为就绪状态。

执行(Running)状态
当进程已获得处理机,其程序正在处理机上执行,此时的进程状态称为执行状态。

阻塞(Blocked)状态
正在执行的进程,由于等待某个事件发生而无法执行时,便放弃处理机而处于阻塞状态。引起进程阻塞的事件可有多种,例如,等待I/O完成、申请缓冲区不能满足、等待信件(信号)等。


1.4、进程三种状态间的转换

一个进程在运行期间,不断地从一种状态转换到另一种状态,它可以多次处于就绪状态和执行状态,也可以多次处于阻塞状态。下图描述了进程的三种基本状态及其转换。

(1) 就绪→执行
处于就绪状态的进程,当进程调度程序为之分配了处理机后,该进程便由就绪状态转变成执行状态。

(2) 执行→就绪
处于执行状态的进程在其执行过程中,因分配给它的一个时间片已用完而不得不让出处理机,于是进程从执行状态转变成就绪状态。

(3) 执行→阻塞
正在执行的进程因等待某种事件发生而无法继续执行时,便从执行状态变成阻塞状态。

(4) 阻塞→就绪
处于阻塞状态的进程,若其等待的事件已经发生,于是进程由阻塞状态转变为就绪状态。


1.5、进程标识

操作系统会为每一个进程分配一个唯一的整型ID,作为进程的标识(pid)。进程除了自身的ID外,还有父进程ID,所有进程的祖先进程是同一个进程,它叫做init进程,ID为 1,init 进程是内核自举后的一个启动进程。

获取进程标识相关函数

#include <sys/types.h>
#include <unistd.h>

pid_t getpid(void); 	返回:调用进程的进程ID
pid_t getppid(void); 	返回:调用进程的父进程ID
uid_t getuid(void); 	返回:调用进程的实际用户ID
uid_t geteuid(void); 	返回:调用进程的有效用户ID
gid_t getgid(void); 	返回:调用进程的实际组ID
gid_t getegid(void); 	返回:调用进程的有效组ID

二、进程创建

2.1、fork系统调用

#include <unistd.h>

pid_t fork(void);
返回值:子进程中为0,父进程中为子进程I D,出错为-1

说明
fork函数用于从已存在进程中创建一个新进程。新进程称为子进程,而原进程称为父进程。

注意
1、子进程复制父进程的0~3g空间和父进程内核中的PCB,但id号不同。

2、fork调用一次返回两次:
① 父进程中返回子进程ID
② 子进程中返回0

3、读时共享,写时复制

4、使用fork函数得到的子进程从父进程的继承了整个进程的地址空间,包括:进程上下文、进程堆栈、内存信息、打开的文件描述符、信号控制设置、进程优先级、进程组号、当前工作目录、根目录、资源限制、控制终端等。

5、fork系统调用之后,父子进程将交替执行。如果父进程先退出,子进程还没退出那么子进程的父进程将变为init进程。(注:任何一个进程都必须有父进程)如果子进程先退出,父进程还没退出,那么子进程必须等到父进程捕获到了子进程的退出状态才真正结束,否则这个时候子进程就成为僵进程。

子进程与父进程的区别
1、父进程设置的锁,子进程不继承
2、各自的进程ID和父进程ID不同
3、子进程的未决告警被清除;
4、子进程的未决信号集设置为空集

示例

#include <sys/types.h>
#include <unistd.h>
#include <stdio.h>
#include <stdlib.h>

int main(void)
{
	pid_t result;
	/*调用fork函数,其返回值为result*/
	result = fork();
	/*通过result的值来判断fork函数的返回情况,首先进行出错处理*/
	if(result == -1)
	{
		perror("fork");
		exit;
	}
	/*返回值为0代表子进程*/
	else if(result == 0)
	{
		printf("The return value is %d\\nIn child process!\\nMy PID is %d\\n",result,getpid());
	}
	else
	{
		printf("The return value is %d\\nIn father process!\\nMy PID is %d\\n",result,getpid());
	}
	return 0;
}

运行结果:

The return value is 0
In child process!
My PID is 27424
The return value is 27424
In father process!
My PID is 27423

2.2、exec族

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

exec 族语法
实际上,在Linux中并没有exec函数,而是有6个以exec开头的函数族,它们之间语法有细微差别,以下列举了exec函数的6个成员函数语法:

#include <unistd.h>

int execl(const char *path, const char *arg, ...);
int execlp(const char *file, const char *arg, ...);
int execle(const char *path, const char *arg, ..., char *const envp[]);
int execv(const char *path, char *const argv[]);
int execvp(const char *file, char *const argv[]);
int execve(const char *path, char *const argv[], char *const envp[]);

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

记忆这6个函数
这六个exec函数的参数很难记忆。函数名中的字符会给我们一些帮助:
①字母 p表示可以只给出可执行文件名,系统会自动按照PATH环境变量所指定的路径寻找可执行文件,它与字母e互斥。
②字母l表示该函数使用一个参数列表传递参数,它与字母v互斥。
③字母v表示该函数使用一个char * argv[ ]传递参数。
④字母e表示该函数使用char * envp[ ] 传递环境量变,而不使用当前环境。
exec族函数名对应含义

注意事项:
由exec启动的新进程继承了原进程的许多东西,已经打开了的文件描述符在新进程里仍将是打开的,除非它们的“exec 调用时关闭此文件”标志被置了位

exec使用实例:

//使用文件名的方式来查找可执行文件,同时使用参数列表的方式
if(fork()==0){
/*调用execlp 函数,这里相当于调用了“ps-f”命令*/
	if (execlp("ps","ps","-ef",NULL)<0)
	{
		perror("execlp error!");
		exit(1);
	}
}

---------------------------------------
//使用完整的文件目录来查找对应的可执行文件
if(fork()==0){
	/*调用execl 函数,注意这里给出ps程序的完整路径*/
	if (execl("/bin/ps","ps","-ef",NULL)<0)
	{
		perror("execl error!");
		exit(1);
	}
}

---------------------------------------
//将环境变量添加到新建的子进程中去   env:查看当前进程环境变量
/*命令参数列表,必须以NULL结尾*/
char *envp[]={"PATH=/tmp","USER=sunnq",NULL};
if(fork()==0){
	/*调用execle 函数,注意这里也要指出env的完整路径*/
	if (execle("/bin/env","env",NULL,envp)<0)
	{
		perror("execle error!");
		exit(1);
	}
}


---------------------------------------
//通过构造指针数组的方式来传递参数,注意参数列表一定要以NULL作为结尾标识符
char*arg[]={"ls", "-a", NULL};
if(fork()==0){
	if (execve("/bin/ls",arg,NULL)<0)
	{
		perror("execve error!");
		exit(1);
	}
}

exec族使用注意点
在使用exec函数时,最好加上错误判断语句。因为exec很容易执行失败,其中最常见的原因有:
①找不到文件路径,此时error被设置为ENOENT
②数组argvenvp忘记用NULL结束,此时error被设置为EFALUT
③没有对应可执行文件的运行权限,此时error被设置为EACCESS
事实上,这6个函数中真正的系统调用只有execve,其他5个都是库函数,它们最终都会调用execve这个系统调用。


三、exit和_exit

3.1、exit和_exit函数说明

exit_exit用于中止进程;当程序执行到exit或_exit时,进程会无条件地停止剩下的所有操作,清楚包括PCB 在内的各种数据结构,并终止本进程的运行。但是,这两个函数还是有区别的,这连个函数的调用过程如下图所示:

由图可以看出,_exit的作用:直接使进程停止运行,清除其使用的内存空间,并清除其在内核中的数据结构;

exit_exit函数不同,exit函数在调用exit系统之前要检查文件打开情况,把文件缓冲区的内容写回文件中去,就是图中的“清理I/O缓冲”一项


3.2、exit和_exit函数语法

#include <stdlib.h>   //exit
#include <unistd.h>   //_exit

void exit(int status)
void _exit(int status)

参数:
status: 0 代表正常结束;其他数值表示出现了错误,进程非正常结束

四、wait和waitpid

僵尸进程: 子进程退出,父进程没有回收子进程资源(PCB),则子进程变成僵尸进程。

孤儿进程: 父进程先于子进程结束,则子进程成为孤儿进程,子进程的父进程成为1号。

init进程:1号进程,负责收留孤儿进程,成为他们的父进程。

4.1、wait和waitpid函数说明

wait函数用于使父进程(也就是调用wait的进程)阻塞,直到一个子进程结束或者该进程接收到一个指定信号为止。如果该父进程没有子进程或者他的子进程已经结束,则wait就会立即返回。

waitpid的作用和wait一样,但它并不一定要等待第一个终止的子进程,它还有若干选项,如可提供一个非阻塞版本的wait功能,也能支持作业控制。实际上wait 函数只是waitpid 函数的一个特例,在Linux内部实现wait函数时直接调用的就是wait函数。


4.2、wait和waitpid函数说明

#include <sys/types.h>
#include <sys/wait.h>

pid_t wait(int *status);
pid_t waitpid(pid_t pid, int *status, int options);

pid是进程号
status:
	<-1 回收指定进程组内的任意子进程
	-1 回收任意子进程
	0 回收和当前waitpid调用一个组的所有子进程
	>0 回收指定ID的子进程
options:
	WNOHANG:若由pid指定的子进程不立即可用,则waitpid不阻塞,此时返回值为0
	WUNTRANCED:若实现某支持作业控制,则由pid指定的任一子进程状态已暂停,且其状态自暂停以来还未报告过,则返回其状态
	0:同wait,阻塞父进程,等待子进程退出

4.3、使用实例

#include <unistd.h>
#include <stdio.h>
#include <stdlib.h>
#include <sys/types.h>
#include <sys/wait.h>

int main(void)
{
	pid_t pc,pr;
	pc = fork();
	if (pc <0)
	{
		printf("Error fork!\\n");
	}
	/*子进程*/
	else if(pc==0)
	{
		/*子进程暂停3秒*/
		Sleep(3);
		/*子进程正常退出*/
		exit(0);
	}
	/*父进程*/
	else
	{
		/*循环测试子进程是否退出*/
		do 
		{
			/*调用waitpid,且父进程不阻塞*/
			pr=waitpid(pc,NULL,WNOHANG);
			/*若子进程还未退出,则父进程暂停1s*/
			if(pr==0)
			{
				printf("The child process has not exited\\n");
				Sleep(1);
			}
		} while (pr==0);
		/*若发现子进程退出,打印出相应情况*/
		if (pr==pc)
		{
			printf("Get child%d\\n",pr);
		}
		else
		{
			printf("some error occured\\n");
		}
	}
	return 0;
}

执行结果:

The child process has not exited
The child process has not exited
The child process has not exited
Get child32225

五、进程间通信

5.1、pipe管道和FIFO有名管道

具体内容看我博客:管道


5.2、信号

看我博客:信号


5.3、消息队列

消息队列(Message Queue),是分布式系统中重要的组件,其通用的使用场景可以简单地描述为:当不需要立即获得结果,但是并发量又需要进行控制的时候,差不多就是需要使用消息队列的时候。消息队列主要解决了应用耦合、异步处理、流量削锋等问题。当前使用较多的消息队列有RabbitMQ、RocketMQ、ActiveMQ、Kafka、ZeroMQ、MetaMq等,而部分数据库如Redis、mysql以及phxsql也可实现消息队列的功能。

消息队列与管道以及有名管道相比,具有更大的灵话性,首先,它提供有格式字节流,有利于
减少开发人员的工作量:其次,消息具有类型,在实际应用中,可作为优先级使用。这两点是管道以及有名管道所不能比的。同样,消息队列可以在几个进程间复用,而不管这几个进程是否具有亲缘关系,这一点与有名管道很相似:但消息队列是随内核持续的,与有名管道(随进程持续)相比,生命力更强,应用空间更大。


5.4、共享内存映射

采用共享内存通信的一个显而易见的好处是效率高,因为进程可以直接读写内存,而不需要任何数据的拷贝。对于像管道和消息队列等通信方式,则需要在内核和用户空间进行四次的数据拷贝,而共享内存则只拷贝两次数据[1]: 一次从输入文件到共享内存区,另一次从共享内存区到输出文件。

实际上,进程之间在共享内存时,并不总是读写少量数据后就解除映射,有新的通信时,再重新建立共享内存区域。而是保持共享区域,直到通信完毕为止,这样,数据内容直保存在共享内存中,并没有写回文件。共享内存中的内容往往是在解除映射时才写回文件的。因此采用共李内存的通信方式效率是非常高的。

Linux的2.2.x内核支持多种共享内存方式,如mmap()系统调用Posix 共享内存,以及系统V共享内存。linux发行版本如Redhat 8.0支持mmap()系统调用及系统V共享内存,但还没实现Posix共享内存。

mmap/munmap

mmap可以把磁盘文件的一部分直接映射到内存,这样文件中的位置直接就有对应的内存地址,对文件的读写可以直接用指针来做而不需要read/write函数。

#include <sys/mman.h>

void *mmap(void *addr, size_t length, int prot, int flags, int fd, off_t offset);
int munmap(void *addr, size_t length);

参数:
	addr:为NULL,内核会自己在进程地址空间中选择合适的地址建立映射。
		  不为NULL,则给内核一个提示,应该从什么地址开始映射,内核会选择addr之上的某个合适的地址开始映射
	len:需要映射的那一部分文件的长度。
	off:参从文件的什么位置开始映射,必须是页大小的整数倍(在32位体系统结构上通常是4K)。
	filedes:代表该文件的描述符。
	prot:
		PROT_EXEC表示映射的这一段可执行,例如映射共享库
		PROT_READ表示映射的这一段可读
		PROT_WRITE表示映射的这一段可写
		PROT_NONE表示映射的这一段不可访问
	flag:(这里只写了两种)
		MAP_SHARED多个进程对同一个文件的映射是共享的,一个进程对映射的内存做了修改,另一个进程也会看到这种变化。
		MAP_PRIVATE多个进程对同一个文件的映射不是共享的,一个进程对映射的内存做了修改,另一个进程并不会看到这种变化,也不会真的写到文件中去。
	
返回值:
	成功则返回映射首地址,如果出错则返回常数MAP_FAILED

当进程终止时,该进程的映射内存会自动解除,也可以调用munmap解除映射。munmap成功返回0,出错返回 -1。

示例:

#include <stdlib.h>
#include <sys/mman.h>
#include <fcntl.h>
int main(void)
{
	int *p;
	int fd = open("file.txt", O_RDWR);
	if (fd < 0) {
	perror("open hello");
	exit(1);
	}
	p = mmap(NULL, 6, PROT_WRITE, MAP_SHARED, fd, 0);
	if (p == MAP_FAILED) {
		perror("mmap");
		exit(1);
	}
	close(fd);
	munmap(p, 6);
	return 0;
}

注意:

  • 用于进程间通信时,一般设计成结构体,来传输通信的数据
  • 进程间通信的文件,应该设计成临时文件
  • 当报总线错误时,优先查看共享文件是否有存储空间(即文件里是否有数据)

六、守护进程

6.1、概念

守护进程,也就是通常所说的 daemom(精灵) 进程,是Linux中的后台服务进程,生存期较长的进程,通常独立于控制终端并且周期性地执行某种任务或等待处理某些发生的事件。

守护进程是在后台运行不受终端控制的进程。

守护进程能自动转到后台并且脱离与终端的联系。

Linux系统中一般有很多守护进程在后台运行,执行不同的管理任务。


6.2、 模型(守护进程编程步骤)

1. 创建子进程,父进程退出
   所有工作在子进程中进行
  形式上脱离了控制终端
2. 在子进程中创建新会话
  setsid()函数
  使子进程完全独立出来,脱离控制
3. 改变当前目录为根目录
  chdir()函数
  防止占用可卸载的文件系统
  也可以换成其它路径
4. 重设文件权限掩码
  umask()函数
  防止继承的文件创建屏蔽字拒绝某些权限
  增加守护进程灵活性
5. 关闭文件描述符
  继承的打开文件不会用到,浪费系统资源,无法卸载
6. 开始执行守护进程核心工作
7. 守护进程退出处理

代码模型

#include <stdlib.h>
#include <stdio.h>
#include <fcntl.h>

void daemonize(void)
{
	pid_t pid;
	/*
	* 成为一个新会话的首进程,失去控制终端
	*/
	if ((pid = fork()) < 0) {
		perror("fork");
		exit(1);
	} else if (pid != 0) /* parent */
		exit(0);
	setsid();
	/*
	* 改变当前工作目录到/目录下.
	*/
	if (chdir("/") < 0) {
		perror("chdir");
		exit(1);
	}
	/* 设置umask为0 */
	umask(0);
	/*
	* 重定向0,1,2文件描述符到 /dev/null,因为已经失去控制终端,再操作0,1,2没有意义.
	*/
	close(0);
	open("/dev/null", O_RDWR);
	dup2(0, 1);
	dup2(0, 2);
}
int main(void)
{
	daemonize();
	while(1); /* 在此循环中可以实现守护进程的核心工作 */
}

注意:运行这个程序,它变成一个守护进程,不再和当前终端关联。用ps命令看不到,必须运行带x参数的ps命令才能看到。另外还可以看到,用户关闭终端窗口或注销也不会影响守护进程的运行。

以上是关于进程管理详解的主要内容,如果未能解决你的问题,请参考以下文章

进程管理详解

LINUX操作系统知识:进程与线程详解

Linux详解 --- 进程管理1 (进程概念 / 进程优先级)

在 Python 多处理进程中运行较慢的 OpenCV 代码片段

20160217.CCPP体系详解(0027天)

进程管理详解