程序:是状态机的静态描述,描述了所有可能的程序状态

进程:程序 (动态) 运行起来,就成了进程 (进行中的程序)

Windows

UNIX

以下的程序运行环境均为windows的 wsl 子系统环境下

fork

复制当前进程的“运行态状态机”,生成一个几乎完全相同的子进程,并让父子进程从同一条指令继续执行。(PID,PPID等不会复制)

调用 fork() 后当前进程会被分成 两个进程:

  • 原来的进程:父进程
  • 新创建的进程:子进程

并且父子进程从 fork() 这一行之后继续执行

fork 的返回值(区分父子进程的唯一方式)

pid_t pid = fork();
返回值 含义
< 0 创建失败
0 当前是子进程
> 0 当前是父进程(值为子进程 PID)

fork初尝试

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

int main() {
    int x = 100;
    printf("Before fork: x = %d\\n", x);
    
    pid_t pid = fork();
    if (pid < 0) {
        // fork 失败
        perror("fork failed");
        return 1;
    } else if (pid == 0) {
        // 子进程
        x += 1;
        printf("Child process:  x = %d, pid = %d, ppid = %d\\n",
               x, getpid(), getppid());
    } else {
        // 父进程
        x += 10;
        printf("Parent process: x = %d, pid = %d, child pid = %d\\n",
               x, getpid(), pid);
    }
    printf("After fork: pid = %d, x = %d\\n", getpid(), x);
    return 0;
}

运行结果:

Before fork: x = 100
Parent process: x = 110, pid = 60903, child pid = 60904
After fork: pid = 60903, x = 110
Child process:  x = 101, pid = 60904, ppid = 60903
After fork: pid = 60904, x = 101

先看fork的基本机制,我们通过fork函数返回的pid来进行了父子的区分:

  • pid == 0 的分支即子进程进入运行的分支。
  • pid > 0 则是父进程进入运行的分支,同时,父进程可以使用这个返回的pid来操作管理子进程。

我们讨论研究 x 可以发现,子进程对其修改以及父进程对其的修改互不影响。fork() 并不是让两个进程共享同一个变量,而是为子进程提供了一份独立的执行环境副本。其中包括了已经定义的局部变量和当前函数栈中的数据。

同样,子进程可以通过getppid来获取自己父进程的pid。

父子进程生命周期和托孤机制

pid_t waitpid(pid_t pid, int *status, int options); 用于非阻塞地检查指定子进程的状态。如果子进程尚未退出,立即返回;如果已退出,则回收其退出状态并返回该子进程的 PID。

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

int main() {
    pid_t child_pid = fork();
    if (child_pid == 0) {
        // 子进程
        printf("Child: pid=%d\\n", getpid());
        sleep(2);
        printf("Child exits\\n");
    } else {
        // 父进程
        printf("Parent: pid=%d, waiting for child %d\\n",
               getpid(), child_pid);
        waitpid(child_pid, NULL, 0);  // 只等待这个子进程
        printf("Parent resumes and exits\\n");
    }
    return 0;
}
Parent: pid=70102, waiting for child 70103
Child: pid=70103
Child exits
Parent resumes and exits

我们可以看到,上面已经说明了父子进程之间的关系,父进程可以管理子进程,还可以使用waitpid来等待子进程结束获取子进程的返回状态码。

那如果在子进程还没结束的时候,父进程结束了,子进程的ppid又该如何处理呢?

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

int main() {
    pid_t pid = fork();

    if (pid == 0) {
        printf("Child: pid=%d, ppid=%d\\n", getpid(), getppid());
        sleep(2);
        printf("Child: pid=%d, ppid=%d\\n", getpid(), getppid());
        printf("Child exits\\n");
    } else {
        printf("Parent: pid = %d\\n", getpid());
        sleep(1);
        printf("Parent exits\\n");
        return 0;
    }
}

这里我们构造了一种情景:子进程需要运行两秒钟,父进程在第一秒结束的时候就退出了。我们检查子进程在父进程退出前后的ppid变化。

Parent: pid = 71318
Child: pid=71319, ppid=71318
Parent exits
Child: pid=71319, ppid=377
Child exits

我们可以发现在父进程退出后,子进程的ppid变为了377,我们可以检查377是谁:

ps -p 377 -o pid,ppid,cmd
    PID    PPID CMD
    377     376 /init

init process,也称为 PID 1system initializer .但在此处PID不为1是因为我的运行环境是windows的wsl子系统。

init 一定存在,但 PID=1 只在“完整 Linux 启动模型”中成立;在 WSL 中,/init 是由宿主环境注入的特殊进程,PID 不固定。

从矛盾深入理解 fork 程序

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

int main() {
    for (int i = 0; i < 2; i++){
        int pid = fork();
        printf("hello\\n");
    }
    return 0;
}

编译运行后可以得到:

hello
hello
hello
hello
hello
hello

很好理解

线程1:创建线程2 → 输出hello → 创建线程3 → 输出hello

线程2:输出 hello → 创建线程4 → 输出hello

线程3:输出 hello

线程4:输出 hello

但是如果我们换一种角度去查看输出结果

$ ./fork4 | wc -l
8
$ ./fork4 | cat
hello
hello
hello
hello
hello
hello
hello
hello

如果使用通道符查看结果,会发现和我们之前的结果矛盾,甚至分析上来看依旧不对。

一切的原因在于printf的缓冲区问题:

在管道符和输出到终端,printf有不同的逻辑

  • 直接运行(输出到终端)stdout → 行缓冲(line-buffered)
  • 通过管道符(输出到管道/文件)stdout → 全缓冲(fully-buffered)

在管道符 | 处理的时候,fork时并没有将 hello 从缓冲区输出,因此fork将缓冲区也复制了一份。

也就是上述线程3和线程4的输出应该是 hello\\nhello\\n

int main() {
    for (int i = 0; i < 2; i++){
        int pid = fork();
        printf("hello\\n");
        fflush(stdout);
    }
    return 0;
}

按照以上修改的程序,在printf后强制刷新缓冲区,两者的输出模式就一致了。

execve

是一个状态机的reset,会直接重置状态机的所有状态。在不改变 PID 的前提下,让一个进程变成另一个程序

原型:

int execve(const char *filename,
           char *const argv[],
           char *const envp[]);

含义:

  • filename:要执行的程序路径
  • argv:命令行参数
  • envp:环境变量

简单使用

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

int main() {
    char *argv[] = {
        "bash",
        "-c",
        "env",
        NULL
    };

    char *envp[] = {
        "MYVAR=hello_execve",
        "PATH=/bin:/usr/bin",
        "LANG=C",
        NULL
    };

    printf("Before execve\\n");
    execve("/bin/bash", argv, envp);

    /* 只有 execve 失败才会执行到这里 */
    printf("After execve\\n");
    perror("execve failed");
    return 1;
}

输出结果:

Before execve
MYVAR=hello_execve
PWD=/mnt/e/code/cpp-code/OS
LANG=C
SHLVL=0
PATH=/bin:/usr/bin
_=/bin/env

通过strace检查execve的使用

strace ls 2>&1 | head -n 4
strace echo hello 2>&1 | head -n 4

输出结果:

$ strace ls 2>&1 | head -n 4
execve("/usr/bin/ls", ["ls"], 0x7fff2ebd0570 /* 36 vars */) = 0
brk(NULL)                               = 0x565044547000
mmap(NULL, 8192, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_ANONYMOUS, -1, 0) = 0x7f5c4ffee000
access("/etc/ld.so.preload", R_OK)      = -1 ENOENT (No such file or directory)

$ strace echo hello 2>&1 | head -n 4
execve("/usr/bin/echo", ["echo", "hello"], 0x7fff813594a8 /* 36 vars */) = 0
brk(NULL)                               = 0x55b2fe63f000
mmap(NULL, 8192, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_ANONYMOUS, -1, 0) = 0x7f99ea674000
access("/etc/ld.so.preload", R_OK)      = -1 ENOENT (No such file or directory)

argv[0] 为什么是程序名

argv[0] 之所以通常填写为程序名,是因为这是 Unix 约定的用户态接口规范:内核在 execve 时只依据路径加载可执行文件,而新程序启动后需要通过 argv[0] 知道“自己被当作什么程序来调用”,以便用于错误提示、帮助信息,甚至根据名称决定运行模式;因此这不是内核强制要求,而是被绝大多数程序依赖的约定。

_exit

退出程序

拓展小知识:Zygote 进程是什么?

在 Android 系统中,Zygote 进程是一个非常特殊的系统进程,它由 init 在系统启动早期创建,用来统一孵化(fork)所有应用进程和部分核心系统进程。

Zygote 在启动时会提前加载大量 Android Framework 的核心类、资源和本地库,这些内容在后续创建应用进程时可以通过 写时复制(Copy-On-Write) 的方式被子进程共享。这样一来,应用进程的创建不需要重新加载公共代码,不仅显著提升了启动速度,也有效降低了内存占用。

当系统需要启动一个应用时,Android 并不会采用传统的 fork + exec 模式,而是直接让 Zygote fork 出一个新进程,并在该进程中完成应用运行环境的初始化。这种设计可以看作是对 Linux 进程模型的一种工程化优化,使 Android 能够在资源受限的移动设备上高效地管理大量应用进程。

可以将 Zygote 理解为:一个已经“准备好运行环境”的进程模板,专门负责高效复制应用进程。