系列文章:
操作系统详解(1)——操作系统的作用
操作系统详解(2)——异常处理(Exception)
操作系统详解(3)——进程、并发和并行
文章目录
一、获得进程ID
简称PID(process id)
每个进程都有唯一的正数PID
getpid函数: 返回调用进程的PID
getppid: 返回父进程的PID
#include <sys/types.h>
#include <unistd.h>
pid_t getpid(void);
pid_t getppid(void);
getpid 和 getppid 函数返回一个类型为 pid_t 的整数值,在 Linux 系统上它在 types.h 中被定义为 int
二、创建和终止进程
进程的三种状态
- Running
- 正在CPU上执行
- 等待被执行
- 最终被内核调度
- Stopped/Blocked
- 进程的执行被挂起
- 不会被调度
- 直到收到SIGCONT信号并重新开始运行
- Terminated
- 进程永久终止
- 原因
- 信号,默认行为是终止
- 从主程序返回
- exit
进程终止
#include <stdlib.h>
void exit(int status);
exit 函数以 status 退出状态来终止进程
(另一种设置退出状态的方法是从主程序中 返回一个整数值)
创建进程
**Tip:**fork是Unix操作系统独有的, 也就是linux和maxOS都有该函数,而Windows没有…由于《深入了解计算机系统》一书以linux系统为基础,所以想要测试代码的友友需要安装虚拟机(比如说VMWare或者使用Windows下子系统WSL). 当然, Windows下也可以用其它的方法实现类似的效果,只是比较麻烦.
父进程通过调用fork函数创建一个新的运行的子进程.
#include <unistd.h>
#include <sys/types.h>
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 <sys/types.h>
#include <unistd.h>
#include <stdio.h>
#include <stdlib.h>
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;
}
}
对进程图中所有顶点进行拓扑排序,即得到程序所有可能的输出结果.
(图中括号(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的值)
三、回收子进程
回收机制
当一个进程由于某种原因终止时,内核并不是立即把它从系统中清除。相反,进程被保持在一种已终止的状态中,直到被它的父进程回收(reaped)。
不立即清除的原因是,子进程可能需要对父进程报告与其终止的相关信息(退出状态),可以看作是它的"遗言"
当父进程回收已终止的子进程时,内核将子进程的退出状态传递给父进程,然后抛弃子进程(不存在)
一个终止了但还未被回收的进程称为僵死进程(zombie)
如果子进程还未被回收,父进程已经被终止了, 内核会安排init进程回收它.
init进程的PID为1,是在系统启动时由内核创建的,它不会终止,是所有进程的祖先。
Attention: 长时间运行的程序,比如shell或者服务器, 必须手动地去回收僵死子进程, 否则这些僵死进程仍会消耗内存资源.
那么如何手动回收子进程呢?
waitpid函数
一个进程可以通过调用waitpid函数来等待它的子进程终止或者停止。
#include <syd/types.h>
#include <sys/wait.h>
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 <syd/types.h>
#include <sys/wait.h>
pid_t wait(int *statusp);
调用 wait(&status) 等价于调用 waitpid(-1, &status, 0).
即默认对所有子进程挂起等待.
使用waitpid 的示例
#include <syd/types.h>
#include <sys/wait.h>
#include <unistd.h>
#include <stdio.h>
#include <stdlib.h>
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 <unistd.h>
unsigned int sleep(unsigned int secs);
//Returns: seconds left to sleep
//如果已经睡满, 返回0
//如果休眠被信号中断而过早返回,返回还剩下的要休眠的秒数.
int pause(void);
//Always returns -1
//使该进程休眠直到收到信号
五、加载并运行程序
execve函数
execve函数在当前进程的上下文中加载并运行一个新程序。
#include <unistd.h>
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
//正常情况下绝不返回
命令行参数与环境变量
对于什么是命令行参数与环境变量, 用图演示:
- 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[]);
三个参数:
- argc, 是argv[]中非空指针的数量
- argc, 指向argv[]中第一个条目
- envp, 指向envp[]中第一个条目
当main开始执行时, 用户栈的组织结构如下所示:
execve使用例子
比如shell里面键入命令ls -lt /usr/include
, 就会运行一个新的程序 ls
并把ls -lt /usr/include
作为命令行参数传入, 会影响程序的执行方式.
相关函数
#include <unistd.h>
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是极其重要且难懂的.
下一章将讲解信号机制以及实现.