- 进程 - 状态
- 进程 - 创建进程
- 进程 - 回收子进程
- 进程 - 休眠
- 进程 - 加载和运行程序
- 进程 - 多进程程序
进程 - 状态
进程控制有很多系统调用函数.从程序员的角度, 可以认为进程有如下三种状态: 运行, 停止, 终止.
- 运行指的是进程在CPU上执行, 或者等待执行, 也就是说会被内核调度程序调度, 类似于做好了运行准备
- 停止的进程被挂起, 而且不会被调度. 一般进程收到信号的时候, 就会停止.
- 终止, 进程永远停止, 会因为三个情况终止: 收到信号终止, 正常返回, 调用exit函数
每个进程都有一个进程ID, 简称PID. 可以用两个系统函数getpid 和 getppid 分别返回当前进程的PID和父进程的PID:
#include <sys/types.h>
#include <unistd.h>
pid_t getpid(void);
pid_t getppid(void);
如果要终止进程, 则可以调用 stdlib.h中的exit函数强行中止进程并向操作系统返回状态码.
创建进程
创建进程是著名的fork函数, 创建的瞬间实际上程序就分支了. 子进程会得到相同的但是独立的父进程当前状态的一份副本, 包括代码段,数据段,堆,共享库和用户栈, 文件描述符.
由于分支了, 所以fork函数会返回两次, 一次在主进程中, 一次在分支出来的子进程中. 在子进程中的fork返回0, 父进程中返回PID, 所以可以用一个判断来让代码在不同的进程中执行.
可以使用拓扑图来学习进程分支的情况.
练习8.2 fork程序的运行结果
int main(){
int x = 1;
if(Fork() == 0)
printf("p1: x=%d\n", ++x);
printf("p2: x=%d\n", --x);
exit(0);
}
在fork之后, 如果是子进程, 就执行显示++x, 如果是父进程, 就不执行. 然后子进程和父进程都会打印--x.
因此子进程的输出是:
p1: x=2
p2: x=1
父进程的输出是:
p2: x=0
进程 - 回收子进程
进程终止之后是什么样子, 其实处在一种不生不死的样子, 叫做终止状态. 其代码已经不再运行, 但是相关的数据还没有被从内存中清除出去, 即还占据内存空间, 直到被父进程回收.
终止但还没有回收的进程叫做僵尸进程 zombie .
父进程要回收子进程的时候, 去找管理进程的操作系统, 操作系统会把子进程的退出情况传递给父进程, 之后操作系统就会彻底抛弃这个进程(清除内存, 从调度器中去掉该进程). 这个时候子进程就不存在了.
如果子进程还在的时候, 父进程先挂了, 操作系统会安排PID=1的init进程当这个子进程的爹, 由init来负责回收.
具体的来说, 这个过程是通过waitpid函数来操作的.
#include <:sys/types.h>
#include <sys/wait.h>
pid_t waitpid(pid_t pid, int *statusp, int options);
其中的第一个参数用于确定等待集合的成员(等待哪些子进程), 如果pid>0, 就等待这个pid对应的子进程. 如果pid=-1, 会等待当前父进程所有的子进程.
第三个参数options可以设置为一些常量, 这些常量是由wait.h头定义的:
- WNOHANG, 不挂起主进程, 如果所有的子进程都还没终止, 会立刻返回0. 如果不使用这个参数, 调用waitpid的程序(主进程)会一直挂起等到子进程结束.
- WUNTRACED, 挂起主进程, 直到等待集合的一个进程变成终止或者停止, 返回那个进程的PID. 不使用这个参数的默认行为是只返回已经终止的子进程.
- WCONTINUED, 挂起主进程, 直到等待集合中的一个正在运行的进程终止, 或者一个停止的进程收到SIGCONT信号重新开始运行. 这个可以用来监听重新运行的进程.
- 0, 默认的挂起等待子进程结束.
可以用或运算将这三个连接起来, 表示满足某种条件之一就可以返回.
第二个参数是用来接收状态码status的参数, 所以传入一个int类型的指针. waitpid会在status中放入导致返回的子进程的状态信息. 用wait.h库中的几个宏当做函数, 传入status可以来解释这个状态码的意义:
- WIFEXITED(status), 如果子进程是通过exit或者return正常返回, 就返回真
- WEXITSTATUS(status), 只有当WIFEXITED为真的时候, 返回一个正常终止的子进程的退出状态.
- WIFSIGNALED(status), 如果子进程是因为未捕获的信号终止, 返回真
- WTERMSIG(status), 返回导致子进程终止的信号的编号, 只有在WIFSIGNALED为真的时候才能使用.
- WIFSTOPPED(status), 如果引起返回的子进程当前是停止的, 就返回真
- WSTOPSIG(status), 返回引起子进程停止的信号的编号, 只有在WIFSTOPPED返回为真的时候可用
- WIFCONTINUED, 如果子进程收到SIGCONT信号重新启动, 就返回真.
CSAPP 3E提供了csapp.h 和 csapp.c 两个文件供使用. 从官网可以下载到这两个文件, 然后放到 /usr/include 目录内,
之后使用gcc -c csapp.c -o csapp.o编译成目标文件. 再把目标文件复制的各种自己编写的文件同目录下, 然后使用这个目标文件编译就可以了:
gcc main.c csapp.o -lpthread
由于使用了线程库, 所以要加上 -plthread后缀.
练习 8.3 列出下面程序可能的输出序列
int main(){
if(Fork() == 0) {
printf("a");
fflush(stdout);
} else {
printf("b");
fflush(stdout);
waitpid(-1, NULL, 0);
}
printf("c");
fflush(stdout);
exit(0);
}
在fork的时候, 子进程会输出a, 父进程会输出b, 然后父进程要等待子进程结束. 子进程此时会继续向下执行, 输出c. 而父进程在子进程输出完c之后,才会继续执行, 输出c.
所以子进程的ac和父进程的b谁先输出不一定, 但考虑到a一定在c前边输出, 所以可能的序列是abcc ,acbc, bacc.
除了 waitpid, 还有一个wait 函数, 是waitpid 的简单版本, 等于 waitpid(-1, NULL, 0)
, 即父进程等待所有子进程结束.
两个等待子进程结束的例子, 先看一个不按特定顺序回收:
#include <csapp.h>
#include <sys/wait.h>
#include <sys/types.h>
#include <stdio.h>
#include <errno.h>
#include <stdlib.h>
#define N 20
int main() {
int status, i;
pid_t pid;
//主进程创建一个循环用来fork新进程, 每次创建之后如果是子进程, 就会执行exit, 而主进程会继续循环直到创建完所有进程
for (i = 0; i < N; i++) {
if ((pid = Fork()) == 0) {
exit(100 + i);
}
}
//-1表示所有的子进程, 反复调用waitpid, 监听到一个就对返回的status进行测试, 根据是否正常退出打印结果.
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);
}
}
//所有的子进程都被回收之后, waitpid会返回-1并且会设置errno. 如果errno不是ECHILD, 就说明发生了意料之外的错误.
if (errno != ECHILD) {
unix_error("waitpid error");
}
exit(0);
}
这里反复运行的时候, 可以看到回收子进程的顺序是不同的, 这就是并发时候的非确定性行为.
在创建进程的时候稍作改变, 用一个数组来存放pid, 就可以按照顺序来回收子进程:
#include <csapp.h>
#include <sys/wait.h>
#include <sys/types.h>
#include <stdio.h>
#include <errno.h>
#include <stdlib.h>
#define N 20
int main() {
int status, i;
pid_t pid_list[N];
pid_t temp;
//主进程创建一个循环用来fork新进程, 每次创建之后如果是子进程, 就会执行exit
//这里, 注意在fork瞬间 pid_list也会复制一份到新进程, 父子进程的pid_list[i]中都会存pid, 父进程存放的是不为0的pid, 子进程存放的是为0的pid
for (i = 0; i < N; i++) {
if ((pid_list[i] = Fork()) == 0) {
exit(100 + i);
}
}
//重新初始化循环变量
i=0;
//继续用每个pid去监听, 直到监听的pid越界, 如果都执行正确, 刚越界的时候, 父进程已经没有子进程, 所以会返回-1并设置errno
while (temp = waitpid(pid_list[i++], &status, 0) > 0) {
if (WIFEXITED(status)) {
printf("child %d terminated normally with exit status = %d\n", pid_list[i], WEXITSTATUS(status));
} else {
printf("child %d terminated abnormally\n", pid_list[i]);
}
}
//所有的子进程都被回收之后, waitpid会返回-1并且会设置errno. 如果errno不是ECHILD, 就说明发生了意料之外的错误.
if (errno != ECHILD) {
unix_error("waitpid error");
}
exit(0);
}
练习 8.4 多进程程序跟踪
#include <csapp.h>
#include <sys/wait.h>
#include <sys/types.h>
#include <stdio.h>
#include <errno.h>
#include <stdlib.h>
int main(){
int status;
pid_t pid;
printf("Hello\n");
pid = Fork();
printf("%d\n", !pid);
if (pid != 0) {
if (waitpid(-1, &status, 0) > 0) {
if (WIFEXITED(status) != 0) {
printf("%d\n", WEXITSTATUS(status));
}
}
}
printf("Bye\n");
exit(2);
}
A: 判断程序的输出有几行. 画出拓扑图可以知道, 程序先输出一行Hello 然后 分支, 子进程会输出!pid 和Bye, 主进程会输出!pid 然后等待子进程结束.
结束之后由于子进程调用exit退出, WIFEXITED为真, 会打印退出状态. 之后再打印bye. 所以一共有6行.
B: 由于子进程的输出!pid和bye与父进程的输出!pid并发, 所以这三个的顺序很难确定. 一种可能的顺序是:
Hello
0
1
Bye
2
Bye
进程 - 休眠
通过系统调用sleep, 可以主动的向调度器申请将当前进程休眠. sleep 函数如下:
#include <unistd.h>
unsigned int sleep(unsigned int secs);
sleep函数有返回值, 返回的是还剩下多少秒没有休眠完, 如果完成了整个休眠过程, 就返回0. 为什么会出现没有休眠完, 是因为sleep过程中可能收到信号而中断sleep函数.
还有一个函数是 pause, 这个是挂起当前进程, 直到收到信号:
#include <unistd.h>
int pause(void);
练习 8.5 编写一个snooze函数, 和 sleep 功能一样, 但是会返回实际休眠的时间:
#include <unistd.h>
#include <stdio.h>
unsigned int snooze(unsigned int secs) {
unsigned int time = secs - sleep(secs);
printf("Slept for %u of %u secs.", time, secs);
return time;
}
进程 - 加载和运行程序
加载和运行程序的函数是exec家族函数, 在之前复习C语言时候的博客exec()与进程里已经提到了这个函数. CSAPP这里只讲了最常用的execve程序:
#include <unistd.h>
int execve(const char *filename, const char *argv[], const char *envp[]);
这个函数加载可执行文件filename. 第二个参数是参数列表, 列表以NULL作为最后一项, 按惯例, argv[0]就是filename. envp则是环境变量指针数组, 其中每个指针指向一个类似"name=value"的键值对字符串.
这个函数加载之后, 如果成功找到了filename, 就不会再返回到调用的程序, 只有出错才会返回. 成功加载之后, 新开启的进程就会变成加载的程序. 所以一般父进程会先fork一个子进程, 然后在子进程中使用execve来加载程序, 之后这个子进程就变成了要加载的程序本身.
execve加载完程序之后, 就会调用第七章里提到过的系统调用, 最后将控制权交给加载的新程序的主函数, 然后会把argv[]和envp[]都传递给新函数的main函数:
int main(int argc, char **argv, char **envp);
int main(int argc, char *argv[], char *envp[]);
这些参数是压在栈底的. 靠近栈顶的是系统调用libc_start_main函数的栈帧, 之后才是main函数的栈帧.
main函数之前一直使用的是void, 但其实main函数也可以接受三个参数:
- argc 指的是 argv 中不为空的参数数量.
- argv 是指向argv数组第一个元素的指针
- envp 是指向envp数组的第一个元素的指针
在一个运行的程序中, Linux提供了一些函数用于获得环境变量:
#include <stdlib.h>
char *getenv(const char *name);
如果存在 name 环境变量, 就返回一个指向其值的指针
#include <stdlib.h>
int setenv(const char *name, const char *newvalue, int overwrite);
void unsetenv(const char *name);
setenv是设置环境变量. 如果overwrite为真, 就会覆盖. 如果name不存在 无论overwrite值是多少, 就会新增一个键值对.
unsetenv则是删除环境变量. 这两个函数都是操作argv数组.
练习 8.6 编写 myecho 程序, 打印出所有的命令行参数和环境变量
这个程序其实就是遍历数组, 第一个数组可以根据argc参数来确定, 第二个数组就要遍历到NULL为止.
#include <stdio.h>
int main(int argc, char *argv[], char *envp[]){
int i;
printf("Command-line arguments:\n");
for (i = 0; i<argc; i++) {
if (argv[i] != NULL) {
printf("\targv[%2d]: %s\n", i, argv[i]);
}
}
printf("Environment variables:\n");
for (i = 0; ;i++) {
if (envp[i] != NULL) {
printf("\tenvp[%2d]: %s\n", i, envp[i]);
} else {
break;
}
}
return 0;
}
execve的作用是将当前进程变成加载的程序, 因此将fork 和 execve 结合起来, 就有了可以从应用程序中启动应用程序的办法, 就是先 fock 一个新进程, 然后在新的进程中加载想执行的程序.
进程 - 多进程程序
系统级的程序大量使用了fork函数和execve函数来操作和管理程序. 常见的Linux的bash就是一个典型代表. 在用户输入一个命令后, bash读取命令, 然后代替用户执行该命令, CSAPP这里写了一个简单的shell命令. 来分析一下看看.
#include <csapp.h>
#include <stdio.h>
#include <string.h>
#include <stdlib.h>
#include <unistd.h>
#define MAXARGS 128
void eval(char *cmdline);
int parseline(char *buf, char **argv);
int builtin_command(char **argv);
int main(){
//存放用户输入的命令的字符串
char cmdline[MAXLINE];
while(1){
printf("> ");
// 获取用户输入, 复制到cmdline中
Fgets(cmdline, MAXLINE, stdin);
if (feof(stdin)) {
exit(0);
}
//调用eval函数执行命令
eval(cmdline);
}
}
//解析命令行并执行程序的函数
void eval(char *cmdline){
//传递给要调用的函数的argv[]参数
char *argv[MAXARGS];
//修改过的命令行
//这里为何用数组存放而不是char* ,就是因为之后parseline要修改buf的内容.
char buf[MAXLINE];
//用于标记前台还是后台执行, 初始化为0
int bg = 0;
//子进程id
pid_t pid;
//把传入的用户输入复制到buf中
strcpy(buf, cmdline);
//调用parseline函数来检测参数, parseline会解析buf字符串, 将解析后的结果设置到argv中. 然后根据最后一个参数是不是 & ,如果是就返回1, 不是就返回0
bg = parseline(buf, argv);
//解析后的命令行没有, 则直接返回
if (argv[0] == NULL) {
return ;
}
//检测是不是内部命令, 如果不是, 就要创建子进程来运行. 如果是, 就直接运行.
//内置命令在检测的过程中, 直接由builtin_command(argv)运行了. 所以这里都是不是内部命令的情况
if (!builtin_command(argv)) {
//子进程执行的代码, 子进程启动execve去执行命令, 如果执行不了, 就提示命令未找到.
//父进程在fork之后什么也没有干, 继续向下运行. 子进程在这里要么出错退出, 要么变成要执行的程序.
if ((pid = Fork()) == 0) {
//调用execve执行解析后的命令行
//这里如果成功执行, 子进程就变成了要执行的程序
if (execve(argv[0], argv, environ) < 0) {
printf("%s: Command not found.\n", argv[0]);
exit(0);
}
}
//父进程判断子进程结束, 注意, 这里的程序只由父进程执行, 子进程到了这里不是挂了就是变成了其他的程序.
//bg为1表示后台, bgw为0表示前台, 前台的时候才需要等待子进程运行结束
if (!bg) {
int status;
if (waitpid(pid, &status, 0) < 0) {
unix_error("waitfg: waitpid error");
}
} else {
printf("%d %s", pid, cmdline);
}
}
}
int builtin_command(char **argv){
//如果命令是quit 就退出
if (!strcmp(argv[0], "quit")) {
exit(0);
}
if (!strcmp(argv[0], "dir")) {
printf("dir command executed.\n");
return 1;
}
//忽略单独的&字符
if (!strcmp(argv[0], "&")) {
return 1;
}
return 0;
}
//这个函数的作用是将buf字符串中的内容解析好, 放入 argv[] 数组中.
int parseline(char *buf, char **argv){
char *delim;
//用来计数的变量
int argc;
//是否需要后台运行
int bg;
//将buf末尾的\n换成空格
buf[strlen(buf) - 1] = ' ';
//将buf指针移动到第一个不是空格的位置
while (*buf && (*buf == ' ')) {
buf++;
}
argc = 0;
//每次的循环条件是将 delim 指向buf之后的空格的位置
//这段代码到了最后越界的时候是不是有问题, 需要用MAXLINE判断一下
while ((delim = strchr(buf, ' '))) {
//将此时的buf的指针位置赋给argv[]数组中的对应元素
argv[argc++] = buf;
//把delim指向的第一个空格改成\0. 即字符串末尾
*delim = '\0';
//将buf 指针设置到 delim之后的1个位置
buf = delim + 1;
//跳过剩余的空白
while (*buf && (*buf == ' ')) {
buf++;
}
}
argv[argc] = NULL;
// 如果argc是0 , 说明是空白行
if (argc == 0) {
return 1;
}
//如果最后一个参数(倒数第二个索引位置)是 & 字符, 就将其设置为NULL, 然后返回bg=1, 因为不能够将&字符作为参数传给要执行的程序
if ((bg = (*argv[argc - 1] == '&')) != 0) {
argv[--argc] = NULL;
}
return bg;
}
这个程序写好之后, 和csapp.o放在一起, 也是使用 gcc shellex.c csapp.o -o shell -lpthread来编译即可. 成功之后, 就得到了一个shell程序.
运行的时候我加了个dir用来测试也OK. 然后这个 shell 也可以执行任意其他程序, 只要前边加上路径即可.
观察main函数中, 无论前台后台, 程序都会fork然后执行, 对于myshell来讲, 只等待了前台的进程结束, 而并没有等待后台进程结束. 要如何知道无需等待的子进程结束并且收回呢?
这需要使用Linux信号.