浅析进程之进程控制

进程:为了能使程序并发执行,并且可以对并发执行的程序加以描述和控制,引入了进程的概念。进程时进程实体的运行过程,是系统进行资源分配和调度的一个独立单位。(并发:一个处理器同时处理多个任务;并行:多个处理器或者多核处理器同时处理多个不同的任务)注意:可执行程序不等同于进程,我们平时所说的某个程序结束了是不严谨的,应该是某个进程结束了。

并发和并行的理解图:(此图来源于github)


在linux上,我们一般可以使用ps指令或者top指令来获取进程信息:



为了参与并发执行的每个程序都能独立的运行,在操作系统中必须为之分配一个专门的数据结构,称为进程控制块(Process Control Block,简称PCB),可以理解为进程属性的集合。(Linux下的PCB为:task_struct)

下面我们来研究下task_struct 里面都有哪些主要内容:task_struct包含了一个进程所需要的所有信息,它定义在/include/linux/sched.h中。

主要包括:1.标识符:跟这个进程相关的唯一标识符,用来区别其他进程。

                 2.状态:如果进程正在执行,那么进程处于执行态;

                 3.优先级:相对于其他进程的优先级。

                 4.程序计数器:程序中即将被执行指令的下一条指令的地址。

                 5.内存指针:包括程序代码和进程相关数据的指针,还有其他进程共享内存块的指针。

                 6.上下文数据:进程执行时处理器的寄存器中的数据。

                 7.I/O状态信息:包括显示的I/O请求,分配给进程的I/O设备(例如磁带驱动器)和被进程使用的文件列表等。

                 8.审计信息:可包括处理器的时间总和,使用的时钟数总和,时间限制,审计号等。

进程控制块是操作系统能够支持多进程和提供多处理的关键工具。当进程中断时,操作系统会把程序计数器和处理器寄存器(上下文数据)保存到进程控制块相应的位置,进程状态也随之改变。此时操作系统可以把其他进程设置为运行态,把其他进程的程序计数器和上下文数据加载到处理器寄存器中,这样其他进程就可以执行。

进程状态:为了需要明白正在运行的状态是什么意思,我们需要知道进程都有哪些状态?进程的几个重要状态主要有以下几种:

1.R运⾏状态(running): 并不意味着进程⼀定在运⾏中,它表明进程要么是在运⾏中要么在运⾏队列⾥。

2.S睡眠状态(sleeping): 意味着进程在等待事件完成(这⾥的睡眠有时候也叫做可中断睡眠(interruptible sleep))。
3.D磁盘休眠状态(Disk sleep)有时候也叫不可中断睡眠状态(uninterruptible sleep),在这个状态的进程通常会等待IO的结束。
4.T停⽌状态(stopped): 可以通过发送 SIGSTOP 信号给进程来停⽌(T)进程。这个被暂停的进程可以通过发送 SIGCONT 信号让进程继续运⾏。
5.X死亡状态(dead):这个状态只是⼀个返回状态,你不会在任务列表⾥看到这个状态。(已经不存在了)
在这里我演示一下R状态:
R状态(敲下top指令,则该指令正在运行):

还有一种重要状态僵尸状态——Z(zombie)-僵尸进程

1.僵尸状态是一种比较特殊的状态,当进程退出并且父进程没有读取到子进程退出的返回码时就会产生僵尸进程。

2.僵尸状态会以终止状态保存在进程表中,并且会一直等待父进程读取退出状态代码。

3.所有,只要子进程退出,父进程还在运行,但父进程没有读取到子进程状态,子进程进程Z状态。

接下来用一段代码创建一个僵尸进程的例子:

#include<stdio.h>
#include<unistd.h>
#include<stdlib.h>
int main()
{
	  pid_t ret=fork();
	  if(ret==0)//子进程
	  {
		  printf("child:%d is begin Z...\n",getpid());
		  sleep(5);
		  printf("child begin quit!\n");
		  exit(6);
	  }
	  else if(ret>0)//父进程
	  {
		  printf("father:%d,is sleeping...\n",getpid());
		  sleep(10);
		  printf("father quit!\n");
	  }
	  else//fork失败
	  {
		  perror("fork ");
	  }
	  return 0;
}

运行结果为:


可以看出当子进程退出之后父进程才退出。当子进程退出后,父进程睡眠10秒后才退出,在这十秒中此进程处于僵尸状态。

在另外一个调试窗口可发现僵尸Z状态已经产生:



僵尸进程的危害:

                         1.进程的退出状态必须被维持下去,因为他需要告诉关心他的进程(父进程),你交给我的任务,我办的怎么样了?但是如果父进程一直不去读取子进程的退出状态,那子进程会一直处于Z状态。

                          2.维护退出状态本身就是要数据维护,也属于进程基本信息,会被保存在task_struct(PCB)中,换句话说,Z状态一直不退出,PCB就一直需要被维护。

                          3.如果有一个父进程创建了很多子进程,但不进行回收工作,则会造成内存资源的浪费,因为数据结构对象本身就要占用内存。

                           4.僵尸进程会造成内存泄露。

还有一种比较重要的进程:孤儿进程

关于孤儿进程有以下几点需要说明:

                            1.父进程先退出,子进程就称之为“孤儿进程”。

                            2.孤儿进程将被init进程(进程号为1)所收养,并由init进程对它们完成状态收集工作。每当有一个进程凄惨的沦落为孤儿进程时,init进程就会收养它。

用一段代码来演示一下孤儿进程:

#include <stdio.h>
#include <unistd.h>
#include <stdlib.h>
int main()
{
	pid_t ret=fork();
	if(ret>0)//父进程
	{
		printf("father pid:%d\n",getpid());
		sleep(3);
		exit(0);
	}
	else if(ret==0)//子进程
	{
		printf("child pid:%d\n",getpid());
	}
	else//fork失败
	{
		perror("fork");
		return 1;
	}
	return 0;
}

用另外一个窗口进行监视后发现孤儿进程的确被init(1号进程)收养:


前面我们主要了解了进程的概念和进程的几种常见状态,接下来我们较为深入的研究下进程的创建,进程的等待,以及进程的终止,进程的替换。(前面创建进程已经使用了fork函数)

进程创建   有两种创建进程的方式:fork函数和vfork函数

fork函数是非常重要的函数,它从已经存在的进程中创建一个新进程。新进程称为子进程,原进程为父进程。fork函数包含在#include<unistd.h>中,函数原型为:pid_t  fork(void);无参数,返回值为整型。fork函数有两个返回值,其中当某个进程运行完成后,父进程会返回子进程的pid,子进程返回0;出错返回-1。

进程调用fork函数后,内核会完成如下工作:

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

                       2.将父进程的部分数据结构内容拷贝到子进程

                       3.添加子进程到系统进程列表当中。

                       4.fork返回后便开始调度器的调度。

关于fork函数,有以下几点需要说明:

                      1.一次调用有两个返回值,父进程返回子进程的pid ,子进程返回0.

                      2.父进程和子进程都是从fork执行之后的位置开始执行的。

                      3.子进程以父进程为模板(PCB,数据和代码),进行写时拷贝。

                      4.fork之后,父子进程执行先后顺序不一定,取决于操作系统调度器。

用一张图来简单理解一下:


fork函数调用失败的原因主要可以归结为两点:

                     1.系统中的进程太多,导致内存不够。

                     2.进程数量太多。

vfork函数也是用来创建一个子进程的。但是他与fork函数有以下两个重要区别:

                     1.vfork用于创建一个子进程,子进程与父进程共享一段地址空间,而fork的子进程具有独立的地址空间(fork之后父子进程共享代码段,数据各自一份,父子进程相互独立)

                    2.vfork之后保证子进程先运行,在它调用exec或者exit之后父进程才可能开始调度运行。

下面我用一段代码来验证一下上述两点:                 

#include <stdio.h>
#include <unistd.h>
#include <stdlib.h>
int count=0;
int main()
{
	pid_t ret=vfork();
	if(ret>0)//父进程
	{
		printf("father pid:%d\n",getpid());
		printf("father count:%d\n",count);
	}
	else if(ret==0)//子进程
	{
		sleep(3);
		count=100;
		printf("child pid:%d\n",getpid());
		printf("child count:%d\n",count);
		exit(0);
	}
	else//fork失败
	{
		perror("vfork");
		return 1;
	}
	return 0;
}

运行结果为:


说明子进程直接修改了父进程的变量值,因为子进程与父进程共享一段地址空间。

进程退出

进程退出的三种场景:

                          1.代码运行完成,结果正确。

                           2.代码运行完成,结果不正确;

                          3.代码异常终止。(ctrl+c,信号终止)

与进程退出相关的函数有exit函数和_exit函数;exit函数原型为:void exit(int status);_exit函数原型和它一样,其中参数status定义了进程的终止状态,父进程可以通过wait函数(后面介绍)来获取此值。exit函数除了会调用_exit函数之外,还做了两件事情:1.执行用户通过atexit或者on_exit定义的清理函数。2.关闭流,刷新缓冲,所有缓存数据均被写入。

还有一种我们常见的退出进程的退出方法为return退出,只有在main函数中的return才相当于执行exit(n),因为调用main函数的运行时函数会将main函数的返回值当做exit的参数。

进程等待

进程为什么需要等待?主要有以下几点:

1.子进程退出,父进程如果不管不顾,就可能造成“僵尸进程”的问题,进而造成内存泄露。

2.进程如果变成僵尸进程,kill -9也无能为力,谁也不可以杀掉一个已经死去的进程。

3.父进程给子进程分派的任务是否完成以及完成结果对还是不对,我们需要知道。

4.父进程通过进程等待的方式,回收子进程的资源,获取子进程的退出信息。

进程等待有好几种方法,其中比较典型的两种为wait和waitpid;它们都被包含在#include<sys/wait.h>和#include<sys/types.h>中,从头文件就可以看出它们都属于系统调用。

首先来介绍一下wait函数:函数原型为:pid_t wait(int* status);

返回值:返回被等待进程的pid,失败返回-1;

参数为输出型参数,用来获取子进程的退出状态,不关心则可以设置为NULL;

waitpid函数原型:pid_t waitpid(pid_t pid,int *status,int options);

返回值:1.正常返回时,返回子进程的pid.

              2.如果设置了选项WNOHANG,而调用中发现已经没有已经退出的子进程可以退出,则直接返回0;

              3.如果调用中出错,则返回-1,这时全局变量errno会被设置为相应的值以指示错误所在。

参数:pid:   pid=-1;表示等待任意一个子进程退出,此时与wait等效。pid>0,等待其进程ID与子进程的PID相等的子进程。

         status:WIFEXITED(status): 查看进程是否正常退出;WEXITSTATUS(status): 若WIFEXITED非0,提取子进程的退出码。

          options:WNOHANG:若pid指定的子进程没有结束,则waitpid()函数直接返回0,不予以等待,若正常结束,则返回子进程的pid。

关于进程等待的几点说明:

             1.如果子进程已经退出,则不需要等待,调用的wait/watpid直接返回并且释放资源,获取子进程的退出信息。

              2.父进程如果在任意时刻调用wait/waitpid,子进程存在并且还未退出,则进程可能阻塞。

             3.如果要等待的子进程不存在,则立即出错返回。

获取进程退出状态status:操作系统会根据status这个输出型参数,将子进程的退出信息反馈给父进程。status可以当做一个简单的位图来看待。


举一个进程等待的例子:

#include<stdio.h>
#include<unistd.h>
#include<stdlib.h>
#include<sys/wait.h>
int main()
{
	pid_t ret=fork();
	if(ret==0)
	{
		int count=5;
		while(count--)
		{
			printf("child:%d,%d\n",getpid(),getppid());
			sleep(1);
		}
		printf("child quit!\n");
	 	exit(31);
	}
	else if(ret>0)
	{
           printf("father:%d,%d\n",getpid(),getppid());
           sleep(10);
	  // pid_t res=wait(NULL);
	   int status;
	   pid_t res=waitpid(ret,&status,0);
	   if(res>0)
	   {
		   printf("sig:%d,exit:%d\n",status&0x7f,(status>>8)&0xff);
	   }
	   else if(res==0)
	   {
		   printf("child is running!i do other things!\n");
	   }
           else
          {
            perror("waitpid\n");
            return 2;
           }
	   printf("father quit!\n");
	   sleep(1);
	   printf("res:%d\n",res);
	}
	else
	{
		perror("fork\n");
                return 1;
	}
}

运行结果为:



可以看出父进程阻塞在wait子进程退出后才退出。

进程替换

一般来说,用fork函数创建子进程后 执行的是和父进程相同的程序,子进程往往要调用一种exec函数以执行另一个程序。当进程调用exec函数时,该进程的用户空间代码和数据完全被新程序替换,从新程序的启动实例开始执行。调用exec函数并不会创建新的进程,所以调用exec前后该进程的id并未改变,exec函数族只是用磁盘上面的一个新程序替换了当前进程的正文段,数据段,堆段以及栈段。

关于我们的进程替换,主要有6个替换函数,统称为exec函数:

#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,const char *argv[]);
int execve(const char* path,const char *argv[],char *const envp[]);

是不是看得眼花缭乱,经过总结可以分为四个大的部分:其中函数名中带l(list)的表示参数列表中含有可变参数列表部分;函数名中带v(vector)的表示参数列表中含有格式为数组的参数;函数名中带p(path)的可以使用环境变量PATH,无需写全路径;函数名中带e(env)的,表示参数列表中含有一个字符串数组,这个数组用来组装环境变量。(其中argv和envp这两个数组必须以NULL结束)

是不是还是不太清楚,那用一张表来更清晰得看一下:


关于exec函数的几点说明:

                                       1.调用exec函数后,除非调用失败才会返回-1,否则没有返回值。并且只有调用失败后,调用exec函数之后的子进程程序才会继续执行,如果调用成功则加载新的程序从启动代码开始执行,调用处之后子进程的代码将不再执行。

                                         2.当子进程调用exec函数后,不管是否成功,父进程依然可以正常执行。

来看一段代码:

#include<stdio.h>
#include<unistd.h>
#include<stdlib.h>
int main()
{
	int ret=fork();
	if(ret==0)
	{
	int res=execl("/bin/ls","ls","-a","-t","-r","-l",NULL);
	printf("exec error");
	printf("res=%d\n",res);
	exit(0);
	}
	else if(ret>0)
	{
		printf("father:%d\n",getpid());
	}
	else
	{
		perror("fork");
	}
    return 0;
}

执行结果为:



execl函数调用成功,则运行可执行程序相当于在当前工作目录下执行了ls指令。但是不难发现execl函数后面的两条printf语句并没有执行,这也正好说明了exec函数调用成功之后子进程的代码将不再执行。并且并没有影响父进程的执行。

当我们把"/bin/ls"这个全路径改为"/bin/lsvv"时,由于bin目录下并没有lsvv这个东西,所以exec函数必然调用失败。子进程调用exec函数之后的代码将会被执行,并且exec函数返回值为-1。


其余几个函数调用方法上述例子类似,这里我们再做一下简单实例:

 char* const  argv[]={"ls","-l","-r","-t",NULL};
	char* envp[]={"PATH=/bin/ls","TERM=console",NULL};
	execlp("ls","ls","-l","-r","-t",NULL);//带p的,无需写全路径
	execle("/bin/ls","ls","-l","-r","-t",NULL,envp);//带e的,要自己组装环境变量
	execv("/bin/ls",argv);//传参为数组
	execvp("ls",argv);
	execve("/bin/ls",argv,envp);

实际上,只有函数execve是真正的系统调用,其他五个函数最终都会去调用execve函数。


关于进程概念和控制我大概就只理解了这么多,还很浅薄,后面学习过程中我还会不断的完善内容,综合我前面总结的内容,我会在下一篇博客中实现一个简易版本的shell。




























猜你喜欢

转载自blog.csdn.net/qq_39344902/article/details/80169079