进程

关于进程是什么,百度百科给了两个概念:

狭义定义:进程是正在运行的程序的实例
广义定义:进程是一个具有一定独立功能的程序关于某个数据集合的一次运行活动。它是操作系统动态执行的基本单元,在传统的操作系统中,进程既是基本的分配单元,也是基本的执行单元。

个人理解:进程就是一次执行代码的运行过程,其包括但不局限于执行代码所需要各种资源。

进程的标识符

作为一门仿现实学科,进程也拥有自己的名字和身份证号。名字就是你命名的可执行文件名,身份证号则是进程标识符

在操作系统运行过程中,每个进程都拥有一个非负整型表示的唯一进程ID。在shell中使用ps -ef | grep 进程名来查看进程ID。

我们说进程ID唯一的意思就是说:同一时刻运行的进程不可能拥有同样的进程ID!
但是,进程ID是可以重用的,当一个进程结束,其ID可以被操作系统分配给其他进程。

一些特殊的进程ID

在linux系统中有一些专用的进程ID:

  • ID=0:调度进程,又称交换进程,是内核的一部分,负责进程调度。
  • ID=1:init进程。在自举结束后由内核调用,将系统引导至一个状态,其绝不会终止。虽然他是一个普通用户进程,但是它以超级用户特权运行。
  • ID=2:页守护进程,负责支持虚拟存储系统的分页操作。

除了进程ID,每个进程还有一些其他表示符 ,可用以下函数查看:

#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

六个函数看起来很多,但是有个方法能方便我们记忆:
函数名中的p是parent(双亲)的意思,u是user(用户)的意思,e是effective(有效)的意思。

实际用户ID:登录虚拟机的时候输入的账户ID
有效用户ID:用于给操作系统判断某个进程是都拥有操作某个文件的权限。经常等于实际用户ID
保留的设置用户ID:有效用户ID的备份,一般用来恢复有效用户id

进程创建

fork函数

一个现有的进程可以通过fork函数创建一个新进程。

#include<unistd.h>
pid_t fork(void);		//子进程中返回0,父进程中返回子进程id,出错返回-1

由fork函数创建的新进程被称为子进程。fork函数被调用一次,但是会返回两次。两次返回的唯一区别是子进程返回值是0。

fork函数之后,父子进程会继续执行fork调用之后的指令。子进程获得父进程的数据空间、堆和栈的副本
由于在fork之后经常跟寻exec函数族,所以实际上很多实现不执行一个父进程数据段、栈和堆的完全复制,而是使用了写时拷贝技术

写时拷贝技术: 内核只为新生成的子进程创建虚拟空间结构,它们来复制于父进程的虚拟结构,但是不为这些段分配物理内存,它们共享父进程的物理空间,当父子进程中有更改相应段的行为发生时,再为子进程相应的段分配物理空间。

fork使用:

int main(void)
{
   
	pid_t pid;
	if((pid = fork()) < 0)
	{
   
		printf("fork() error!");
	}
	else if(pid == 0)
	{
   
		//子进程执行代码
	}
	else
	{
   
		//父进程执行代码
	}
}

一般来说,fork之后父进程和子进程的执行顺序是异步的!如果想要实现进程之间相互同步,则需要进程间通信

  • 进程间通信(待完成)

多次fork

我们来看一下以下代码:

int main()
{
   
	pid_t pid;
	for (int i = 0; i < 2; i++) 
	{
   
		if ((pid = fork()) == 0)
		{
   
			sleep(1);
			cout << "i am child:" << getpid() << endl;
		}
		else
		{
   
			sleep(1);
			cout << "i am parent:" << getpid() << endl;
		}
	}
}

然后这是运行结果:

会发现,它不是按我们预期创建两个线程,而是创建了四个线程:3681,3682,3683,3684
并且打印了六次。

这是因为3681父进程,创建出了3682子进程。然后进入第二遍for循环,3681作为父进程又创建出3683,而原来的子进程这时候也fork(),创建出了它的子进程3684。

父子进程之间资源共享

fork有一个特性就是:父进程的所有打开文件描述符都被复制到子进程中

而fork之后处理文件描述符有两种常见的情况:

  • 父进程等待子进程完成:父进程无需对描述符做任何处理。当子进程终止后,它曾进行过读、写操作的任一共享描述符的文件偏移量已执行了相应更新
  • 父子进程各自执行不同的程序段。在这种情况下,在fork之后,父子进程各自关闭它们不需要使用的文件描述符,这样就不会干扰对方使用文件描述符。

除了打开文件外,父子进程还有以下内容被继承:

  • 附加组ID
  • 进程组ID
  • 会话ID
  • 控制终端
  • 设置用户ID标志和设置组ID标志
  • 当前工作目录
  • 根目录
  • 文件模式创建屏蔽字
  • 信号屏蔽和安排
  • 针对任一打开文件描述符的执行时关闭标志
  • 环境
  • 连接的共享存储段
  • 存储映射
  • 资源限制

父子进程之间的区别

  • fork返回值
  • 进程ID
  • 两个进程的父进程ID
  • 子进程的tms_utime、tms_stime、tme_cutime以及tms_ustime均被设置为0
  • 父进程设置的文件锁不被子进程继承
  • 子进程未被处理的闹钟被清楚
  • 子进程未处理信号集设置为空集

fork失败的原因

fork函数失败主要有两个原因:

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

fork的两种用法

  • 父进程希望复制自己,使父子进程同时执行不同的代码段
  • 一个进程要执行不同的程序。这个比较常用。

vfork函数

#include<unistd.h>
pid_t vfork(void);		//子进程中返回0,父进程中返回子进程id,出错返回-1

可以看到,vfork函数的调用序列和返回值与fork相同。那笔者在这里为什么又要介绍呢?

vfork用于创建一个新进程,而该新进程的目的是一个exec新程序。
和fork一样,都会创建子进程,但是它并不将父进程的地址空间完全复制到子进程中。因为子进程会立即调用exec或exit,就不会访问该地址空间!你说我复制它干嘛?

那子进程不是原子的,它调用exec或exit之前存在在哪里?

父进程的空间!
这意味着其可以对父进程的变量做出改变!

int main()
{
   
	int var = 0;
	pid_t pid;
	
	if((pid = vfork()) < 0)
	{
   
		//vfork error!
	}
	else if(pid == 0)
	{
   
		var++;
		//子进程代码
	}
	else
	{
   
		printf("var = %d", var);
		//父进程代码
	}
}

result: var = 1

2020年12月26日补充


关于 vfork与生产者和消费者的思考

事情是这样的,一个朋友问我怎么用多进程写生产者消费者模型,我第一时间就想到了vfork的调用execl之前可以修改父进程的变量的性质,代码如下:

int main()
{
   
	pid_t pid_p[2],pid_c[3];
	
	int produces = 0;
;
	for (int i = 0; i < 2; i++) 
	{
   
		if ((pid_p[i] = vfork()) == 0)
		{
   
			while (1) 
			{
   
				sleep(1);
				if (produces <= 3)
				{
   
					produces++;
					cout <<getpid()<< ":product one,produces" << produces << endl;
				}
			}
		}
	}

	for (int i = 0; i < 3; i++)
	{
   
		if ((pid_c[i] = vfork()) == 0)
		{
   
			while (1)
			{
   
				if (produces > 0)
				{
   
					produces--;
					cout << getpid() << ":consume one,produces" << produces << endl;
				}
			}
		}
	}
}

但是,我发现最终运行结果和我预想的不太一样:


只有一个进程在打印,其他进程包括父进程都阻塞住了。

后来查阅资料发现:vfork保证子进程先运行,在它调用exec或exit之后,父进程才能被调度。

所以说,还是乖乖地用共享内存做吧,偷懒是不可能的。


exec函数

刚刚提到了fork函数过后,往往要调用exec函数执行另一个程序。
当进程调用一种exec函数时,该进程执行的程序完全替换为新程序,而新程序则从其main函数开始执行。前后进程id不变

#include <unistd.h>

int execl(const char* pathname, const char* arg0, ..., (char*)0);	//(char*)0 代表参数结束
int execv(const char* pathname,char* const argv[]);
int execle(const char* pathname,const char* arg0, ..., (char*)0, char* const envp[]);
int execve(const char* pathname,char* const argv[],char* const envp[]);
int execlp(const char* filename,const char* arg0, ..., (char*)0);
int execvp(const char* filename,char* const argv[]);
//六个函数,若出错则返回-1,若成功则不返回

记忆方法:

函数名中,字母 p 表示filename作为参数;字母 l 表示list(列表)表示函数取一个参数表;字母 v 表示value(值)表示一个argv[];字母 e 表示environment(环境)表示envp[]环境变量数组

exec函数族区别

1.这些函数的区别是前四个取路径名作为参数,后两个则取文件名作为参数。,当指定filename作为参数时:

  • 如果filename中包含/,则将其视为路径名
  • 否则就按PATH环境变,在指定目录中搜寻

另外,对于execlpexecvp两个函数,如果找到的可执行文件不是由编译器产生的可执行文件,则认为该文件是一个shell脚本,于是就会调用/bin/sh,将filename作为参数传入。

2.对于函数名中有字母 v 的函数,在传参之前需要先构造一个指向各参数的指针数组,然后将该数组地址作为函数的参数

3.以字母 e结尾的两个函数可以传递一个指向环境字符串指针数组的指针。其他几个函数调用进程中的environ变量为新程序复制现有的环境。

exec函数族关系

在很多UNIX实现中,这六个函数只有execve是内核的系统调用。另外五个是库函数。

exec函数使用

int main()
{
   
	pid_t pid;
	if((pid = fork()) < 0)
	{
   
		//fork error
	}
	else if(pid == 0)
	{
   
		execle("路径", "调用程序名称", "参数1", "参数2", (char*)0, "环境变量参数")
	}
	else
	{
   
		//父进程代码
	}
}

进程终止

一般来说,进程有五种正常的终止方式:

  1. 在main函数内执行return语句,等效调用exit。
  2. 调用exit函数,其操作包括调用各终止处理程序(由atexit函数登记)
  3. 调用_exit或者_Exit函数,这两个函数为进程提供一种无需运行终止处理程序或信号处理程序而终止的方法。
  4. 进程的最后一个线程在其启动例程中执行返回语句。
  5. 进程的最后一个线程调用pthread_exit函数

以及三种异常终止方式:

  1. 调用abort,它产生SIGABORT信号,可以终止进程。
  2. 当进程接收到某些信号时。
  3. 最后一个线程对“ 取消 ”请求作出相应。

进程终止之后的关系变更

当父进程在子进程之前终止。子进程就变成了孤儿进程

但是,孤儿进程在计算机世界中不能不管。
这时,init进程就出现了。
它会领养孤儿进程,成为其的父进程。

其领养过程大致如下:

在一个进程终止时,内核逐个检查所以活动进程,以判断它是否是正要终止进程的子进程,如果是,则将该进程的父进程ID更改为1

当然,还有另一种情况:子进程在父进程之前终止且父进程尚未进行善后处理
这时的子进程被称为僵死进程

为啥叫僵死?其实笔者觉得更应该叫早夭进程的。后来我查到了百度百科的解释:子进程停留在僵死状态等待其父进程为其收尸


那问题又来了?父进程咋为其收尸呢?

内核为每个终止子进程保存了一定量的信息,当终止进程的父进程调用waitwaitpid函数时,可以获取这些信息。
这些信息至少包括进程ID、该进程的终止状态、以及该进程使用的CPU时间总量等。

获取进程的终止状态

当一个进程正常或异常终止时,内核就向其父进程发送一个SIGCHLD信号。
父进程可以选择忽略,也可以调用一个函数来处理子进程的尸体。

wait和waitpid函数

刚刚我们说到这两个函数是父进程用来收尸的,但是子进程的终止是一种异步状态。如果父进程收尸的时候,子进程还在咋办?大义灭亲?

这个时候有三种情况:

  1. 如果所有子进程都还在运行,则阻塞
  2. 如果一个子进程已终止,则父进程立即取得该子进程的终止状态。
  3. 如果没有子进程,则立即出错返回。

总结来说:如果进程是收到SIGCHLD信号而调用wait,则可期望理解返回。其他时候,则有可能阻塞。

接下来,我们再看一下函数原型:

#include <sys/wait.h>

pid_t wait(int* statloc);		//如果有一个子进程终止,wait就返回
pid_t waitpid(pid_t pid,int* statloc,int options);
//两个函数返回值一样,若成功返回进程ID,若出错则返回-1。

我们可以看到,这两个函数均有一个整型指针statloc,这是干嘛的?

如果statloc不是一个空指针,则终止进程的终止状态就存放在它所指向的单元内。

wait和waitpid区别
  • 在一个子进程终止前,wait使其调用者阻塞,而waitpid有一个options选项,可使调用者不阻塞。
  • waitpid并不等待其调用之后的第一个终止子进程,它有若干个选项,可以控制它所等待的进程。
waitpid参数解释

刚刚我们说了,wait是只要有一个子进程终止,就会立即返回。那么如何去获取指定的子进程终止信息呢?

这时就要用到waitpid的pid选项了。

pid值 解释
pid == -1 等待任一子进程
pid > 0 等待终止进程的进程ID与pid相等的子进程
pid == 0 等待其组ID等于调用进程组ID的任一子进程
pid < -1 等待其组ID等于pid绝对值的任一子进程

但是,对于waitpid,如果指定的进程或进程组不存在,或者参数pid指定的进程不是调用进程的子进程都将出错

options参数使我们能进一步控制waitpid的操作。

options值 说明
WCONTINUED 若实现支持作业控制.那么由pid指定的任一子进程在暂停后已经继续,但其状态尚未报告,则返回其状态
WNOHANG 若由pid指定的子进程并不是立即可用的,则wa tpid不阻塞,此时其返回值为0
WUNTRACED 若某实现支持作业控制,而由pid指定的任一子进程已处于暂停状态,并且其状态自暂停以来还未报告,则返回其状态

看了这两个函数,我们对父进程收尸的细节都有了大概的了解了。但是这种方法有点笨逼,每次都得主动的收尸,有没有什么自动收尸的方法呢?

请看下面的代码:

#include <sys/wait.h>

int main()
{
   
	pid_t pid;
	if((pid = fork()) < 0)
	{
   
		// fork error
	}
	else if(pid == 0)
	{
   
		if((pid = fork()) < 0)
		{
   
			//fork error
		}
		else if(pid > 0)
		{
   
			//杀死孙子进程的父进程,儿子进程
			exit(0);
		}
		//孙子进程执行代码 
	}
	//爷爷进程执行代码
}

这里用到的思想就是fork两次,然后孙子进程的父进程进行自杀,然后孙子进程认init进程作父。

参考文献

[1] UNIX环境高级编程(第二版)
[2]e我所欲也.linux--进程讲解.2020.03.11
[3]yzpyzp.Linux查看进程id,以及根据进程id查看占用的端口,根据端口号查看占用的进程.2018.08.23