Linux系统编程-进程

180 阅读3分钟

进程概念

进程其实是程序的动态概念。平时我们用c/c++写的程序,在没有运行之前都是叫做程序不能叫做进程,只有运行起来以后才叫做进程。同时进程是资源的分配单位,而线程是cpu调度单位。在linux内核进程的资源管理是通过pcb(进程控制块)来控制的,对应数据结构task_struct,一个进程涉及到的信息有:

  • pid
  • 进程状态
  • 当前工作目录
  • umask
  • 文件描述符表
  • 与信号相关的信息
  • 用于id、组id
  • 控制终端、session、进程组
  • 环境变量 等等。每个进程都是独占进程地址空间的。

如何创建进程

在linux创建进程很简单,是通过系统调用fork来实现的。大致代码片段为:

pid_t pid = fork()
if (pid < 0) {
    /// errror
} else if (pid == 0) {
    /// 是子进程
} else {
    /// 父进程。返回的pid表示子进程的id
}

fork是一次调用,在用户空间会返回两次,一次是返回的父进程,一次返回到子进程,没有绝对的先后顺序,取决于内核的调度算法。fork之后,子进程会继承父进程的pcb(当然pid是不同的),子进程的进程空间代码段、数据段都完全拷贝父进程,只是执行了不同的代码分支而已。

示例代码为:

#include <unistd.h>
#include <stdlib.h>
#include <stdio.h>


int main(void) {
    int n = 0;
    char *msg = NULL;
    pid_t pid = fork();
    if (pid < 0) {
        perror("failed fork");
        abort();
    } else if (pid == 0) {
        // child 
        n = 5;
        msg = "this is the child process";
    } else {
        // parent 
        n = 3;
        msg = "this is the parent process";
    }

    for (int i = 0; i < n; i++) {
        printf("%s\n", msg);
        sleep(1);
    }

    return 0;
}

shell执行命令过程分析

用了上边的基础之后,对于理解shell是如何执行命令就很好理解了。大概是这样:shell等待用户的输入,当截取到用户的命令之后,会先判断是不是shell的内部命令还是外部命令。如果是内部命令的话,就相当于调用一个函数;如果是外部命令的话,会先调用fork创建一个子进程,在进程里边通过exec函数簇来执行命令,父进程通过wait、waitpid等待子进程的结束,而shell进程用同样的方式等待父进程的结束。

exec函数簇

主要是指如下一坨函数:

image.png

这些函数是有明显的规律的:

  • l:表示将命令行参数散开,最终以NULL结尾。
  • p:表示程序可以不用写全路径,可以通过环境变量找到。不带p的则要提供绝对路径或者相对路径。
  • e:表示带有环境变量,如果传入这些环境变量,那么exec执行的程序拿到的环境变量是以该参数指定的为准,当然这些环境变量组成的字符指针数组也是以NULL结尾的。
  • v:表示将命令行参数合并成一个字符指针数组一并传入,也是以NULL结尾。 最终都会调到execve这个函数。

关于环境变量的要点:

  1. 环境变量所用到的libc接口
#include <stdio.h>
#include <stdlib.h>

void print_env() {
    extern char **environ;

    fprintf(stdout, "************\n");
    for (int i = 0; environ[i] != NULL; i++) {
        fprintf(stdout, "%s\n", environ[i]);
    }
}

int main(void) {
    /// 打印从shell进程继承过来的环境变量
    print_env();

    /// 添加新的环境变量
    setenv("MAX", "10", 1);
    /// 打印所有环境变量时会包含新增MAX
    print_env();

    /// 去掉新增MAX
    unsetenv("MAX");
    /// 打印所有环境变量是不包含MAX
    print_env();
    return 0;
}
  1. 环境变量最终是以exec传递的数组为准 当前我们通过execve来执行某条命令时,如果我们自己指定环境变量字符指针数组传过去的话,最终命令拿到的就是我们传过去的那一部分;如果传NULL,那么命令就拿不到任何环境变量;所以我们在使用execl之类的函数时最终调用execve时会将所有的环境变量都传递过去。

wait/waitpid

进程终止时虽然文件都会关闭、用户空间的内存都会释放,但是在内核中的PCB还保留着的呢。要想彻底的清理掉PCB,那么父进程就需要调用waitwaitpid来进行释放。

这里记录一下他们的用法: wait只能等待第一个子进程返回。而且会一直阻塞等待。 waitpid可以等待指定的子进程返回。也可以通过options指定WNOHANG来表示不阻塞该函数立即返回0。两个函数都可以通过传出参数status来获取退出状态。

  • WIFEXITED:用于判断是否为正常终止。进一步通过WEXITSTATUS获取状态码
  • WIFSIGNALED:用于判断是不是因为信号异常终止。进一步通过WTERMSIG获取导致异常终止的信号编码。

这里有两点需要注意:

  1. 如果一个进程终止时,它的父进程没有来得及调用wait、waitpid。那此时该进程就是僵尸进程。简单来说就是子进程终止了,父进程还在但是没有调用wait、waitpid

示例代码为:

#include <unistd.h>
#include <stdlib.h>
#include <stdio.h>

int main(void) {
    pid_t pid = fork();
    if (pid < 0) {
        perror("failed fork");
        abort();
    } else if (pid == 0) {
        fprintf(stdout, "child process\n");
        exit(1);
    }

    for (;;);
    return 0;
}

通过ps -u就可以看出

image.png 可以看出pid为35776的子进程的status为Z+说明是僵尸进程。解决僵尸进程的方法也很简单,就是将父进程kill就可以,因为会被1号进程回收。

  1. 如果一个进程终止时,父进程早就终止了,那么该进程会被init(systemd进程),也就是1号接管。可以先看看如下的代码实例:
#include <unistd.h>
#include <stdlib.h>
#include <stdio.h>


int main(void) {
    pid_t pid = fork();
    if (pid < 0) {
        perror("failed fork");
        abort();
    } else if (pid == 0) {
        fprintf(stdout, "child process\n");
        sleep(20);
        exit(1);
    }
    return 0;
}

也就是父进程先退出,子进程还在跑,这个子进程会被1号进程接管。 image.png 运行之后我们能看到a.out进行的父进程为1号进程了。

进程树

其实进程也是一种树的关系,用户进程的祖宗是1号进程。通过ps -ef可以看到

image.png CMD一栏中如果没有[]表示用户进程,有[]表示内核进程。用户进程的祖宗是1号进程,内核进程的祖宗为2号进程。TTY那一列为?表示后台进程,也就是一些守护进程。

进程间通信

进程间通信最直观的方式肯定得通过一块公共的区域来实现的,比如通过文件或者内核缓冲区。目前就学习到一种方式,那就是管道,通过系统调用pipe来实现。比如实例代码为:

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

int main(void) {
    int fds[2] = {0};
    pipe(fds); // fds[0]:表示读端。fds[1]:表示写端
    pid_t pid = fork();
    if (pid < 0) {
        perror("failed fork");
        abort();
    } else if (pid == 0) {
        // child 
       close(fds[1]);
       sleep(1);
       char buff[10] = {0};
       read(fds[0], buff, sizeof(buff));
       fprintf(stdout, "reviced:%s\n", buff);
       exit(0);
    } else {
        // parent 
        close(fds[0]);
        write(fds[1], "hello", 5);
    }
    wait(NULL);
    return 0;
}

通过pipe来实现进程间通信,具有明显的两个缺点:

  • 一个pipe只能实现单向,如果要实现双向,就得两个pipe。
  • pipe通信是基于两个fd,而fd的传递方式是通过fork,所以得依赖继承关系来传递的。

image.png