一、引言:进程——操作系统最核心的抽象
在第 1 章中,我们俯瞰了 Linux 内核的七大子系统;在第 2 章中,我们穿越了用户态与内核态之间的桥梁。现在,走进内核最基础、最核心的子系统——进程管理。
进程是操作系统最伟大的抽象:它让每个程序都以为自己独占了整台计算机的 CPU 和内存。当你同时在浏览器中阅读本文、在终端编译代码、在音乐播放器中听歌时,正是进程管理在幕后让这一切成为可能。理解进程管理,是理解 Linux 内核一切机制的基础——调度器调度的是进程,内存管理服务于进程,文件系统以进程为单位控制访问权限,信号和 IPC 在进程之间传递信息。
本章将带你深入进程从创建到消亡的完整生命周期,剖析 task_struct 这一内核最核心的数据结构,揭示 fork()-exec() 背后写时复制的精妙设计,并理解僵尸进程、孤儿进程、进程组与会话等关键概念。
二、进程与线程的 Linux 视角
1.1 传统操作系统教科书 vs. Linux
在传统操作系统教科书中,进程和线程是两个截然不同的概念:
- 进程(Process):拥有独立地址空间、文件描述符表、信号处理等资源的执行实体
- 线程(Thread):同一进程内的多个执行流,共享地址空间和资源,但拥有独立的栈和寄存器状态
Linux 采用了截然不同的设计哲学——进程和线程统一用 task_struct 表示。在 Linux 内核看来,不存在”线程”这个概念,只有”轻量级进程”(Lightweight Process, LWP)。线程不过是一组共享了某些资源的进程而已。
这种统一设计被称为”一元化模型”(Unified Process/Thread Model)。它的好处是:调度器无需区分进程和线程,内核代码更简洁,且天然支持各种介于进程和线程之间的共享粒度。
1.2 轻量级进程(LWP)
当使用 pthread_create() 创建一个 POSIX 线程时,内核实际上创建了一个新的 task_struct,但通过 clone() 系统调用的 flags 参数指定了与父进程共享哪些资源:
| 共享的资源 | 独立的资源 |
|---|---|
虚拟地址空间(mm_struct) | 内核栈 |
文件描述符表(files_struct) | 用户栈 |
信号处理(sighand_struct) | 寄存器状态(thread_info) |
| 进程 ID(tgid 相同) | 线程 ID(pid 不同) |
从用户态视角看,同一进程内的多个线程拥有相同的”进程 ID”(getpid() 返回值),但从内核视角看,每个线程都有自己唯一的 pid。用户态的”进程 ID”实际上是内核的 tgid(Thread Group ID)。
// include/linux/sched.h 中的关键字段struct task_struct { pid_t pid; // 进程/线程的唯一标识 pid_t tgid; // 线程组 ID(用户态的"进程 ID") // ...};
// getpid() 系统调用实际返回的是 tgidSYSCALL_DEFINE0(getpid){ return task_tgid_vnr(current); // 返回 tgid,而非 pid}1.3 进程树全景
Linux 系统启动后,所有进程构成一棵以 init 进程(PID 1)为根的树形结构:
每个进程都有且只有一个父进程(ppid),子进程退出后必须由父进程”收尸”(wait()),否则就会变成僵尸进程——我们稍后会详细讨论。
三、task_struct:进程的”身份证”
task_struct 是 Linux 内核中最核心的数据结构,定义在 include/linux/sched.h 中。在 6.x 内核中,它包含了超过 300 个字段。不可能逐一分析,但可以按功能分类,抓住最关键的字段。
2.1 进程标识
struct task_struct { pid_t pid; // 全局唯一的进程/线程 ID pid_t tgid; // 线程组 ID = 用户态的"进程 ID" struct task_struct *group_leader; // 线程组首领(主线程) struct list_head thread_group; // 同一线程组的链表
// 父子关系 struct task_struct __rcu *real_parent; // 真实父进程 struct task_struct __rcu *parent; // "养父"(被调试器时为调试器) struct list_head children; // 子进程链表 struct list_head sibling; // 兄弟进程链表 // ...};关键区分:
pid:内核视角的唯一标识,每个task_struct都不同tgid:用户态视角的”进程 ID”,同一线程组内所有线程共享real_parentvsparent:通常指向同一个进程,但当进程被ptrace()跟踪时,parent指向调试器
2.2 进程状态
struct task_struct { volatile long state; // 进程状态,-1 不可运行,0 可运行,>0 停止 // ...};状态值的定义在 include/linux/sched.h 中,将在下一节详细分析。
2.3 优先级与调度信息
struct task_struct { int prio; // 动态优先级(调度器使用) int static_prio; // 静态优先级(nice 值转换而来) int normal_prio; // 基于静态优先级和调度策略计算的"正常"优先级 unsigned int rt_priority; // 实时优先级(1~99)
const struct sched_class *sched_class; // 调度类 struct sched_entity se; // CFS 调度实体 struct sched_rt_entity rt; // 实时调度实体 unsigned int policy; // 调度策略(SCHED_NORMAL/SCHED_FIFO/SCHED_RR) // ...};优先级的数值关系:prio = normal_prio - bonus,其中 bonus 由调度器根据进程的睡眠时间动态计算。将在第 4 章深入分析调度细节。
2.4 内存描述符
struct task_struct { struct mm_struct *mm; // 用户态地址空间(内核线程为 NULL) struct mm_struct *active_mm; // 内核线程借用前一个进程的 mm // ...};mm:指向进程的完整虚拟地址空间描述符,包含页表、VMA 链表等active_mm:内核线程没有自己的mm,但需要访问用户空间页表时的”借用”对象
2.5 文件描述符表
struct task_struct { struct files_struct *files; // 打开文件表 // ...};files_struct 包含一个文件描述符数组 fd_array,默认大小为 64,超过时动态扩展。每个文件描述符指向一个 struct file 对象。
2.6 信号处理
struct task_struct { struct signal_struct *signal; // 进程共享的信号信息 struct sighand_struct *sighand; // 信号处理函数表 sigset_t blocked; // 被阻塞的信号集 struct sigpending pending; // 私有挂起信号队列 // ...};2.7 命名空间
struct task_struct { struct nsproxy *nsproxy; // 命名空间代理 // ...};
struct nsproxy { struct uts_namespace *uts_ns; // 主机名、版本 struct ipc_namespace *ipc_ns; // System V IPC、POSIX MQ struct mnt_namespace *mnt_ns; // 挂载点 struct pid_namespace *pid_ns_for_children; // PID 命名空间 struct net *net_ns; // 网络命名空间 struct cgroup_namespace *cgroup_ns; // Cgroup 命名空间 // ...};命名空间是容器技术的基石,将在第 15 章深入讨论。
四、进程状态机:六种状态的流转
Linux 进程有六种核心状态,它们之间的转换构成了一个有限状态机:
3.1 TASK_RUNNING(可运行)
进程正在 CPU 上执行,或在运行队列中等待被调度。注意:TASK_RUNNING 包含两种情况——“正在运行”和”就绪等待”,内核通过 current 宏和运行队列来区分。
// 判断进程是否在运行队列上static inline int task_on_rq_queued(struct task_struct *p){ return p->on_rq == TASK_ON_RQ_QUEUED;}3.2 TASK_INTERRUPTIBLE(可中断睡眠)
进程正在等待某个事件(如 I/O 完成、信号量可用),此时可以被信号唤醒。这是最常见的睡眠状态,大多数阻塞操作(如 read() 等待网络数据)都会进入此状态。
当你用 ps aux 看到 S 状态(Sleep)的进程,就是 TASK_INTERRUPTIBLE。
3.3 TASK_UNINTERRUPTIBLE(不可中断睡眠)
进程正在等待一个不可被信号中断的事件,通常是直接等待硬件 I/O 完成。此状态的进程不响应信号,连 kill -9 都无法杀死——因为信号无法打断它的等待。
不可中断睡眠的存在是为了保证内核某些关键操作的原子性。如果一个进程正在等待磁盘 I/O 完成时被信号打断,可能导致数据结构处于不一致状态。不过,长时间处于 D 状态(TASK_UNINTERRUPTIBLE)通常意味着硬件故障或内核 bug。
3.4 TASK_STOPPED(停止)
进程被暂停执行,通常由以下原因触发:
- 收到
SIGSTOP、SIGTSTP、SIGTTIN、SIGTTOU信号 - 被调试器通过
ptrace()暂停
收到 SIGCONT 信号后可以恢复到 TASK_RUNNING。
3.5 EXIT_ZOMBIE(僵尸)
进程已经退出,但父进程尚未调用 wait() 获取其退出状态。此时进程的 task_struct 仍然保留在内核中,仅占用少量内存保存退出状态等信息。僵尸进程不占用 CPU 和内存资源,但大量僵尸进程会耗尽 PID 资源。
3.6 EXIT_DEAD(死亡)
父进程调用 wait() 后,僵尸进程的 task_struct 被释放,进入 EXIT_DEAD 状态。这是一个瞬时状态,几乎无法被观察到。
3.7 状态查看对照表
| 内核状态 | ps 显示 | 含义 |
|---|---|---|
TASK_RUNNING | R | 正在运行或就绪 |
TASK_INTERRUPTIBLE | S | 可中断睡眠 |
TASK_UNINTERRUPTIBLE | D | 不可中断睡眠 |
TASK_STOPPED | T | 停止 |
EXIT_ZOMBIE | Z | 僵尸 |
EXIT_DEAD | X | 死亡(瞬时) |
五、进程创建:fork() 的完整旅程
fork() 是 Unix 最经典的系统调用之一——它创建一个与父进程几乎完全相同的子进程。追踪它从用户态到内核态的完整执行路径。
4.1 fork() → copy_process() → dup_task_struct()
第一步:dup_task_struct()
这是 fork 的起点——为子进程分配一个新的 task_struct 和内核栈:
static struct task_struct *dup_task_struct(struct task_struct *orig, int node){ struct task_struct *tsk; // 1. 分配 task_struct + thread_info + 内核栈 tsk = alloc_task_struct_node(node); // 2. 逐字节复制父进程的 task_struct memcpy(tsk, orig, arch_task_struct_size); // 3. 为子进程分配新的内核栈 tsk->stack = alloc_thread_stack_node(tsk, node); // 4. 复制 thread_info setup_thread_stack(tsk, orig); // ... return tsk;}注意:此时子进程是父进程的完整副本——所有字段都相同,包括打开的文件、信号处理、地址空间指针等。后续的 copy_* 函数会根据 clone_flags 决定是共享还是复制这些资源。
第二步:copy_mm()——写时复制的精妙
这是 fork 最核心的步骤。如果 clone_flags 没有设置 CLONE_VM(即不是创建线程),则调用 dup_mm() 复制地址空间:
static int copy_mm(unsigned long clone_flags, struct task_struct *tsk){ struct mm_struct *mm, *oldmm;
oldmm = current->mm; if (clone_flags & CLONE_VM) { // 线程:共享地址空间 mmget(oldmm); mm = oldmm; } else { // 进程:复制地址空间(COW) mm = dup_mm(tsk); } tsk->mm = mm; return 0;}写时复制(Copy-On-Write, COW) 的工作原理:
dup_mm()创建新的mm_struct,复制父进程的 VMA 链表和页表- 但不复制物理页——父子进程的页表项都指向相同的物理页,并标记为只读
- 当任一方尝试写入时,CPU 触发缺页异常(Page Fault)
- 内核的缺页处理程序检测到这是 COW 页,才会复制物理页,修改页表项为可写
// mm/memory.c 中的 COW 处理(简化)static vm_fault_t do_wp_page(struct vm_fault *vmf){ // 检测到写只读页 if (page_count(vmf->page) > 1) { // 该页被多个进程共享 → 复制 vmf->page = alloc_page(); // 分配新物理页 copy_user_page(vmf->page, old_page, ...); } // 更新页表项为可写 pte_mkwrite(pte);}COW 的精妙之处在于:只有真正被修改的页才会被复制。如果子进程 fork 后立即调用 exec() 加载新程序,那么几乎所有页都不需要复制——因为 exec() 会替换整个地址空间。
COW 不仅节省内存,更节省时间。一个拥有 1GB 地址空间的进程 fork 时,如果不用 COW,需要复制所有页表和物理页;使用 COW 后,只需复制页表(几 MB),物理页在写入时才按需复制。
description: ”#### 第三步:copy_thread()——设置子进程的”起跑线”
copy_thread() 设置子进程的寄存器状态,使其被调度时从 ret_from_fork 开始执行:
int copy_thread(struct task_struct *p, const struct kernel_clone_args *args){ struct inactive_task_frame *frame; struct fork_frame *fork_frame; struct pt_regs *childregs;
childregs = task_pt_regs(p); // 子进程的用户态寄存器 = 父进程的寄存器 *childregs = *current_pt_regs(); // 关键:子进程的 fork() 返回值为 0 childregs->ax = 0;
// 设置子进程的内核态入口 frame = &fork_frame->regs; frame->ret_addr = (unsigned long) ret_from_fork; p->thread.sp = (unsigned long) fork_frame; // ... return 0;}最关键的一行是 childregs->ax = 0——这就是为什么 fork() 在子进程中返回 0,而在父进程中返回子进程的 PID。两个进程执行相同的代码,但通过返回值的不同走不同的分支。
4.2 fork 的性能优化
Linux 内核对 fork 做了大量优化:
- COW:避免复制物理页
- 共享页表:对于
MAP_SHARED的 VMA,直接共享页表 - 延迟 FPU 复制:子进程首次使用 FPU 时才复制浮点寄存器状态
- 内核栈复制优化:使用
vmap_stack减少内存碎片
六、clone():Linux 线程的真正创建者
fork() 只是 clone() 的一个特例。在 Linux 中,一切进程/线程的创建最终都通过 clone() 系统调用完成。
5.1 clone() 的 flags 控制
// clone() 的函数原型long clone(unsigned long flags, void *child_stack, int *ptid, int *ctid, unsigned long newtls);flags 参数的每一位控制一种资源的共享行为:
| Flag | 含义 | fork() | pthread_create() |
|---|---|---|---|
CLONE_VM | 共享地址空间 | 否 | 是 |
CLONE_FS | 共享根目录/当前目录/umask | 否 | 是 |
CLONE_FILES | 共享文件描述符表 | 否 | 是 |
CLONE_SIGHAND | 共享信号处理函数表 | 否 | 是 |
CLONE_THREAD | 加入同一线程组 | 否 | 是 |
CLONE_SYSVSEM | 共享 System V 信号量 | 否 | 是 |
CLONE_PARENT | 与父进程共享同一个父进程 | 否 | 否 |
CLONE_NEWPID | 创建新的 PID 命名空间 | 否 | 可选 |
可以看到,fork() 等价于 clone() 时不设置任何共享 flag,而 pthread_create() 则设置了几乎所有共享 flag。
5.2 clone() 的内核实现
struct task_struct *copy_process(struct task_struct *parent, const struct kernel_clone_args *args){ unsigned long clone_flags = args->flags; struct task_struct *p;
// 1. 安全检查 if (clone_flags & CLONE_NEWPID && clone_flags & CLONE_THREAD) return ERR_PTR(-EINVAL); // 新 PID 命名空间不能与线程共享
// 2. 复制 task_struct p = dup_task_struct(current, node);
// 3. 根据 flags 逐项复制或共享资源 if (clone_flags & CLONE_VM) p->mm = current->mm; // 共享 else copy_mm(clone_flags, p); // 复制(COW)
if (clone_flags & CLONE_FILES) p->files = current->files; // 共享 else copy_files(clone_flags, p); // 复制
if (clone_flags & CLONE_SIGHAND) p->sighand = current->sighand; // 共享 else copy_sighand(clone_flags, p); // 复制
// 4. 设置子进程的执行上下文 copy_thread(p, args);
// 5. 如果是线程,加入线程组 if (clone_flags & CLONE_THREAD) p->tgid = current->tgid;
return p;}5.3 vfork():一个历史遗留的优化
vfork() 是 fork() 的一个特殊变体,它保证子进程在调用 exec() 或 _exit() 之前不修改地址空间。内核通过设置 CLONE_VM | CLONE_VFORK 实现:
CLONE_VM:共享地址空间(不复制页表)CLONE_VFORK:父进程阻塞,直到子进程调用exec()或_exit()
现代 Linux 中,由于 COW 的存在,vfork() 的性能优势已经很小。除非你非常清楚自己在做什么,否则不应使用 vfork()——它的使用约束太多,容易引入难以调试的 bug。
七、execve():进程蜕变的瞬间
fork() 创建了父进程的副本,但大多数情况下,子进程需要执行一个全新的程序。execve() 就是完成这一”蜕变”的系统调用——它替换进程的地址空间,加载新程序。
6.1 execve() 的执行流程
static int do_execveat_common(int fd, struct filename *filename, const char __user *const __user *argv, const char __user *const __user *envp, int flags){ struct linux_binprm *bprm;
// 1. 准备 binprm 结构(保存执行参数) bprm = alloc_bprm(fd, filename);
// 2. 读取可执行文件头部(前 256 字节) bprm_fill_uid(bprm); prepare_binprm(bprm); copy_strings(bprm, envp); // 复制环境变量 copy_strings(bprm, argv); // 复制参数
// 3. 遍历注册的二进制格式处理器 // ELF → load_elf_binary() // 脚本(#!)→ load_script() → 递归 exec retval = exec_binprm(bprm);
// 4. 执行成功后的收尾 audit_bprm(bprm); return retval;}6.2 地址空间替换
execve() 对进程地址空间的替换是彻底的:
- 释放旧地址空间:解除所有旧 VMA 的映射,释放旧页表
- 建立新地址空间:根据 ELF 文件的 Program Header 创建新的 VMA
- 设置栈:分配用户栈,将
argv和envp压入栈顶 - 设置入口点:将指令指针设为 ELF 入口点(
e_entry)或动态链接器入口
// fs/exec.c 中 load_elf_binary() 的关键步骤(简化)static int load_elf_binary(struct linux_binprm *bprm){ // 1. 释放旧 mm flush_old_exec(bprm); // → exec_mmap() → mm_release() + 分配新 mm
// 2. 映射 ELF 段 for (each PT_LOAD segment) { vm_mmap(file, phdr->p_vaddr, phdr->p_memsz, prot, flags, phdr->p_offset); }
// 3. 设置入口点 if (elf_interpreter) { // 动态链接:入口 = ld.so 的入口 entry = load_elf_interp(&loc->interp_elf_ex, ...); } else { // 静态链接:入口 = e_entry entry = elf_ex.e_entry; }
start_thread(regs, entry, bprm->p); // 设置 PC 和 SP return 0;}6.3 文件描述符的继承规则
execve() 对文件描述符的处理遵循”close-on-exec”规则:
| 情况 | 行为 |
|---|---|
fd 的 FD_CLOEXEC 标志为 0 | 继承到新程序 |
fd 的 FD_CLOEXEC 标志为 1 | 关闭该 fd |
使用 O_CLOEXEC 打开的文件 | 自动设置 FD_CLOEXEC |
FD_CLOEXEC 的存在是为了防止文件描述符泄漏。在多线程程序中,如果 fork 和 exec 之间有其他线程创建 fd,可能导致意外的 fd 泄漏到子进程。O_CLOEXEC(Linux 2.6.23+)让 open 和设置 close-on-exec 成为原子操作,彻底解决了这个竞态问题。
信号处理的重置规则:
- 自定义信号处理函数 → 重置为
SIG_DFL(默认处理) SIG_IGN(忽略)→ 保持忽略SIG_DFL(默认)→ 保持默认
八、进程退出:从 do_exit() 到僵尸
7.1 exit() 的内核路径
当进程调用 exit()(或从 main() 返回)时,内核执行以下步骤:
void __noreturn do_exit(long code){ struct task_struct *tsk = current;
// 1. 设置退出码 tsk->exit_code = code;
// 2. 释放资源 exit_mm(tsk); // 释放地址空间(mm_struct) exit_sem(tsk); // 释放 System V 信号量 exit_shm(tsk); // 释放共享内存 exit_files(tsk); // 释放文件描述符表 exit_fs(tsk); // 释放 fs_struct(根目录/当前目录) exit_task_namespaces(tsk); // 释放命名空间引用
// 3. 处理子进程 // 将子进程托付给 init 进程或子线程组 exit_notify(tsk);
// 4. 变为僵尸状态 tsk->state = EXIT_ZOMBIE;
// 5. 最后一次调度 schedule(); // 永远不会返回到这里}7.2 exit_notify():通知父进程
static void exit_notify(struct task_struct *tsk){ // 1. 向父进程发送 SIGCHLD do_notify_parent(tsk, tsk->exit_signal);
// 2. 如果父进程是"忽略 SIGCHLD"的,自动回收 if (tsk->exit_signal == -1 && thread_group_empty(tsk)) tsk->exit_state = EXIT_DEAD; // 直接死亡,不经过僵尸
// 3. 将子进程托付给 init forget_original_parent(tsk); // 子进程的 parent 被设为 init 或同线程组的其他线程}7.3 资源释放的顺序
资源释放的顺序非常重要,必须遵循依赖关系:
- 先释放用户态资源:内存映射、文件、信号量等
- 再处理进程关系:通知父进程、托付子进程
- 最后保留
task_struct:变为僵尸,等待父进程wait()
九、僵尸进程与孤儿进程
8.1 僵尸进程(Zombie Process)
当子进程退出但父进程尚未调用 wait() 时,子进程就变成了僵尸进程。僵尸进程的 task_struct 仍然保留在内核中,保存着以下信息:
- 退出状态(
exit_code) - 资源使用统计(CPU 时间、内存使用等)
- 进程 ID(PID)
// 僵尸进程保留的信息(kernel/exit.c)// release_task() 被调用时才真正释放 task_structvoid release_task(struct task_struct *p){ // 只有父进程 wait() 后才会执行到这里 zap_leader = 0; leader = p->group_leader; // 释放 PID detach_pid(p, PIDTYPE_PID); // 释放 task_struct free_task(p);}为什么需要僵尸状态? 因为父进程可能需要获取子进程的退出状态。如果子进程退出后立即释放所有信息,父进程就无法知道子进程是正常退出还是被信号杀死、退出码是多少。
8.2 孤儿进程(Orphan Process)
如果父进程先于子进程退出,子进程就变成了孤儿进程。内核的 forget_original_parent() 函数会将孤儿进程”托付”给:
- 同一线程组内的其他线程(如果父进程是多线程的)
- init 进程(PID 1)——这是最常见的收养者
static void forget_original_parent(struct task_struct *father){ // 查找合适的"养父" reaper = find_new_reaper(father); // 将所有子进程的 parent 改为 reaper list_for_each_entry(p, &father->children, sibling) { p->real_parent = reaper; p->parent = reaper; }}init 进程(现代系统通常是 systemd)会定期调用 wait() 清理收养的孤儿进程,因此孤儿进程不会变成僵尸。
8.3 僵尸进程的危害与处理
僵尸进程本身不占 CPU 和内存(仅保留 task_struct 的几百字节),但会占用 PID 资源。Linux 的 PID 是有限资源(默认最大 32768),大量僵尸进程可能导致无法创建新进程。
处理僵尸进程的方法:
- 修复父进程:确保父进程正确调用
wait()/waitpid() - 信号处理:捕获
SIGCHLD并在处理函数中调用wait() - 杀死父进程:父进程死后,僵尸子进程变为孤儿,被 init 收养并清理
- 双 fork 技巧:创建”孙子进程”后让子进程退出,孙子进程自动被 init 收养
// 双 fork 技巧:避免僵尸进程if (fork() == 0) { // 子进程 if (fork() == 0) { // 孙子进程 // 孙子进程执行实际工作 // 父进程(子进程)已退出,自动被 init 收养 do_work(); _exit(0); } _exit(0); // 子进程立即退出}// 父进程只需 wait 一次(回收子进程)wait(NULL); // 孙子进程由 init 回收十、进程组、会话、控制终端
9.1 进程组(Process Group)
进程组是一个或多个进程的集合,通常对应一个作业(Job)。同一进程组内的所有进程拥有相同的进程组 ID(PGID),等于进程组首进程的 PID。
struct task_struct { pid_t pgid; // 进程组 ID // ...};进程组的主要用途是信号分发:kill -SIGINT -PGID 可以向整个进程组发送信号。当你在终端按 Ctrl+C 时,内核会向整个前台进程组发送 SIGINT。
9.2 会话(Session)
会话是一个或多个进程组的集合,用于作业控制。每个会话有一个会话首进程(Session Leader),其 PID 即为会话 ID(SID)。
struct task_struct { pid_t sid; // 会话 ID // ...};会话的核心概念是控制终端:
- 每个会话最多有一个控制终端
- 控制终端连接的会话称为控制会话
- 控制终端的前台进程组接收终端输入和信号(
Ctrl+C、Ctrl+Z)
9.3 作业控制的内核支持
当你在终端按 Ctrl+C 时,内核的处理流程:
- 终端驱动程序检测到
Ctrl+C,生成SIGINT信号 - 内核将
SIGINT发送给前台进程组的所有进程 - 前台进程组中的每个进程收到
SIGINT,执行默认处理(终止)
9.4 守护进程的创建
守护进程(Daemon)是一种特殊的后台进程,它脱离控制终端,独立于任何会话。创建守护进程的经典步骤:
// 创建守护进程的经典方法int daemonize(void){ // 1. fork() 后父进程退出 if (fork() != 0) _exit(0);
// 2. setsid() 创建新会话,脱离控制终端 setsid();
// 3. 再次 fork(),确保不会重新获取控制终端 if (fork() != 0) _exit(0);
// 4. 将工作目录改为根目录 chdir("/");
// 5. 关闭继承的文件描述符 for (int i = 0; i < 1024; i++) close(i);
// 6. 重定向 stdin/stdout/stderr 到 /dev/null open("/dev/null", O_RDWR); // stdin → fd 0 dup(0); // stdout → fd 1 dup(0); // stderr → fd 2
return 0;}现代 Linux 提供了 daemon() 库函数,封装了上述步骤。
十一、/proc/[pid]/:进程信息的窗口
Linux 的 /proc 文件系统将内核中进程的信息以文件的形式暴露给用户态。每个正在运行的进程在 /proc 下都有一个以 PID 命名的目录。
10.1 核心文件一览
| 文件 | 内容 | 对应的 task_struct 字段 |
|---|---|---|
/proc/[pid]/status | 进程状态的可读摘要 | state, pid, ppid, uid, VmSize 等 |
/proc/[pid]/stat | 进程状态的机器可读格式 | 同上,以数字编码 |
/proc/[pid]/maps | 虚拟内存映射 | mm → VMA 链表 |
/proc/[pid]/fd/ | 打开的文件描述符符号链接 | files → fd_array |
/proc/[pid]/cmdline | 完整命令行 | mm → arg_start/end |
/proc/[pid]/environ | 环境变量 | mm → env_start/end |
/proc/[pid]/smaps | 详细内存映射(含 RSS) | mm → VMA 链表 |
/proc/[pid]/oom_score | OOM 杀手评分 | mm → oom_score |
/proc/[pid]/cgroup | Cgroup 归属 | cgroup 信息 |
/proc/[pid]/ns/ | 命名空间符号链接 | nsproxy |
10.2 /proc/[pid]/status 详解
$ cat /proc/self/statusName: cat // comm 字段Umask: 0022State: R (running) // task_struct.stateTgid: 12345 // tgidNgid: 0Pid: 12345 // pidPPid: 12344 // real_parent->pidTracerPid: 0 // 被调试时的调试器 PIDUid: 1000 1000 1000 1000 // real/effective/saved/fsGid: 1000 1000 1000 1000FDSize: 64 // files->max_fdsGroups: 4 24 27 30 46 118 128NStgid: 1 // 嵌套命名空间层级NSpid: 1VmSize: 5524 kB // mm->total_vmVmRSS: 820 kB // mm->hiwater_rssVmData: 1236 kB // mm->data_vmVmStk: 136 kB // mm->stack_vmThreads: 1 // 线程组内线程数SigQ: 0/14378 // 信号队列使用/上限SigPnd: 0000000000000000 // 私有挂起信号ShdPnd: 0000000000000000 // 共享挂起信号SigBlk: 0000000000000000 // 被阻塞的信号SigIgn: 0000000000000000 // 被忽略的信号SigCgt: 0000000000000000 // 被捕获的信号10.3 /proc/[pid]/maps 与内存布局
$ cat /proc/self/maps55a1c0000000-55a1c0023000 r--p 00000000 08:01 1234567 /usr/bin/cat55a1c0023000-55a1c0045000 r-xp 00023000 08:01 1234567 /usr/bin/cat55a1c0045000-55a1c004e000 r--p 00045000 08:01 1234567 /usr/bin/cat55a1c004f000-55a1c0050000 r--p 0004e000 08:01 1234567 /usr/bin/cat55a1c0050000-55a1c0051000 rw-p 0004f000 08:01 1234567 /usr/bin/cat55a1c1a00000-55a1c1a21000 rw-p 00000000 00:00 0 [heap]7f4e8c000000-7f4e8c021000 rw-p 00000000 00:00 07f4e8c021000-7f4e8c100000 ---p 00000000 00:00 07f4e8c400000-7f4e8c500000 r--p 00000000 08:01 2345678 /usr/lib/locale/...7f4e8c800000-7f4e8c900000 r--p 00000000 08:01 3456789 /usr/lib/x86_64-linux-gnu/libc.so.67f4e8c900000-7f4e8cb18000 r-xp 00100000 08:01 3456789 /usr/lib/x86_64-linux-gnu/libc.so.6...7ffd5a7fe000-7ffd5a820000 rw-p 00000000 00:00 0 [stack]7ffd5a8a0000-7ffd5a8a4000 r--p 00000000 00:00 0 [vvar]7ffd5a8a4000-7ffd5a8a6000 r-xp 00000000 00:00 0 [vdso]每一行的格式为:起始地址-结束地址 权限 偏移 设备号 inode 路径。从中可以清晰看到 ELF 文件的各个段(代码段 r-xp、数据段 rw-p)、堆、栈、vdso 等的布局。
十二、动手实践
实践一:观察进程状态
# 查看当前进程的状态信息cat /proc/self/status
# 观察所有进程的状态ps aux # BSD 格式ps -ef # System V 格式ps -eo pid,ppid,stat,cmd # 自定义格式
# 查看进程树pstree -p # 带PID的进程树pstree -p $$实践二:fork+exec 的完整追踪
编写以下 C 程序,用 strace 追踪系统调用:
#include <stdio.h>#include <stdlib.h>#include <unistd.h>#include <sys/wait.h>
int main(void){ pid_t pid = fork(); if (pid < 0) { perror("fork"); exit(1); }
if (pid == 0) { // 子进程 printf("[Child] PID=%d, PPID=%d, fork returned %d\n", getpid(), getppid(), pid); execlp("/bin/ls", "ls", "-l", "/tmp", NULL); // 如果 exec 成功,下面的代码不会执行 perror("exec"); exit(1); } else { // 父进程 int status; printf("[Parent] PID=%d, child PID=%d\n", getpid(), pid); waitpid(pid, &status, 0); printf("[Parent] Child exited with status %d\n", WEXITSTATUS(status)); } return 0;}# 编译并运行gcc -o fork_exec_demo fork_exec_demo.c./fork_exec_demo
# 用 strace 追踪系统调用strace -f ./fork_exec_demo 2>&1 | head -60
# 关注以下系统调用:# clone() → fork 的内核实现# execve() → 执行新程序# wait4() → 等待子进程# exit_group() → 进程退出实践三:创建僵尸进程
#include <stdio.h>#include <stdlib.h>#include <unistd.h>
int main(void){ pid_t pid = fork(); if (pid == 0) { // 子进程立即退出,成为僵尸 printf("[Child] PID=%d becoming zombie...\n", getpid()); exit(0); }
// 父进程不调用 wait(),子进程变成僵尸 printf("[Parent] PID=%d, child PID=%d\n", getpid(), pid); printf("[Parent] Sleeping 60s... Check zombie with: ps aux | grep Z\n"); sleep(60);
// 60 秒后回收僵尸 wait(NULL); printf("[Parent] Zombie reaped.\n"); return 0;}# 编译运行gcc -o zombie_demo zombie_demo.c./zombie_demo &
# 在另一个终端观察僵尸进程ps aux | grep Z# 或ps -eo pid,ppid,stat,cmd | grep zombie_demo
# 你会看到类似输出:# 12345 12344 Z [zombie_demo] <defunct>
# 查看 /proc 中的僵尸进程信息ls -la /proc/$(pgrep -f zombie_demo)/cat /proc/$(pgrep -f zombie_demo)/status | grep State# State: Z (zombie)实践四:进程树与命名空间
# 查看完整进程树pstree -p
# 查看当前进程的命名空间ls -la /proc/self/ns/
# 输出示例:# cgroup -> 'cgroup:[4026531835]'# ipc -> 'ipc:[4026531839]'# mnt -> 'mnt:[4026531840]'# net -> 'net:[4026531992]'# pid -> 'pid:[4026531836]'# user -> 'user:[4026531837]'# uts -> 'uts:[4026531838]'
# 比较两个进程是否在同一命名空间readlink /proc/1/ns/pidreadlink /proc/self/ns/pid# 如果 inode 号相同,则在同一命名空间实践五:进程内存映射
# 查看进程的完整内存映射cat /proc/self/maps
# 查看更详细的内存信息(含 RSS、PSS)cat /proc/self/smaps | head -50
# 查看进程的内存统计cat /proc/self/status | grep Vm
# 查看进程打开的所有文件ls -la /proc/self/fd/
# 查看进程的命令行cat /proc/self/cmdline | tr '\0' ' '十三、源码导航
本章涉及的核心源码文件:
| 文件路径 | 内容 |
|---|---|
include/linux/sched.h | task_struct 定义,进程状态常量 |
kernel/fork.c | copy_process()、dup_task_struct()、copy_thread() |
kernel/exit.c | do_exit()、exit_notify()、release_task() |
fs/exec.c | do_execveat_common()、load_elf_binary() |
kernel/sys.c | getpid()、getppid()、setsid() 等系统调用 |
fs/proc/base.c | /proc/[pid]/ 目录下各文件的实现 |
mm/memory.c | COW 缺页处理 do_wp_page() |
推荐阅读顺序:
- 先读
include/linux/sched.h中的task_struct,建立全局视图 - 再读
kernel/fork.c中的copy_process(),理解进程创建 - 然后读
fs/exec.c中的do_execveat_common(),理解程序加载 - 最后读
kernel/exit.c中的do_exit(),理解进程退出
本章小结
本章深入剖析了 Linux 进程管理的核心机制,以下是关键要点回顾:
- 统一模型:Linux 用
task_struct统一表示进程和线程,通过clone()的 flags 控制资源共享粒度 - task_struct:进程的”身份证”,包含标识、状态、调度、内存、文件、信号、命名空间等 300+ 字段
- 状态机:六种状态(RUNNING、INTERRUPTIBLE、UNINTERRUPTIBLE、STOPPED、ZOMBIE、DEAD)的流转
- fork-COW:写时复制让 fork 高效——只复制页表,物理页在写入时才复制
- clone():Linux 线程的真正创建者,flags 精细控制资源共享
- execve():彻底替换地址空间,文件描述符按 close-on-exec 规则处理
- 僵尸与孤儿:僵尸保留退出信息等待父进程
wait(),孤儿被 init 收养 - 进程组与会话:作业控制的内核支持,信号按进程组分发
- /proc/[pid]/:内核进程信息的用户态窗口
参考资料
经典教材
- 《Linux 内核设计与实现》 第 3 章 — Robert Love,进程管理的高层概述
- 《深入理解 Linux 内核》 第 3 章 — Daniel P. Bovet 等,进程的详细实现分析
- 《操作系统导论》(OSTEP) 进程篇 — Remzi H. Arpaci-Dusseau,建立宏观认知
手册页
man 2 fork— fork 系统调用的完整文档man 2 clone— clone 系统调用及所有 flags 的详细说明man 2 execve— execve 系统调用及文件描述符继承规则man 2 wait— wait/waitpid 及僵尸进程回收man 2 exit— exit/_exit 的行为差异man 5 proc— /proc 文件系统的完整文档
内核源码
- include/linux/sched.h — task_struct 定义
- kernel/fork.c — 进程创建
- kernel/exit.c — 进程退出
- fs/exec.c — 程序执行
在线资源
- Bootlin Elixir Cross Referencer — 在线内核源码交叉引用
- Linux Kernel Newbies — 内核开发入门资源
- LWN.net — Linux 内核最新动态
支持与分享
如果这篇文章对你有帮助,欢迎支持作者或分享给更多人
部分信息可能已经过时






