从内核视角看进程从创建到销毁的完整旅程,以及它所占用的一切系统资源
进程是操作系统进行资源分配和调度的基本单位。它是程序的一次执行实例,拥有独立的地址空间、系统资源和执行上下文。
内核为每个进程维护的数据结构,记录 PID、状态、寄存器、内存信息、打开文件、信号等一切上下文。
每个进程拥有独立的虚拟地址空间(4GB on 32-bit),通过页表映射到物理内存,进程间互不可见。
CPU 保存当前进程寄存器状态到 PCB,加载下一个进程的寄存器状态。开销约数微秒到数十微秒。
每个进程在内核态有独立的栈(通常 8KB),用于系统调用和内核函数执行。
从用户态发起 fork() 到内核完成进程创建的每一个步骤
pid = fork() 系统调用,触发 int 0x80 或 syscall 指令,CPU 从用户态切换到内核态。struct task_struct *p = alloc_task_struct() 分配 PCB(Linux 中即 task_struct),从 kmem_cache 的 slab 分配器获取。mm_struct(内存描述符)深入理解 fork 的工作原理、资源继承规则和常见模式
// 最经典的 fork 用法
#include <unistd.h>
#include <stdio.h>
int main() {
pid_t pid = fork(); // 一分为二!
if (pid < 0) {
// fork 失败(资源不足、进程数达上限)
perror("fork failed");
} else if (pid == 0) {
// 👶 子进程 —— pid == 0
printf("我是子进程, PID=%d, PPID=%d\n", getpid(), getppid());
} else {
// 👨 父进程 —— pid > 0
printf("我是父进程, PID=%d, 子进程PID=%d\n", getpid(), pid);
wait(NULL); // 等待子进程结束
}
return 0;
}
| 资源类型 | 继承方式 | 说明 |
|---|---|---|
| 代码段(Text) | 共享 | 只读,父子进程指向同一物理页 |
| 数据段(Data + BSS) | COW | fork 时共享,写入时才复制(写时复制) |
| 堆(Heap) | COW | malloc 的内存,COW 机制,写入时复制 |
| 栈(Stack) | COW | 每个进程会获得独立的栈空间视图 |
| 文件描述符表 | 共享 | 表项复制,但指向同一个打开文件表项(偏移量共享!) |
| 文件描述符的 close-on-exec | 继承 | 标志位被复制 |
| 当前工作目录 | 继承 | 子进程继承父进程的 cwd |
| 信号处理方式 | 继承 | 子进程继承父进程的信号 disposition |
| 环境变量 | 继承 | 完整的 environ 数组被复制 |
| 真实/有效 UID/GID | 继承 | 用户身份和权限信息 |
| 资源限制(RLIMIT) | 继承 | 文件大小限制、内存限制等 |
| 进程间锁(flock) | 继承 | 文件锁也由子进程继承 |
| pending 信号 | 不继承 | fork 时 pending 信号集被清空 |
| 定时器(alarm/setitimer) | 不继承 | 子进程不会继承父进程的定时器 |
| 内存锁(mlock) | 不继承 | mlock 锁定的页面不继承 |
fork 和 vfork 在 Linux 中都是基于 clone() 实现的库函数。clone() 通过 flags 参数精确控制共享哪些资源:
// clone 的关键 flags
CLONE_VM // 共享地址空间(线程)
CLONE_FS // 共享文件系统信息
CLONE_FILES // 共享文件描述符表
CLONE_SIGHAND // 共享信号处理
CLONE_THREAD // 放入同一个线程组
// fork = clone(SIGCHLD, 0) — 全部独立
// vfork = clone(CLONE_VM|CLONE_VFORK|SIGCHLD, 0)
// 线程 = clone(CLONE_VM|CLONE_FS|CLONE_FILES|CLONE_SIGHAND|CLONE_THREAD, ...)
进程在运行过程中,会在不同状态之间切换
进程正在被创建:内核已分配 PCB,正在复制地址空间、设置资源。此时进程还不能被调度运行。
进程已准备就绪,等待 CPU 时间片。被放在调度器的运行队列(runqueue)中。可能因时间片用完从运行态回到此状态。
进程正在 CPU 上执行。单核上同一时刻只有一个 RUNNING 进程;多核上可以同时有多个。时间片耗尽或高优先级进程抢占时退出。
进程等待某事件完成(I/O 操作、锁获取、信号等),被移出运行队列。阻塞原因消除后被内核唤醒回到就绪态。
进程已终止,但其 PCB 仍保留在内核中(存储退出码、资源使用统计等信息),等待父进程调用 wait() 回收。如果父进程不回收,就变成僵尸进程泄漏。
在 Linux 内核源码中(include/linux/sched.h),进程状态比教科书模型更细致:
/* 进程状态定义 */
#define TASK_RUNNING 0 // 正在运行 或 在运行队列中等待
#define TASK_INTERRUPTIBLE 1 // 可中断睡眠(可被信号唤醒,如 wait、select)
#define TASK_UNINTERRUPTIBLE 2 // 不可中断睡眠(不被信号唤醒,如磁盘 I/O)
#define TASK_STOPPED 4 // 暂停(收到 SIGSTOP / 被 debugger 挂起)
#define TASK_TRACED 8 // 被跟踪(ptrace 调试中)
// 退出状态(在 exit_notify 中设置)
#define EXIT_ZOMBIE 16 // 僵尸进程
#define EXIT_DEAD 32 // 终止(等待父进程回收后最终状态)
内核为每个进程维护的核心数据结构,约 400+ 个字段
cat /proc/[pid]/status 可以看到 PCB 中的大部分关键字段。/proc/[pid]/stat 提供更详细的统计信息。
每个进程看到的独立地址空间(以 x86-64 Linux 为例)
调整堆顶指针(program break),是 malloc 底层的实现方式之一。现代 glibc 更多使用 mmap。
将文件或匿名内存映射到虚拟地址空间。大型内存分配(> 128KB)使用 mmap 而非 brk,便于归还。
修改内存页的保护属性(读/写/执行),COW 机制就是将页设为只读实现的。
一个进程运行时究竟消耗了操作系统中的哪些资源?
| 资源类别 | 具体内容 | 查看方式 |
|---|---|---|
| 💾 CPU 时间 | 用户态时间 (utime)、内核态时间 (stime)、实际墙钟时间 | /proc/pid/stattime 命令 |
| 🧠 虚拟内存 | 代码段、数据段、堆、栈、内存映射区(mmap)、共享库映射 | /proc/pid/mapspmap 命令 |
| 📄 物理内存 (RSS) | 实际占用的物理页数量,包括共享库(按比例分摊 PSS) | /proc/pid/statmsmem 工具 |
| 📂 打开的文件 | 每个 fd 对应一个 file 结构体(含引用计数、文件偏移量、访问模式) | /proc/pid/fdlsof -p pid |
| 📁 文件系统上下文 | 当前工作目录 (cwd)、根目录 (root)、umask | /proc/pid/cwd/proc/pid/root |
| 📡 信号 | 信号处理函数表、信号屏蔽字、待处理信号队列 | /proc/pid/status |
| 🔓 文件锁 | 使用 fcntl/flock 加的文件锁 | /proc/locks |
| 🔗 网络资源 | 打开的 socket(TCP/UDP/Unix)、监听端口、连接状态 | /proc/pid/net/tcpnetstat/ss |
| 🧵 线程 | 属于同一进程的所有线程(共享地址空间的轻量级进程) | /proc/pid/taskps -Lp pid |
| 📦 IPC 资源 | System V 信号量、消息队列、共享内存段 | ipcs 命令 |
| 🏷️ 内核资源 | PCB (task_struct)、内核栈 (8KB)、页表、slab 缓存 | /proc/pid/smaps_rollup |
| 🔑 凭证 | 真实 UID/GID、有效 UID/GID、保存的 UID/GID、capabilities | /proc/pid/status |
| 🖼️ 命名空间 | Mount、UTS、IPC、PID、Network、User 命名空间 | ls /proc/pid/ns |
| 📝 cgroup | 进程归属的控制组(CPU、内存、IO 限制) | /proc/pid/cgroup |
# 假设进程 PID 为 1234
# 1. 基本信息
cat /proc/1234/status
# 2. 内存映射
cat /proc/1234/maps
pmap -x 1234
# 3. 打开的文件
ls -la /proc/1234/fd
lsof -p 1234
# 4. 网络连接
ss -tnp | grep 1234
# 5. 线程
ls /proc/1234/task
# 6. 文件锁
cat /proc/locks | grep 1234
# 7. 环境变量和命令行
cat /proc/1234/environ | tr '\0' '\n'
cat /proc/1234/cmdline | tr '\0' ' '
# 8. 资源限制
cat /proc/1234/limits
所有进程构成树状结构,init/systemd 是一切的祖先
struct task_struct {
// 父子关系
struct task_struct *parent; // 指向父进程
struct list_head children; // 所有子进程的链表
struct list_head sibling; // 兄弟进程链表
struct task_struct *real_parent; // 创建自己的进程
struct task_struct *group_leader; // 线程组组长
};
// 遍历所有子进程的宏
list_for_each_entry(child, ¤t->children, sibling) {
printk("child pid: %d\n", child->pid);
}
父进程先于子进程退出时,子进程成为孤儿。内核会将其托付给 init/systemd (PID 1)作为新的父进程,由 init 负责回收。
子进程退出但父进程未调用 wait() 回收,子进程的 PCB 不被释放。PCB 中保留了退出状态和资源使用统计。
通过 fork() → 父进程退出 → 子进程被 init 收养 的方式,让进程脱离终端控制,在后台独立运行。步骤:
① fork + 父退出
② setsid() 创建新会话
③ 再次 fork 防止获取终端
④ chdir("/") 释放挂载点
⑤ 关闭继承的 fd
fork 之后最常配合使用的两个系统调用
exec 系列调用(execve 是真正的系统调用)用一个新程序替换当前进程的地址空间,但保持 PID 不变:
wait() / waitpid() / wait3() / wait4() — 父进程等待子进程终止,并回收子进程资源:
// wait 的三种用途
pid_t waitpid(pid_t pid, int *status, int options);
// 参数说明:
// pid > 0 等待指定 PID 的子进程
// pid == -1 等待任意子进程(同 wait())
// pid == 0 等待同进程组的任意子进程
// status 输出:子进程退出状态(可用 WIFEXITED 等宏检查)
// options WNOHANG(非阻塞)/ WUNTRACED(也等被暂停的子进程)
// 使用示例
int status;
waitpid(child_pid, &status, 0); // 阻塞等待
if (WIFEXITED(status)) { // 正常退出
printf("exit code: %d\n", WEXITSTATUS(status));
} else if (WIFSIGNALED(status)) { // 被信号杀死
printf("killed by signal: %d\n", WTERMSIG(status));
}
wait() 在内核中执行的关键操作:
① 如果子进程还在运行,父进程进入阻塞(TASK_INTERRUPTIBLE)
② 子进程退出时触发 do_exit() → exit_notify()
③ exit_notify() 向父进程发送 SIGCHLD 信号
④ 父进程被唤醒,waitpid 返回子进程 PID
⑤ 内核释放子进程的 PCB (task_struct)和剩余资源
⑥ 子进程从僵尸态变为完全终止
// 在知道子进程的地方调用
waitpid(pid, &status, 0);
signal(SIGCHLD, SIG_IGN);
// 告知内核:不关心子进程状态
// 子进程退出后自动回收,不产生僵尸
// 注册 SIGCHLD handler
struct sigaction sa;
sa.sa_handler = handler;
sa.sa_flags |= SA_NOCLDSTOP;
sigaction(SIGCHLD, &sa, NULL);
void handler(int sig) {
while (waitpid(-1, NULL, WNOHANG) > 0);
}