CSAPP 第八章 进程

作者 柚爸
  1. 进程 – 状态
  2. 进程 – 创建进程
  3. 进程 – 回收子进程
  4. 进程 – 休眠
  5. 进程 – 加载和运行程序
  6. 进程 – 多进程程序

进程 – 状态

进程控制有很多系统调用函数.从程序员的角度, 可以认为进程有如下三种状态: 运行, 停止, 终止.

  1. 运行指的是进程在CPU上执行, 或者等待执行, 也就是说会被内核调度程序调度, 类似于做好了运行准备
  2. 停止的进程被挂起, 而且不会被调度. 一般进程收到信号的时候, 就会停止.
  3. 终止, 进程永远停止, 会因为三个情况终止: 收到信号终止, 正常返回, 调用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头定义的:

  1. WNOHANG, 不挂起主进程, 如果所有的子进程都还没终止, 会立刻返回0. 如果不使用这个参数, 调用waitpid的程序(主进程)会一直挂起等到子进程结束.
  2. WUNTRACED, 挂起主进程, 直到等待集合的一个进程变成终止或者停止, 返回那个进程的PID. 不使用这个参数的默认行为是只返回已经终止的子进程.
  3. WCONTINUED, 挂起主进程, 直到等待集合中的一个正在运行的进程终止, 或者一个停止的进程收到SIGCONT信号重新开始运行. 这个可以用来监听重新运行的进程.
  4. 0, 默认的挂起等待子进程结束.

可以用或运算将这三个连接起来, 表示满足某种条件之一就可以返回.

第二个参数是用来接收状态码status的参数, 所以传入一个int类型的指针. waitpid会在status中放入导致返回的子进程的状态信息. 用wait.h库中的几个宏当做函数, 传入status可以来解释这个状态码的意义:

  1. WIFEXITED(status), 如果子进程是通过exit或者return正常返回, 就返回真
  2. WEXITSTATUS(status), 只有当WIFEXITED为真的时候, 返回一个正常终止的子进程的退出状态.
  3. WIFSIGNALED(status), 如果子进程是因为未捕获的信号终止, 返回真
  4. WTERMSIG(status), 返回导致子进程终止的信号的编号, 只有在WIFSIGNALED为真的时候才能使用.
  5. WIFSTOPPED(status), 如果引起返回的子进程当前是停止的, 就返回真
  6. WSTOPSIG(status), 返回引起子进程停止的信号的编号, 只有在WIFSTOPPED返回为真的时候可用
  7. 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函数也可以接受三个参数:

  1. argc 指的是 argv 中不为空的参数数量.
  2. argv 是指向argv数组第一个元素的指针
  3. 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信号.