进程:程序活起来了
进程:程序活起来了
一张乐谱只是纸上的符号,但当乐队演奏时,它就活了。程序是存在磁盘上的静态文件,进程是这份乐谱被演奏时的动态过程——有声音、有节奏、有记忆、随时间推进。
你的桌面上同时运行着十几个程序,但磁盘上可能只有一份 Chrome 的可执行文件。打开三个 Chrome 窗口,就有三个 Chrome 进程——同一份乐谱,三支乐队同时演奏,彼此独立,互不干扰。
Level 1:建立直觉
进程是什么
进程(Process)= 程序的一次运行实例。它包含:
进程 = 代码 + 数据 + 执行状态
执行状态包括:
├── CPU 寄存器的当前值(PC、SP、通用寄存器……)
├── 内存映射(虚拟地址空间:代码段、堆、栈)
├── 打开的文件(文件描述符表)
├── 网络连接(socket)
├── 进程 ID(PID)和父进程 ID(PPID)
└── 信号处理器、环境变量、当前目录……
一个进程可以理解为一个"自给自足的计算宇宙":它有自己的地址空间、自己的文件表、自己的运行状态,外界不经许可无法侵入。
进程的生命周期
创建(fork/exec)
↓
就绪(Runnable)← 等待 CPU 时间片
↓ ↑
运行(Running) (时间片用完,被调度出去)
↓
等待(Waiting)← I/O、睡眠、等待子进程
↓ ↑
(I/O完成,重新就绪)
↓
终止(Zombie/Dead)
僵尸进程(Zombie):进程已退出,但父进程还没有调用 wait() 来"收尸"。进程的 PCB(进程控制块)仍然保留在内核里,占用 PID。如果父进程忘记回收,僵尸进程会越积越多,直到 PID 资源耗尽。
fork():分裂复制
Unix 创建进程的方式非常有意思:复制自己。
#include <unistd.h>
#include <stdio.h>
int main() {
printf("我是父进程,PID=%d\n", getpid());
pid_t pid = fork(); // 从这里开始,出现两个完全相同的进程
if (pid == 0) {
// 这段代码在子进程里执行
printf("我是子进程,PID=%d,父=%d\n", getpid(), getppid());
} else if (pid > 0) {
// 这段代码在父进程里执行(pid是子进程的PID)
printf("父进程看到:子进程PID=%d\n", pid);
}
return 0;
}
fork() 返回两次:在父进程里返回子进程的 PID(> 0),在子进程里返回 0。
fork 之后的子进程是父进程的完整副本——内存、文件描述符、信号处理器——但它们共享同一套代码,跑在独立的地址空间。
写时复制(COW)的魔法:fork 不会立刻复制几 GB 的内存。内存页面被标记为只读、共享。当任一方尝试写入时,才真正复制那一页。所以 fork() 几乎是瞬间完成的,哪怕父进程占用了几 GB 内存。
exec():变身
fork 之后通常会调用 exec(),让子进程变成另一个程序:
// 典型 shell 执行命令的方式
pid_t pid = fork();
if (pid == 0) {
// 子进程:把自己变成 ls
execvp("ls", (char*[]{"ls", "-la", NULL}));
// exec 成功后这行永远不会执行
// exec 失败时才会执行(通常是程序找不到)
perror("exec failed");
exit(1);
}
// 父进程(shell)等待子进程完成
wait(NULL);
exec() 用一个新程序的代码/数据替换当前进程的内存,但 PID 和打开的文件描述符保持不变。这个"fork + exec"组合是 Unix 创建进程的经典模式。
Level 2:原理剖析
进程控制块(PCB)
内核用 **PCB(Process Control Block)**描述每个进程。Linux 里叫 task_struct,是个巨大的结构体(Linux 6.x 中超过 800 个字段):
// Linux task_struct 关键字段(简化版)
struct task_struct {
// 标识
pid_t pid; // 进程 ID
pid_t tgid; // 线程组 ID(多线程时=主线程 PID)
char comm[16]; // 进程名
// 状态
volatile long state; // TASK_RUNNING, TASK_INTERRUPTIBLE...
int exit_code; // 退出码
// 内存
struct mm_struct *mm; // 内存映射描述符
// 文件
struct files_struct *files; // 打开的文件描述符
// 调度
int prio; // 优先级
struct sched_entity se; // 调度实体(CFS 用)
// 父子关系
struct task_struct *parent;
struct list_head children;
// 信号
struct signal_struct *signal;
sigset_t blocked; // 被屏蔽的信号
// 时间统计
u64 utime, stime; // 用户态/内核态 CPU 时间
// ... 还有几百个字段
};
上下文切换
当操作系统从进程 A 切换到进程 B 时,必须保存 A 的"现场",恢复 B 的"现场"——这叫上下文切换(Context Switch)。
上下文切换代价:
1. 保存 A 的寄存器到 A 的 PCB(几十个寄存器)
2. 切换页表(从 A 的地址空间到 B 的地址空间)
3. 刷新 TLB(地址翻译缓存,切换后 A 的 TLB 条目无效)
4. 恢复 B 的寄存器
典型代价:1-10 微秒(包括 TLB 冷却)
每秒切换次数:数百到数千次
TLB 刷新是上下文切换的主要开销。现代 CPU 引入了 PCID(Process Context Identifier)——给每个进程的 TLB 条目打标签,切换时不必清空 TLB,可以保留最近进程的翻译缓存。Linux 4.14+ 默认启用 PCID。
信号:进程间的异步通知
信号是 Unix 进程间通信的基础形式:
常用信号:
SIGTERM (15) → 请求进程优雅退出(kill命令默认)
SIGKILL (9) → 强制杀死(不可捕获、不可忽略)
SIGINT (2) → Ctrl+C 产生
SIGHUP (1) → 终端关闭,进程"挂断"
SIGSEGV (11) → 段错误(非法内存访问)
SIGCHLD (17) → 子进程状态改变
SIGALRM (14) → 定时器到期
SIGUSR1/2 → 用户自定义信号
#include <signal.h>
#include <stdio.h>
void sigint_handler(int signum) {
printf("\n收到 SIGINT,优雅退出中...\n");
// 清理资源
exit(0);
}
int main() {
// 注册信号处理器
signal(SIGINT, sigint_handler);
while (1) {
printf("工作中...\n");
sleep(1);
}
return 0;
}
// 按 Ctrl+C 会触发 sigint_handler,而不是直接杀死进程
SIGKILL 是特殊的——进程无法捕获或忽略它,内核强制终止。所以 kill -9 永远有效(除非内核 bug)。
进程间通信(IPC)
进程隔离是好事,但进程之间也需要协作。Unix 提供多种 IPC 机制:
IPC 机制 特点 适用场景
─────────────────────────────────────────────────────
管道(pipe) 单向字节流,父子进程间 shell 管道(ls | grep)
命名管道(fifo)有名字的管道,任意进程 进程间简单通信
消息队列 结构化消息,内核维护队列 进程间异步消息
共享内存 最快,直接映射同一物理页 高吞吐量数据共享
信号量 计数器,同步访问 互斥和同步控制
socket 网络协议,可跨机器 分布式系统、同机通信
D-Bus Linux 桌面,服务发现 桌面应用间通信
// 管道示例:父子进程通信
int pipefd[2];
pipe(pipefd); // 创建管道,pipefd[0]=读端,pipefd[1]=写端
pid_t pid = fork();
if (pid == 0) {
// 子进程:写数据到管道
close(pipefd[0]); // 关闭读端
write(pipefd[1], "hello from child", 16);
close(pipefd[1]);
} else {
// 父进程:从管道读数据
close(pipefd[1]); // 关闭写端
char buf[64];
read(pipefd[0], buf, sizeof(buf));
printf("父进程收到: %s\n", buf);
close(pipefd[0]);
wait(NULL);
}
共享内存是最快的 IPC——两个进程将同一块物理内存映射到各自的虚拟地址空间,通信就是读写内存,没有内核拷贝开销。Chrome 浏览器的多进程架构大量使用共享内存在渲染进程和浏览器进程之间传输图像数据。
/proc 文件系统:进程的透明窗口
Linux 的 /proc 让你实时查看任何进程的状态:
# 查看进程 1234 的详细信息
ls /proc/1234/
# cmdline -- 启动命令行
# exe -- 可执行文件路径(符号链接)
# fd/ -- 所有打开的文件描述符
# maps -- 内存映射(虚拟地址空间布局)
# status -- 进程状态、内存使用等
# net/ -- 网络连接
cat /proc/1234/status
# Name: nginx
# State: S (sleeping)
# Pid: 1234
# VmRSS: 12345 kB ← 实际占用物理内存
# VmSize: 98765 kB ← 虚拟内存大小
# Threads: 4
cat /proc/1234/maps
# 00400000-00401000 r-xp 00000000 08:01 123 /usr/sbin/nginx ← 代码段
# 00600000-00601000 rw-p 00000000 08:01 123 /usr/sbin/nginx ← 数据段
# 7f8a12345000-7f8a1256789 rw-p 00000000 00:00 0 [heap]
# 7ffe12345000-7ffe12367000 rw-p 00000000 00:00 0 [stack]
Level 3 · 规范怎么定义的(资深)
进程模型的标准定义
进程的行为在 POSIX 标准(IEEE Std 1003.1)中有详尽定义。POSIX 定义了进程的状态(运行、就绪、睡眠、僵尸、停止)、进程标识符(PID 类型为 pid_t,至少 16 位)、进程组和会话的关系、以及进程生命周期的关键系统调用语义。
fork() 的 POSIX 规范要求:子进程获得父进程地址空间的完整副本(语义上的副本,实现上可以用写时复制优化)、继承文件描述符表(共享底层文件表项)、继承信号处理设置(但挂起的信号不继承)。fork() 在父进程中返回子进程的 PID,在子进程中返回 0——这个"一次调用两次返回"的设计是 Unix 哲学的标志。
Linux 特有的 clone() 系统调用比 fork() 更通用,它通过标志位(CLONE_VM、CLONE_FS、CLONE_FILES、CLONE_THREAD 等)精细控制父子进程共享哪些资源。当设置了 CLONE_VM | CLONE_FS | CLONE_FILES | CLONE_THREAD 时,clone() 创建的就是一个线程而非独立进程。这意味着在 Linux 内核中,进程和线程在底层使用相同的数据结构(task_struct),区别只在于共享程度——这就是"Linux 没有真正的线程,只有共享地址空间的进程"这一说法的来源。
Level 4 · 边界与陷阱(所有人)
陷阱 1:僵尸进程(Zombie)会耗尽 PID
子进程终止后,其退出状态会保留在内核的进程表中,直到父进程调用 wait()/waitpid() 回收。在此期间,子进程处于"僵尸"状态(Z state)——不占用内存和 CPU,但占用一个 PID 和进程表条目。如果父进程从不调用 wait()(常见于 daemon 程序的 bug),僵尸进程会持续累积。Linux 默认的 PID 上限是 32768(可通过 /proc/sys/kernel/pid_max 调整到 4194304),累积够多的僵尸进程会导致系统无法创建新进程。修复方式是在父进程中正确处理 SIGCHLD 信号或使用 "double fork" 技巧让 init 进程接管孤儿进程的回收。
陷阱 2:fork() 在多线程程序中是危险的
POSIX 规定 fork() 只复制调用线程——子进程中只有调用 fork() 的那个线程存在,其他线程全部消失。但如果那些消失的线程正持有锁(如 malloc() 内部的堆锁),子进程继承了这些锁的"已锁定"状态,却没有线程去释放它们。结果是子进程中的第一次 malloc() 就可能永久死锁。这就是为什么在多线程程序中,fork() 后应该立即调用 exec() 替换进程映像(exec() 会重置所有锁状态),而不是在子进程中做任何复杂操作。Python 的 multiprocessing 模块在 macOS 上默认使用 spawn(而非 fork)正是为了避免这个问题。
陷阱 3:PID 重用导致信号发给错误的进程
PID 在进程终止并被回收后可以被操作系统重新分配给新进程。如果你保存了一个 PID 并在稍后用 kill(pid, signal) 发送信号,但原进程已经终止、PID 被新进程复用,你的信号会发给一个完全无关的进程。在高进程创建/销毁频率的系统(如容器编排平台)上,PID 重用的概率不低。Linux 5.3 引入了 pidfd(进程文件描述符),通过 pidfd_open() 获取一个指向特定进程的文件描述符,即使 PID 被重用也不会发错信号——这是解决 PID 竞态的正确方式。