欢迎光临
我们一直在努力

第4讲:进程控制

本节重点

  • 学习进程创建:fork / vfork
  • 学习进程终止,认识 $?
  • 学习进程等待
  • 学习进程程序替换
  • 实现微型 Shell,重新认识 Shell 的运行原理

目录

  • [[#1. 进程创建|1. 进程创建]]
  • [[#2. 进程终止|2. 进程终止]]
  • [[#3. 进程等待|3. 进程等待]]
  • [[#4. 进程程序替换|4. 进程程序替换]]
  • [[#5. 自主 Shell 命令行解释器|5. 自主 Shell 命令行解释器]]

1. 进程创建

1.1 fork 函数初识

在 Linux 中,fork 是非常重要的函数。它从已有进程中创建一个新进程,新进程为子进程,原进程为父进程。

#include <unistd.h>

pid_t fork(void);

返回值:在子进程中返回 0,在父进程中返回子进程的 PID,出错时返回 -1。

  • 为什么要给子进程返回 0,给父进程返回子进程的 PID?
  • 为什么一个 fork 函数会有两个返回值?
  • 为什么同一个返回值既可能等于 0,又可能大于 0?
  • 进程调用 fork,当控制转移到内核中的 fork 代码后,内核会:

    • 为子进程分配新的内存块和内核数据结构。
    • 将父进程的部分数据结构内容复制到子进程。
    • 将子进程添加到系统进程列表中。
    • 从 fork 返回,开始由调度器调度。

    image.png

    当一个进程调用 fork 之后,就会有两个二进制代码相同的进程,而且它们都运行到了相同的位置。之后,每个进程都可以开始自己的执行过程。示例如下:

    int main(void)
    {
    pid_t pid;

    printf("Before: pid is %d\\n", getpid());
    if ((pid = fork()) == –1)
    perror("fork()"), exit(1);
    printf("After:pid is %d, fork return %d\\n", getpid(), pid);
    sleep(1);
    return 0;
    }

    运行结果:

    [root@localhost linux]# ./a.out
    Before: pid is 43676
    After:pid is 43676, fork return 43677
    After:pid is 43677, fork return 0

    这里有三行输出:一行 Before,两行 After。进程 43676 先打印 Before,然后打印 After;另一个 After 由进程 43677 打印。进程 43677 没有打印 Before,原因如下图所示。

    image.png

    所以,fork 之前由父进程独立执行;fork 之后,父、子两个执行流分别执行。fork 之后谁先执行,完全由调度器决定。

    1.2 fork 函数返回值

    • 子进程返回 0。
    • 父进程返回子进程的 PID。

    1.3 写时拷贝

    通常,父子进程共享代码;父子进程都不写入时,数据也是共享的。当任意一方试图写入时,便以写时拷贝的方式各自保留一份副本。具体见下图。

    image.png

    写时拷贝技术使父子进程得以彻底分离,为进程独立性提供技术保证。

    写时拷贝是一种延时申请技术,可以提高整机内存的使用率。

    1.4 fork 的常规用法

    • 父进程希望复制自己,使父子进程同时执行不同的代码段。例如,父进程等待客户端请求,生成子进程处理请求。
    • 一个进程需要执行另一个程序。例如,子进程从 fork 返回后调用 exec 函数。

    1.5 fork 调用失败的原因

    • 系统中有太多进程。
    • 实际用户的进程数超过限制。

    2. 进程终止

    进程终止的本质是释放系统资源,也就是释放进程申请的相关内核数据结构以及对应的数据和代码。

    2.1 进程退出场景

    • 代码运行完毕,结果正确。
    • 代码运行完毕,结果不正确。
    • 代码异常终止。

    2.2 进程常见退出方法

    正常终止,可以通过 echo $? 查看进程退出码:

  • 从 main 返回。
  • 调用 exit。
  • 调用 _exit。
  • 异常退出:

    • Ctrl+C,由信号终止。
    2.2.1 退出码

    退出码(退出状态)可以告诉我们最后一次执行命令的状态。命令结束后,可以据此判断命令是成功完成,还是以错误结束。其基本思想是:程序返回退出码 0 表示执行成功,任何非 0 退出码都表示执行未成功。

    Linux Shell 中的主要退出码如下图所示。

    image.png

    • 退出码 0 表示命令执行无误,这是完成命令的理想状态。
    • 退出码 1 可以解释为“不被允许的操作”。例如,在没有 sudo 权限时使用 yum;再例如,除以 0 等操作也会返回错误码 1,对应命令为 let a=1/0。
    • 130(SIGINT 或 ^C)和 143(SIGTERM)等终止状态非常典型,它们属于 128+n,其中 n 代表终止信号编号。
    • 可以使用 strerror 函数获取退出码对应的描述。
    2.2.2 _exit 函数

    #include <unistd.h>

    void _exit(int status);

    参数 status 定义进程的终止状态,父进程通过 wait 获取该值。

    虽然 status 是 int,但是只有低 8 位可以被父进程使用。所以调用 _exit(-1) 时,在终端执行 echo $?,会发现返回值是 255。

    2.2.3 exit 函数

    #include <stdlib.h>

    void exit(int status);

    exit 最后也会调用 _exit,但在调用 _exit 之前还会:

  • 执行用户通过 atexit 或 on_exit 定义的清理函数。
  • 关闭所有打开的流,将所有缓存数据写出。
  • 调用 _exit。
  • image.png

    实例:

    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]#

    2.2.4 return 退出

    return 是一种更常见的退出进程方式。执行 return n 等同于执行 exit(n),因为调用 main 的运行时函数会将 main 的返回值作为 exit 的参数。

    3. 进程等待

    3.1 进程等待的必要性

    • 子进程退出后,父进程如果不管不顾,就可能造成“僵尸进程”问题,进而造成内存泄漏。
    • 进程一旦变成僵尸状态,kill -9 也无能为力,因为没有办法杀死一个已经死去的进程。
    • 父进程派给子进程的任务完成得如何,需要被父进程获知。例如,子进程运行完成后,结果是否正确,或者是否正常退出。
    • 父进程通过进程等待的方式回收子进程资源,并获取子进程退出信息。

    3.2 进程等待的方法

    3.2.1 wait 方法

    #include <sys/types.h>
    #include <sys/wait.h>

    pid_t wait(int *status);

    • 返回值:成功时返回被等待进程的 PID,失败时返回 -1。
    • 参数:status 是输出型参数,用于获取子进程退出状态;不关心时可以设为 NULL。
    3.2.2 waitpid 方法

    pid_t waitpid(pid_t pid, int *status, int options);

    返回值:

    • 正常返回时,waitpid 返回收集到的子进程 PID。
    • 如果设置了 WNOHANG,且调用时没有已退出的子进程可收集,则返回 0。
    • 如果调用出错,则返回 -1,同时设置 errno 指示错误。

    参数:

    • pid == -1:等待任意一个子进程,与 wait 等效。
    • pid > 0:等待进程 ID 等于 pid 的子进程。
    • status:输出型参数。
    • WIFEXITED(status):如果子进程正常终止,则结果为真,用于判断进程是否正常退出。
    • WEXITSTATUS(status):当 WIFEXITED(status) 非零时,提取子进程退出码。
    • options:默认为 0,表示阻塞等待。
    • WNOHANG:如果 pid 指定的子进程没有结束,waitpid 返回 0,不进行等待;如果已经正常结束,则返回该子进程的 PID。

    此外:

    • 如果子进程已经退出,调用 wait / waitpid 时会立即返回,同时释放资源并取得子进程退出信息。
    • 如果调用 wait / waitpid 时子进程存在且正在正常运行,父进程可能阻塞。
    • 如果不存在相应的子进程,则立即出错返回。

    image.png

    3.2.3 获取子进程 status
    • wait 和 waitpid 都有一个 status 参数,该参数是由操作系统填充的输出型参数。
    • 如果传入 NULL,表示不关心子进程的退出状态信息。
    • 否则,操作系统会通过该参数将子进程的退出信息反馈给父进程。
    • status 不能简单地作为普通整数看待,可以将其视为位图。这里只研究 status 的低 16 位。

    image.png

    测试代码:

    #include <sys/wait.h>
    #include <stdio.h>
    #include <stdlib.h>
    #include <string.h>
    #include <errno.h>
    #include <unistd.h>

    int main(void)
    {
    pid_t pid;

    if ((pid = fork()) == –1)
    perror("fork"), exit(1);

    if (pid == 0) {
    sleep(20);
    exit(10);
    } else {
    int st;
    int ret = wait(&st);

    if (ret > 0 && (st & 0x7F) == 0) { // 正常退出
    printf("child exit code:%d\\n", (st >> 8) & 0xFF);
    } else if (ret > 0) { // 异常退出
    printf("sig code : %d\\n", st & 0x7F);
    }
    }
    }

    测试结果:

    # ./a.out # 等 20 秒退出
    child exit code:10
    # ./a.out # 在其他终端 kill 掉
    sig code : 9

    3.2.4 阻塞与非阻塞等待

    进程的阻塞等待方式:

    int main()
    {
    pid_t pid;
    pid = fork();
    if (pid < 0) {
    printf("%s fork error\\n", __FUNCTION__);
    return 1;
    } else if (pid == 0) { // child
    printf("child is run, pid is : %d\\n", getpid());
    sleep(5);
    exit(257);
    } else {
    int status = 0;
    pid_t ret = waitpid(–1, &status, 0); // 阻塞式等待,等待 5 秒
    printf("this is test for wait\\n");
    if (WIFEXITED(status) && ret == pid) {
    printf("wait child 5s success, child return code is :%d.\\n",
    WEXITSTATUS(status));
    } else {
    printf("wait child failed, return.\\n");
    return 1;
    }
    }
    return 0;
    }

    运行结果:

    [root@localhost linux]# ./a.out
    child is run, pid is : 45110
    this is test for wait
    wait child 5s success, child return code is :1.

    进程的非阻塞等待方式:

    #include <stdio.h>
    #include <stdlib.h>
    #include <sys/wait.h>
    #include <unistd.h>
    #include <vector>

    typedef void (*handler_t)(); // 函数指针类型
    std::vector<handler_t> handlers; // 函数指针数组

    void fun_one()
    {
    printf("这是一个临时任务1\\n");
    }

    void fun_two()
    {
    printf("这是一个临时任务2\\n");
    }

    void Load()
    {
    handlers.push_back(fun_one);
    handlers.push_back(fun_two);
    }

    void handler()
    {
    if (handlers.empty())
    Load();
    for (auto iter : handlers)
    iter();
    }

    int main()
    {
    pid_t pid;

    pid = fork();
    if (pid < 0) {
    printf("%s fork error\\n", __FUNCTION__);
    return 1;
    } else if (pid == 0) { // child
    printf("child is run, pid is : %d\\n", getpid());
    sleep(5);
    exit(1);
    } else {
    int status = 0;
    pid_t ret = 0;
    do {
    ret = waitpid(–1, &status, WNOHANG); // 非阻塞式等待
    if (ret == 0) {
    printf("child is running\\n");
    }
    handler();
    } while (ret == 0);

    if (WIFEXITED(status) && ret == pid) {
    printf("wait child 5s success, child return code is :%d.\\n",
    WEXITSTATUS(status));
    } else {
    printf("wait child failed, return.\\n");
    return 1;
    }
    }
    return 0;
    }

    4. 进程程序替换

    fork 之后,父子进程各自执行父进程代码的一部分。如果子进程想执行一个全新的程序,就需要通过进程程序替换完成。

    程序替换通过特定接口,将磁盘上一个全新程序的代码和数据加载到调用进程的地址空间中。

    4.1 替换原理

    用 fork 创建子进程后,子进程执行的是与父进程相同的程序,但可能执行不同的代码分支。子进程往往要调用一种 exec 函数执行另一个程序。当进程调用某种 exec 函数时,该进程的用户空间代码和数据会完全被新程序替换,并从新程序的启动例程开始执行。调用 exec 并不创建新进程,所以调用前后该进程的 PID 并未改变。

    image.png

    4.2 替换函数

    共有六种以 exec 开头的函数,统称为 exec 函数:

    #include <unistd.h>

    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[]);

    4.2.1 函数解释
    • 这些函数调用成功时,会加载新程序并从启动代码开始执行,不再返回。
    • 调用出错时返回 -1。
    • 因此,exec 函数只有出错返回值,没有成功返回值。
    4.2.2 命名理解

    这些函数原型看起来容易混淆,但掌握命名规律后就容易记忆:

    • l(list):参数采用列表。
    • v(vector):参数使用数组。
    • p(path):自动搜索环境变量 PATH 指定的路径。
    • e(env):由调用者维护环境变量。

    image.png

    exec 调用示例如下:

    #include <unistd.h>
    #include <stdlib.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("/bin/ps", "ps", "-ef", NULL, envp);

    execv("/bin/ps", argv);

    // 带 p 的函数可以使用环境变量 PATH,无需写完整路径
    execvp("ps", argv);

    // 带 e 的函数需要自行组装环境变量
    execve("/bin/ps", argv, envp);

    exit(0);
    }

    事实上,只有 execve 是真正的系统调用,其他五个函数最终都会调用 execve。因此,execve 位于 man 手册第 2 节,其他函数位于 man 手册第 3 节。这些函数之间的关系如下图所示。

    image.png

    5. 自主 Shell 命令行解释器

    5.1 目标

    • 能处理普通命令。
    • 能处理内建命令。
    • 帮助理解内建命令、本地变量和环境变量等概念。
    • 帮助理解 Shell 的运行原理。

    5.2 实现原理

    考虑下面这个与 Shell 的典型互动:

    [root@localhost epoll]# ls
    client.cpp readme.md server.cpp utility.h
    [root@localhost epoll]# ps
    PID TTY TIME CMD
    3451 pts/0 00:00:00 bash
    3514 pts/0 00:00:00 ps

    Shell 从用户处读入字符串 ls,建立一个新进程,在该进程中运行 ls 程序,并等待这个进程结束。下图用时间轴表示事件发生的次序,时间从左向右;Shell 由标识为 sh 的方块表示,它随着时间流逝从左向右移动。

    image.png

    然后,Shell 读取新的一行输入,建立一个新进程,在这个进程中运行程序,并等待这个进程结束。

    所以,要编写一个 Shell,需要循环执行以下过程:

  • 获取命令行。
  • 解析命令行。
  • 建立子进程(fork)。
  • 替换子进程(execvp)。
  • 父进程等待子进程退出(wait)。
  • 根据这些思路和前面学过的技术,就可以自行实现一个 Shell。

    5.3 源码

    实现代码:

    #include <iostream>
    #include <cstdio>
    #include <cstdlib>
    #include <cstring>
    #include <string>
    #include <unistd.h>
    #include <sys/types.h>
    #include <sys/wait.h>
    #include <ctype.h>

    using namespace std;

    const int basesize = 1024;
    const int argvnum = 64;
    const int envnum = 64;

    // 全局的命令行参数表
    char *gargv[argvnum];
    int gargc = 0;

    // 全局的变量
    int lastcode = 0;

    // 我的系统的环境变量
    char *genv[envnum];

    // 全局的当前 Shell 工作路径
    char pwd[basesize];
    char pwdenv[basesize];

    // " "file.txt
    #define TrimSpace(pos) \\
    do { \\
    while (isspace(*pos)) { \\
    pos++; \\
    } \\
    } while (0)

    string GetUserName()
    {
    string name = getenv("USER");
    return name.empty() ? "None" : name;
    }

    string GetHostName()
    {
    string hostname = getenv("HOSTNAME");
    return hostname.empty() ? "None" : hostname;
    }

    string GetPwd()
    {
    if (nullptr == getcwd(pwd, sizeof(pwd)))
    return "None";
    snprintf(pwdenv, sizeof(pwdenv), "PWD=%s", pwd);
    putenv(pwdenv); // PWD=XXX
    return pwd;

    // string pwd = getenv("PWD");
    // return pwd.empty() ? "None" : pwd;
    }

    string LastDir()
    {
    string curr = GetPwd();
    if (curr == "/" || curr == "None")
    return curr;
    // /home/whb/XXX
    size_t pos = curr.rfind("/");
    if (pos == std::string::npos)
    return curr;
    return curr.substr(pos + 1);
    }

    string MakeCommandLine()
    {
    // [whb@bite-alicloud myshell]$
    char command_line[basesize];
    snprintf(command_line, basesize, "[%s@%s %s]# ",
    GetUserName().c_str(), GetHostName().c_str(), LastDir().c_str());
    return command_line;
    }

    void PrintCommandLine() // 1. 命令行提示符
    {
    printf("%s", MakeCommandLine().c_str());
    fflush(stdout);
    }

    bool GetCommandLine(char command_buffer[], int size) // 2. 获取用户命令
    {
    // 我们认为:要将用户输入的命令行当作一个完整的字符串
    // "ls -a -l -n"
    char *result = fgets(command_buffer, size, stdin);
    if (!result) {
    return false;
    }
    command_buffer[strlen(command_buffer) – 1] = 0;
    if (strlen(command_buffer) == 0)
    return false;
    return true;
    }

    void ParseCommandLine(char command_buffer[], int len) // 3. 分析命令
    {
    (void)len;
    memset(gargv, 0, sizeof(gargv));
    gargc = 0;

    // "ls -a -l -n"
    const char *sep = " ";
    gargv[gargc++] = strtok(command_buffer, sep);
    // = 是刻意写的
    while ((bool)(gargv[gargc++] = strtok(nullptr, sep)))
    ;
    gargc—;
    }

    void debug()
    {
    printf("argc: %d\\n", gargc);
    for (int i = 0; gargv[i]; i++) {
    printf("argv[%d]: %s\\n", i, gargv[i]);
    }
    }

    // 在 Shell 中
    // 有些命令必须由子进程执行
    // 有些命令不能由子进程执行,要由 Shell 自己执行 — 内建命令 built command
    bool ExecuteCommand() // 4. 执行命令
    {
    // 让子进程进行执行
    pid_t id = fork();
    if (id < 0)
    return false;
    if (id == 0) {
    // 子进程
    // 1. 执行命令
    execvpe(gargv[0], gargv, genv);
    // 2. 退出
    exit(1);
    }

    int status = 0;
    pid_t rid = waitpid(id, &status, 0);
    if (rid > 0) {
    if (WIFEXITED(status)) {
    lastcode = WEXITSTATUS(status);
    } else {
    lastcode = 100;
    }
    return true;
    }
    return false;
    }

    void AddEnv(const char *item)
    {
    int index = 0;
    while (genv[index]) {
    index++;
    }

    genv[index] = (char *)malloc(strlen(item) + 1);
    strncpy(genv[index], item, strlen(item) + 1);
    genv[++index] = nullptr;
    }

    // Shell 自己执行命令,本质是 Shell 调用自己的函数
    bool CheckAndExecBuiltCommand()
    {
    if (strcmp(gargv[0], "cd") == 0) {
    // 内建命令
    if (gargc == 2) {
    chdir(gargv[1]);
    lastcode = 0;
    } else {
    lastcode = 1;
    }
    return true;
    } else if (strcmp(gargv[0], "export") == 0) {
    // export 也是内建命令
    if (gargc == 2) {
    AddEnv(gargv[1]);
    lastcode = 0;
    } else {
    lastcode = 2;
    }
    return true;
    } else if (strcmp(gargv[0], "env") == 0) {
    for (int i = 0; genv[i]; i++) {
    printf("%s\\n", genv[i]);
    }
    lastcode = 0;
    return true;
    } else if (strcmp(gargv[0], "echo") == 0) {
    if (gargc == 2) {
    // echo $?
    // echo $PATH
    // echo hello
    if (gargv[1][0] == '$') {
    if (gargv[1][1] == '?') {
    printf("%d\\n", lastcode);
    lastcode = 0;
    }
    } else {
    printf("%s\\n", gargv[1]);
    lastcode = 0;
    }
    } else {
    lastcode = 3;
    }
    return true;
    }
    return false;
    }

    // 作为一个 Shell,获取环境变量应该从系统配置中读取
    // 这里直接从父 Shell 中获取环境变量
    void InitEnv()
    {
    extern char **environ;
    int index = 0;
    while (environ[index]) {
    genv[index] = (char *)malloc(strlen(environ[index]) + 1);
    strncpy(genv[index], environ[index], strlen(environ[index]) + 1);
    index++;
    }
    genv[index] = nullptr;
    }

    int main()
    {
    InitEnv();
    char command_buffer[basesize];
    while (true) {
    PrintCommandLine(); // 1. 命令行提示符
    // command_buffer -> output

    if (!GetCommandLine(command_buffer, basesize)) { // 2. 获取用户命令
    continue;
    }

    // printf("%s\\n", command_buffer);
    ParseCommandLine(command_buffer, strlen(command_buffer)); // 3. 分析命令

    if (CheckAndExecBuiltCommand()) {
    continue;
    }

    ExecuteCommand(); // 4. 执行命令
    }
    return 0;
    }

    5.4 总结

    在继续学习新知识前,思考函数和进程之间的相似性。

    exec / exit 就像 call / return。

    一个 C 程序由很多函数组成。一个函数可以调用另一个函数,同时传递一些参数;被调用的函数执行一定的操作,然后返回一个值。每个函数都有自己的局部变量,不同函数通过 call / return 机制进行通信。

    这种通过参数和返回值在拥有私有数据的函数之间通信的模式,是结构化程序设计的基础。Linux 鼓励将这种应用于程序内部的模式扩展到程序之间,如下图所示。

    image.png

    一个 C 程序可以通过 fork / exec 调用另一个程序,并向它传递一些参数。被调用的程序执行一定的操作,然后通过 exit(n) 返回值;调用它的进程可以通过 wait(&ret) 获取 exit 的返回值。

    赞(0)
    未经允许不得转载:171主机测评 » 第4讲:进程控制
    分享到: 更多 (0)

    评论 抢沙发

    • 昵称 (必填)
    • 邮箱 (必填)
    • 网址