程序:是状态机的静态描述,描述了所有可能的程序状态
进程:程序 (动态) 运行起来,就成了进程 (进行中的程序)
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 1 或 system 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 理解为:一个已经“准备好运行环境”的进程模板,专门负责高效复制应用进程。