前言
进程控制是进程管理中最基本的功能。它用于创建一个新进程,终止一个已完成的进程,或终止一个因出现某事件而使其无法运行下去的进程,还可负责进程运行中的状态转换。如当一个正在执行的进程因等待某事件而暂时不能继续执行时,将其转换为阻塞状态,而当该进程所期待的事件出现时,又将该进程转换为就绪状态等等。进程控制一般是由 OS的内核中的原语来实现的。
一、进程创建
1. fork函数
在Linux中fork是非常重要的函数,它可以从一个已经存在的进程中创建一个新的进程。这个新的进程一般称为子进程,而原进程称为父进程。fork()是一个系统调用,用于创建进程。创建的这个进程与原来进程几乎完全相同。这个新产生的进程称为子进程。一个进程调用fork()函数后,系统先给新的进程分配资源,例如存储数据和代码的空间。然后把原来的进程的所有值都复制到新的新进程中,只有少数值与原来的进程的值不同。相当于克隆了一个自己。需要注意的一点:就是调用fork之后,两个进程同时执行的代码段是fork函数之后的代码,而之前的代码已经由父进程执行完毕。
fork手册:
fork函数的返回值
子进程中返回0,父进程返回子进程的PID,出错返回 -1。
进程调用fork,当控制转移到内核中的fork代码后,内核会做以下工作:
- 分配新的内存块和内核数据结构给子进程
- 将父进程部分数据结构内容拷贝至子进程
- 添加子进程到系统进程列表当中
- fork返回,开始调度器调度
当一个进程调用fork之后,就会有两个二进制代码相同的进程。而且他们都在相同的地方运行。但是每个进程都将会有自己的旅程,例如下面的程序:
#include<stdio.h> #include<unistd.h> #include <sys/types.h> #include<stdlib.h> int main() { pid_t pid; printf("Before: pid is %d\n", getpid()); if((pid = fork())==-1) { perror("error"); exit(1); } printf("After: pid is %d, fork return %d\n", getpid(), pid); sleep(1); return 0; }
运行结果:
[lhf@hecs-197241 test]$ ./test.exe Before: pid is 5331 After: pid is 5331, fork return 5332 After: pid is 5332, fork return 0
这里看到了三行输出,一行before,两行after。进程43676先打印before消息,然后它有打印after。另一个after消息有43677打印的。注意到进程43677没有打印before,为什么呢?如下图所示
所以调用fork函数之前父进程独立运行,调用fork之后,父子进程执行流分别执行。这里注意一点**,调用fork之后,父子进程的执行顺序完全由调度器来决定。**
fork常规用法
- 一个父进程希望复制自己,使父子进程同时执行不同的代码段。例如,父进程等待客户端请求,生成子进程来处理请求。
- 一个进程要执行一个不同的程序。例如子进程从fork返回后,调用exec函数。
fork调用失败的原因
- 系统中有太多的进程
- 实际用户的进程数超过了限制
2. 写时拷贝
写时拷贝(copy-on-write, COW)就是等到修改数据时才真正分配内存空间,这是对程序性能的优化,可以延迟甚至是避免内存拷贝,当然目的就是避免不必要的内存拷贝。通常父子进程的代码共享,父子再不写入时,数据也是共享的,当任意一方试图写入,便以写时拷贝的方式各自一份副本。具体见下图:
二、进程终止
1. 进程终止场景和方法
进程终止场景:
- 代码运行完毕,结果正确
- 代码运行完毕,结果不正确
- 代码异常终止
进程常见终止方法:
- 正常终止(可以通过 echo $? 查看进程退出码)
- 从main返回,即return退出
- 调用exit
- _exit
return是一种更常见的退出进程方法。执行return n等同于执行exit(n),因为调用 main 的运行时函数会将 main 的返回值当做 exit 的参数。
- 异常退出
ctrl + c,信号终止
2. _exit函数
_exit函数手册:
参数:
status 定义了进程的终止状态,父进程通过wait来获取该值。
说明:虽然status是int,但是仅有低8位可以被父进程所用。所以_exit(-1)时,在终端执行$?发现返回值是255。
3. exit函数
exit函数手册:
exit最后也会调用_exit, 但在调用_exit之前,还做了其他工作:
- 执行用户通过 atexit或on_exit定义的清理函数。
- 关闭所有打开的流,所有的缓存数据均被写入
- 调用_exit
例如:
int main() { printf("hello"); exit(0); }
运行结果:
[root@localhost linux]# ./a.out
hello[root@localhost linux]#
int main() { printf("hello"); _exit(0); }
运行结果:
[root@localhost linux]# ./a.out
[root@localhost linux]#
三、进程等待
1. 进程等待的必要性
- 之前我们了解过,子进程退出,父进程如果不管不顾,就可能造成 “僵尸进程” 的问题,进而造成内存泄漏。另外,进程一旦变成僵尸状态,那就刀枪不入,“杀人不眨眼” 的kill -9 也无能为力,因为谁也没有办法杀死一个已经死去的进程。
- 程序运行结束后,我们需要知道父进程派给子进程的任务完成的如何。比如,我们需要知道子进程运行完成,结果对还是不对,或者是否正常退出。
- 父进程通过进程等待的方式,回收子进程资源,获取子进程退出信息。
2. 进程等待的方法
wait方法:
#include<sys/types.h> #include<sys/wait.h> pid_t wait(int*status);
返回值:
成功返回被等待进程pid,失败返回-1。
参数:
输出型参数,获取子进程退出状态,不关心则可以设置成为NULL
waitpid方法:
#include<sys/types.h> #include<sys/wait.h> pid_ t waitpid(pid_t pid, int *status, int options);
返回值:
- 当正常返回的时候waitpid返回收集到的子进程的进程ID;
- 如果设置了选项WNOHANG,而调用中waitpid发现没有已退出的子进程可收集,则返回0;
- 如果调用中出错,则返回-1,这时errno会被设置成相应的值以指示错误所在;
参数:
- pid:
pid = -1,等待任一个子进程。与wait等效。
pid > 0.等待其进程ID与pid相等的子进程。- status:
WIFEXITED(status): 若为正常终止子进程返回的状态,则为真。(查看进程是否是正常退出)
WEXITSTATUS(status): 若WIFEXITED非零,提取子进程退出码。(查看进程的退出码)
- options:
WNOHANG: 若pid指定的子进程没有结束,则waitpid()函数返回0,不予以等待。若正常结束,则返回该子进程的ID。
【注意事项】
- 如果子进程已经退出,调用wait/waitpid时,wait/waitpid会立即返回,并且释放资源,获得子进程退出信息。
- 如果在任意时刻调用wait/waitpid,子进程存在且正常运行,则进程可能阻塞。
- 如果不存在该子进程,则立即出错返回。
3. 获取子进程的退出码
- wait和waitpid,都有一个status参数,该参数是一个输出型参数,由操作系统填充。
- 如果传递NULL,表示不关心子进程的退出状态信息。
- 否则,操作系统会根据该参数,将子进程的退出信息反馈给父进程。
- status不能简单的当作整形来看待,可以当作位图来看待,具体细节如下图(只研究status低16比特位):
测试代码如下:
- 进程的阻塞式等待
#include <sys/wait.h> #include <stdio.h> #include <stdlib.h> #include <string.h> #include <errno.h> #include<unistd.h> int main() { pid_t pid; pid = fork(); if(pid < 0) { printf("%s fork error", __FUNCTION__); return 1; } else if(pid == 0) { printf("我是子进程,pid = %d, ppid = %d\n", getpid(), getppid()); sleep(5); exit(0); } else { int status = 0; pid_t ret = waitpid(-1, &status, 0); //阻塞式等待,等待5秒 printf("我是父进程,我在等待子进程,pid = %d\n", getpid()); if(WIFEXITED(status) && ret == pid) { printf("等待子进程成功,子进程的退出码为 %d\n", WEXITSTATUS(status)); } else { printf("等待子进程失败。\n"); return 1; } } return 0; }
运行结果:
[lhf@hecs-197241 test]$ ./wait.exe 我是子进程,pid = 9374, ppid = 9373 我是父进程,我在等待子进程,pid = 9373 等待子进程成功,子进程的退出码为 0
- 进程的非阻塞式等待
int main() { pid_t pid; pid = fork(); if(pid < 0) { printf("%s fork error", __FUNCTION__); return 1; } else if(pid == 0) { printf("我是子进程,pid = %d, ppid = %d\n", getpid(), getppid()); sleep(5); exit(1); } else { int status = 0; pid_t ret = 0; do { ret = waitpid(-1, &status, WNOHANG);//非阻塞式等待 if(ret == 0) { printf("子进程正在运行\n"); } sleep(1); }while(ret ==0); if(WIFEXITED(status) && ret == pid) { printf("等待子进程成功,子进程的退出码为 %d\n", WEXITSTATUS(status)); } else { printf("等待子进程失败。\n"); return 1; } } return 0; }
运行结果:
[lhf@hecs-197241 test]$ ./wait.exe 我是子进程,pid = 10227, ppid = 10226 子进程正在运行 子进程正在运行 子进程正在运行 子进程正在运行 子进程正在运行 等待子进程成功,子进程的退出码为 1
四、进程替换
1. 替换原理
用fork创建子进程后执行的是和父进程相同的程序(但有可能执行不同的代码分支),子进程往往要调用一种exec函数以执行另一个程序。当进程调用一种exec函数时,该进程的用户空间代码和数据完全被新程序替换,从新程序的启动例程开始执行。调用exec并不创建新进程,所以调用exec前后该进程的id并未改变。
2. 替换函数
有六种以exec开头的函数,统称exec函数。
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函数只有出错的返回值而没有成功的返回值。
命名理解
这些函数原型看起来很容易混,但只要掌握了规律就很好记。
- l (list) : 表示参数采用列表
- v (vector) : 参数用数组
- p (path) : 有p自动搜索环境变量PATH
- e (env) : 表示自己维护环境变量
exec调用举例:
#include <unistd.h> int main() { char *const argv[] = {"ps", "-ef", NULL}; char *const envp[] = {"PATH=/bin:/usr/bin", "TERM=console", NULL}; execl("/bin/ps", "ps", "-ef", NULL); // 带p的,可以使用环境变量PATH,无需写全路径 execlp("ps", "ps", "-ef", NULL); // 带e的,需要自己组装环境变量 execle("ps", "ps", "-ef", NULL, envp); execv("/bin/ps", argv); // 带p的,可以使用环境变量PATH,无需写全路径 execvp("ps", argv); // 带e的,需要自己组装环境变量 execve("/bin/ps", argv, envp); exit(0); }
五、一个简易的Shell程序
用下图的时间轴来表示事件的发生次序。其中时间从左向右。shell由标识为sh的方块代表,它随着时间的流逝从左向右移动。shell从用户读入字符串"ls"。shell建立一个新的进程,然后在那个进程中运行ls程序并等待那个进程结束。
然后shell读取新的一行输入,建立一个新的进程,在这个进程中运行程序 并等待这个进程结束。所以要写一个shell,需要循环以下过程:
- 获取命令行
- 解析命令行
- 建立一个子进程(fork)
- 替换子进程(execvp)
- 父进程等待子进程退出(wait)
实现代码:
#include <stdio.h> #include <string.h> #include <stdlib.h> #include <unistd.h> #include <sys/types.h> #include <sys/wait.h> #define SEP " " #define NUM 1024 #define SIZE 128 char command_line[NUM]; char *command_args[SIZE]; int main() { //shell 本质上就是一个死循环 while(1) { //不关心获取这些属性的接口, 搜索一下 //1. 显示提示符 printf("[张三@我的主机名 当前目录]# "); fflush(stdout); //2. 获取用户输入 memset(command_line, '\0', sizeof(command_line)*sizeof(char)); fgets(command_line, NUM, stdin); //键盘,标准输入,stdin, 获取到的是c风格的字符串, '\0' command_line[strlen(command_line) - 1] = '\0';// 清空\n //3. "ls -a -l -i" -> "ls" "-a" "-l" "-i" 字符串切分 command_args[0] = strtok(command_line, SEP); int index = 1; // = 是故意这么写的 // strtok 截取成功,返回字符串其实地址 // 截取失败,返回NULL while(command_args[index++] = strtok(NULL, SEP)); //for debug //for(int i = 0 ; i < index; i++) //{ // printf("%d : %s\n", i, command_args[i]); //} // 4. TODO, 编写后面的逻辑, 内建命令 // 5. 创建进程,执行 pid_t id = fork(); if(id == 0) { //child // 6. 程序替换 //exec*? execvp(command_args[0]/*不就是保存的是我们要执行的程序名字吗?*/, command_args); exit(1); //执行到这里,子进程一定替换失败 } int status = 0; pid_t ret = waitpid(id, &status, 0); if(ret > 0) { printf("等待子进程成功: sig: %d, code: %d\n", status&0x7F, (status>>8)&0xFF); } }// end while return 0; }
总结
在不断的学习中我们会发现函数和进程之间具有相似性。一个C程序有很多函数组成。一个函数可以调用另外一个函数,同时传递给它一些参数。被调用的函数执行一定的操作,然后返回一个值。每个函数都有他的局部变量,不同的函数通过call/return系统进行通信。这种通过参数和返回值在拥有私有数据的函数间通信的模式是结构化程序设计的基础。Linux鼓励将这种应用于程序之内的模式扩展到程序之间。如下图:
一个C程序可以fork/exec另一个程序,并传给它一些参数。这个被调用的程序执行一定的操作,然后通过exit(n)来返回值。调用它的进程可以通过wait(&ret)来获取exit的返回值。