操作系统详解(4)——进程控制(fork, waitpid, sleep, execve)

系列文章:
操作系统详解(1)——操作系统的作用
操作系统详解(2)——异常处理(Exception)
操作系统详解(3)——进程、并发和并行

文章目录

  • 一、获得进程ID
  • 二、创建和终止进程
    • 进程的三种状态
    • 进程终止
    • 创建进程
    • fork函数的特点
      • 调用一次,返回两次
      • 并发执行
      • 父进程与子进程有相同但是==独立==的地址空间
      • 共享文件
    • fork容易混淆的例子
  • 三、回收子进程
    • 回收机制
    • waitpid函数
      • 1. 判定等待集合的成员
      • 2. 修改默认行为
      • 3. 检查已回收子进程的退出状态
      • 错误条件
      • wait 函数
      • 使用waitpid 的示例
  • 四、程序休眠
  • 五、加载并运行程序
    • execve函数
    • 命令行参数与环境变量
    • execve使用例子
    • 相关函数
    • 程序与进程的区别
    • fork与execve的区别
  • 总结

一、获得进程ID

简称PID(process id)
每个进程都有唯一的正数PID

getpid函数: 返回调用进程的PID
getppid: 返回父进程的PID

#include  
#include 
pid_t getpid(void); 
pid_t getppid(void);

getpid 和 getppid 函数返回一个类型为 pid_t 的整数值,在 Linux 系统上它在 types.h 中被定义为 int

二、创建和终止进程

进程的三种状态

  • Running
    • 正在CPU上执行
    • 等待被执行
    • 最终被内核调度
  • Stopped/Blocked
    • 进程的执行被挂起
    • 不会被调度
    • 直到收到SIGCONT信号并重新开始运行
  • Terminated
    • 进程永久终止
    • 原因
      • 信号,默认行为是终止
      • 从主程序返回
      • exit

进程终止

#include  
void exit(int status);

exit 函数以 status 退出状态来终止进程
(另一种设置退出状态的方法是从主程序中 返回一个整数值)

创建进程

**Tip:**fork是Unix操作系统独有的, 也就是linux和maxOS都有该函数,而Windows没有…由于《深入了解计算机系统》一书以linux系统为基础,所以想要测试代码的友友需要安装虚拟机(比如说VMWare或者使用Windows下子系统WSL). 当然, Windows下也可以用其它的方法实现类似的效果,只是比较麻烦.

父进程通过调用fork函数创建一个新的运行的子进程.

#include 
#include 
pid_t fork(void);
// 返回:子进程返回0,父进程返回子进程的PID,如果出错,则为-1。

新创建的子进程几乎但不完全与父进程相同。
共同点:

  • 子进程得到与父进程(用户级)虚拟地址空间相同(但是独立的)一份副本
    包括: 代码和数据段, 堆, 共享库, 用户栈

  • 子进程还将获得与父进程打开文件描述符相同的副本

子进程可以读写父进程中打开的任何文件

不同点:
子进程与父进程PID不同

fork函数的特点

调用一次,返回两次

  • 只被调用一次(在父进程中)
  • 返回两次
    • 在父进程中, 返回值是子进程的PID
    • 在子进程中, 返回0

返回值的特性使得程序可以知道是在父进程还是子进程中执行.
下面是一个例子:

int main()
{
	pid_t pid;
	int x = 1;

	pid = Fork();
	if(pid == 0){ /* Child */
		printf("child: x=%d\n", ++x);
		exit(0);
	}

	/* Parent */
	printf("parent: x=%d\n", --x);
	exit(0);
}

这里的Fork是上一章提到的包装函数, 定义如下:

pid_t Fork(void)
{
 	pid_t pid;
 	if ((pid = fork()) < 0)
 		unix_error("Fork error");
 	return pid;
}
void unix_error(char *msg) /* unix-style error */
{
    fprintf(stderr, "%s: %s\n", msg, strerror(errno));
 	exit(0);
}

当在Unix系统上运行这个程序时,我们得到下面的结果:

linux> ./fork
parent: x=0
child : x=2

并发执行

在我们的系统上运行这个程序时,父进程先完成它的 printf语句,然后是子进程。然而,在另一个系统上可能正好相反。
父进程与子进程是并发执行的独立进程,内核能够以任意方式交替执行它们控制流中的指令.
因此不能假定父进程与子进程的执行顺序

父进程与子进程有相同但是独立的地址空间

该地址空间指的是虚拟地址.
也就是说,在这个例子中, 当fork返回时, 子进程和父进程中的x的值都是1. (相同)

然而, 父进程和子进程对x所做的任何改变都是独立的,不会反映在另一个进程的内存中。(独立)
在本例中, 体现为printf输出的内容不同.

共享文件

在这个例子中, stdout(标准输出)就是子进程与父进程共享的文件,所以子进程与父进程的内容才会都输出到(同一个)屏幕上.
子进程会继承父进程所有的打开文件.

fork容易混淆的例子

下面这个例子展示了如何用进程图理解fork的执行顺序:

#include 
#include 
#include 
#include 

int x = 10;
int main(){
	int y = 1, i;
	for(i - 0; i < 2; ++i){
		pid_t pid = fork();
		if(!pid){
		x += y;
		printf("x in child: %d\n", x);
		exit(0);
		}
		y++;
		x <<= 1;
		printf("x in parent: %d\n", x);
		return 0;
	}
}

对进程图中所有顶点进行拓扑排序,即得到程序所有可能的输出结果.
操作系统详解(4)——进程控制(fork, waitpid, sleep, execve)_第1张图片
(图中括号(x,y)表示输出前x与y的值)
比方说,一个可能的输出是:

x in parent: 20
x in parent: 40 
x in child: 11 
x in child: 22

所谓的拓扑排序,可以简单理解为,流程图中子分支上的所有事件一定在主分支上的事件之后发生.
在这个例子中,对x的值进行讨论:
(x=) 20 一定在40之前,也一定在22之前

如果更复杂一些,把exit(0)删去,程序的进程图如下:
(图中C表示输出"x in child"时x与y的值, P表示输出"x in parent"时x与y的值)

操作系统详解(4)——进程控制(fork, waitpid, sleep, execve)_第2张图片

三、回收子进程

回收机制

当一个进程由于某种原因终止时,内核并不是立即把它从系统中清除。相反,进程被保持在一种已终止的状态中,直到被它的父进程回收(reaped)。

不立即清除的原因是,子进程可能需要对父进程报告与其终止的相关信息(退出状态),可以看作是它的"遗言"

当父进程回收已终止的子进程时,内核将子进程的退出状态传递给父进程,然后抛弃子进程(不存在)

一个终止了但还未被回收的进程称为僵死进程(zombie)

如果子进程还未被回收,父进程已经被终止了, 内核会安排init进程回收它.
init进程的PID为1,是在系统启动时由内核创建的,它不会终止,是所有进程的祖先。

Attention: 长时间运行的程序,比如shell或者服务器, 必须手动地去回收僵死子进程, 否则这些僵死进程仍会消耗内存资源.

那么如何手动回收子进程呢?

waitpid函数

一个进程可以通过调用waitpid函数来等待它的子进程终止或者停止。

#include 
#include 

pid_t waitpid(pid_t pid, int *statusp, int options*);
// 返回: 成功,则为子进程的PID;
// WHOHANG, 则为0;
// 其他错误,则为-1

下面具体地解释它的3个参数:

1. 判定等待集合的成员

等待集合的成员是由参数pid来确定的:

  • 如果pid>0,那么等待集合就是一个单独的子进程,它的进程ID等于pid.
  • 如果pid=-1, 那么等待集合就是由父进程所有的子进程组成的。

2. 修改默认行为

可以通过将options 设置为常量 WNOHANG, WUNTRACED和 WCONTINUED 的各种组合来修改默认行为:

  • 默认情况下(当options=0时), waitpid挂起调用进程的执行,直到它的等待集合(wait set)中的一个子进程终止。(就是会一直等着) 如果等待集合中的一个进程在刚调用的时刻就已经终止了,那么waitpid就立即返回。在这两种情况中,waitpid返回导致waitpid 返回的已终止子进程的PID.
  • WNOHANG: 如果等待集合中的任何子进程都还没有终止,那么就立即返回 (返回值为0)。在等待子进程终止的同 时,如果还想做些有用的工作,这个选项会有用
  • WUNTRACED: 挂起调用进程的执行,直到等待集合中的一个进程terminated or stopped(停止)。
  • WCONTINUED: 挂起调用进程的执行,直到等待集合中一个正在运行的进程终止 或等待集合中一个被停止的进程收到SIGCONT信号重新开始执行。

可以用或运算把这些选项组合起来。例如:

  • WNOHANG | WUNTRACED: 立即返回,如果等待集合中的子进程都没有被停止或终止,则返回0: 如果有一个停止或终止,则返回值为该子进程的PID.

3. 检查已回收子进程的退出状态

如果 statusp参数非空,那么waitpid就会在 status 中放上关于导致返回的 子进程的状态信息(statusp->status),wait.h 头文件中定义了解释status参数的宏:

  • WIFEXITED(status): 如果子进程通过调用 exit 或者 return 正常终止,就返回真。
  • WEXITSTATUS(status): 返回一个正常终止的子进程的退出状态。只有在 WIFEXITED()返回为真时,才会定义这个状态。
  • WIFSIGNALED(status): 如果子进程是因为一个未被捕获的信号终止的,那么就返回真。
  • WTERMSIG(status): 返回导致子进程终止的信号的编号。只有在 WIFSIGNALED() 返回为真时,才定义这个状态
  • WIFSTOPPED(status): 如果引起返回的子进程当前是停止 (stopped)的,那么就返回真。
  • WSTOPSIG(status): 返回引起子进程停止的信号的编号。只有在 WIFSTOPPED() 返回为真时,才定义这个状态。
  • WIFCONTINUED(status): 如果子进程收到 SIGCONT信号重新启动,则返回真。

错误条件

  • 如果调用进程没有子进程,那么waitpid返回-1,并且设置 errno 为 ECHILD
  • 如果waitpid函数被一个信号中断,那么它返回-1,并设置 errno 为 EINTR

wait 函数

wait 函数是 waitpid 函数的简单版本:

#include 
#include 

pid_t wait(int *statusp);

调用 wait(&status) 等价于调用 waitpid(-1, &status, 0).
即默认对所有子进程挂起等待.

使用waitpid 的示例

#include 
#include 
#include 
#include 
#include 

void unix_error(char *msg) /* unix-style error */
{
    fprintf(stderr, "%s: %s\n", msg, strerror(errno));
 	exit(0);
}

#define N 2

int main()
{
	int status, i;
	pid_t pid;

	/* Parent creates N children */
    for (i = 0; i < N; i++)
    	if ((pid = Fork()) == 0) /* child */
  	    	exit(100+i);

	/* Parent reaps N chds. in no particular order */
	while((pid = waitpid(-1, &status, 0)) > 0){
		if(WIFEXITED(status))
			printf(("child %d terminated normally with exit status=%d\n", pid, WEXITSTATUS(status));
		else
			printf(("child %d terminated abnormally\n", pid);
	}

	/* The only normal term. is if there no more chds. */
	/* 当回收了所有的子进程之后,再调用 waitpid 就返回-1,并且设置ECHILD */
	if(errno != ECHILD) // 检查waitpid正常终止
		unix_error("waitpid error");

	exit(0);
}

一个示例输出:

unix>./waitpid1
child 22966 terminated normally with exit status=100
child 22967 terminated normally with exit status=101

但是由于子进程是并发的,两个子进程回收的顺序是不确定的.
对程序稍作修改, 消除了这种不确定性,按照父进程创建子进程的相同顺序来回收这些子进程.

int main()
{
	int status, i;
	pid_t pid[N], retpid;

	/* Parent creates N children */
    for (i = 0; i < N; i++)
    	if ((pid = Fork()) == 0) /* child */
  	    	exit(100+i);

	/* Parent reaps N chds. in order */
	i = 0;
	while((retpid = waitpid(pid[i++], &status, 0)) > 0){
		if(WIFEXITED(status))
			printf(("child %d terminated normally with exit status=%d\n", retpid, WEXITSTATUS(status));
		else
			printf(("child %d terminated abnormally\n", retpid);
	}

	/* The only normal term. is if there no more chds. */
	if(errno != ECHILD) 
		unix_error("waitpid error");

	exit(0);
}

四、程序休眠

#include 
unsigned int sleep(unsigned int secs);
//Returns: seconds left to sleep
//如果已经睡满, 返回0
//如果休眠被信号中断而过早返回,返回还剩下的要休眠的秒数.
int pause(void);
//Always returns -1
//使该进程休眠直到收到信号

五、加载并运行程序

execve函数

execve函数在当前进程的上下文中加载并运行一个新程序。

#include 
int execve(const char *filename, const char *argv[], const char *envp[]);

//filename: 可执行文件名
//argv: argument list (命令行参数)
//envp: environment variable list (环境变量)

//does not return if OK, returns -1 on error
//正常情况下绝不返回

命令行参数与环境变量

对于什么是命令行参数与环境变量, 用图演示:
操作系统详解(4)——进程控制(fork, waitpid, sleep, execve)_第3张图片

  • argv变量指向一个以 null结尾的指针数组,其中每个指针都指向一个参数字符串。按照惯例,argv[0]是可执行目标文件的名字。
  • 环境变量的列表是由一个类似的数据结构表示的,envp变量指向一 个以null结尾的指针数组,其中每个指针指向一个环境变量字符串,每个串都是形如 “name=value” 的名字-值对.

在execve加载了filename之后,它调用启动代码, 设置好栈,并将控制传递给新程序的主函数,该主函数有如下形式的原型
int main(int argc, char **argv, char **envp);
int main(int argc, char *argv[], char *envp[]);

三个参数:

  1. argc, 是argv[]中非空指针的数量
  2. argc, 指向argv[]中第一个条目
  3. envp, 指向envp[]中第一个条目

当main开始执行时, 用户栈的组织结构如下所示:

操作系统详解(4)——进程控制(fork, waitpid, sleep, execve)_第4张图片

execve使用例子

比如shell里面键入命令ls -lt /usr/include, 就会运行一个新的程序 ls
并把ls -lt /usr/include作为命令行参数传入, 会影响程序的执行方式.

相关函数

#include 
char *getenv(const char *name);
//Returns: ptr to name if exists, NULL if no match.
int setenv(const char *name, const char *newvalue, int overwrite);    
//若overwrite非零, 用newvalue代替oldvalue
//Returns: 0 on success, -1 on error.
void unsetenv(const char *name);
//如果环境数组包含一个形如"name=oldvalue"的字符串,那么unsetenv会删除它
//Returns: nothing.

程序与进程的区别

程序是一堆代码和数据;程序可以作为目标文件存在于磁盘上,或者作为段存在于地址空间中。

进程是执行中程序的一个具体的实例;程序总是运行在某个进程的上下文中。

fork与execve的区别

fork在新的子进程中运行相同的程序, 新的子进程是父进程的一个复制品.
execve在当前进程的上下文中加载并运行一个新的程序。它会覆盖当前进程的地址空间, 但没有创建一个新进程. 新的程序仍然有相同的PID, 并且继承调用 execve 函数时已打开的所有文件描述符.

总结

介绍了进程控制的相关函数, 包括fork, waitpid, sleep, execve, 其中fork与wait是极其重要且难懂的.

下一章将讲解信号机制以及实现.

你可能感兴趣的:(《深入了解计算机系统》,学习,笔记,linux)