mobile wallpaper 1mobile wallpaper 2mobile wallpaper 3mobile wallpaper 4
5202 字
14 分钟
进程管理
2024-10-04

一、引言:进程——操作系统最核心的抽象#

第 1 章中,我们俯瞰了 Linux 内核的七大子系统;在第 2 章中,我们穿越了用户态与内核态之间的桥梁。现在,走进内核最基础、最核心的子系统——进程管理

进程是操作系统最伟大的抽象:它让每个程序都以为自己独占了整台计算机的 CPU 和内存。当你同时在浏览器中阅读本文、在终端编译代码、在音乐播放器中听歌时,正是进程管理在幕后让这一切成为可能。理解进程管理,是理解 Linux 内核一切机制的基础——调度器调度的是进程,内存管理服务于进程,文件系统以进程为单位控制访问权限,信号和 IPC 在进程之间传递信息。

本章将带你深入进程从创建到消亡的完整生命周期,剖析 task_struct 这一内核最核心的数据结构,揭示 fork()-exec() 背后写时复制的精妙设计,并理解僵尸进程、孤儿进程、进程组与会话等关键概念。

二、进程与线程的 Linux 视角#

1.1 传统操作系统教科书 vs. Linux#

在传统操作系统教科书中,进程和线程是两个截然不同的概念:

  • 进程(Process):拥有独立地址空间、文件描述符表、信号处理等资源的执行实体
  • 线程(Thread):同一进程内的多个执行流,共享地址空间和资源,但拥有独立的栈和寄存器状态

Linux 采用了截然不同的设计哲学——进程和线程统一用 task_struct 表示。在 Linux 内核看来,不存在”线程”这个概念,只有”轻量级进程”(Lightweight Process, LWP)。线程不过是一组共享了某些资源的进程而已。

Note

这种统一设计被称为”一元化模型”(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() 系统调用实际返回的是 tgid
SYSCALL_DEFINE0(getpid)
{
return task_tgid_vnr(current); // 返回 tgid,而非 pid
}

1.3 进程树全景#

Linux 系统启动后,所有进程构成一棵以 init 进程(PID 1)为根的树形结构:

graph TD INIT["init/systemd<br/>PID=1"] --> SSH["sshd<br/>PID=234"] INIT --> BASH1["bash<br/>PID=567"] INIT --> CROND["crond<br/>PID=890"] BASH1 --> LS["ls<br/>PID=568"] BASH1 --> VIM["vim<br/>PID=569"] SSH --> BASH2["bash<br/>PID=1234"] BASH2 --> GCC["gcc<br/>PID=1235"] BASH2 --> AOUT["a.out<br/>PID=1236"] style INIT fill:#e74c3c,color:#fff style SSH fill:#3498db,color:#fff style BASH1 fill:#2ecc71,color:#fff style BASH2 fill:#2ecc71,color:#fff

每个进程都有且只有一个父进程(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_parent vs parent:通常指向同一个进程,但当进程被 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 进程有六种核心状态,它们之间的转换构成了一个有限状态机:

stateDiagram-v2 [*] --> TASK_RUNNING : fork() TASK_RUNNING --> TASK_INTERRUPTIBLE : 等待事件<br/>(schedule) TASK_RUNNING --> TASK_UNINTERRUPTIBLE : 等待不可中断事件<br/>(磁盘I/O) TASK_RUNNING --> TASK_STOPPED : SIGSTOP/<br/>ptrace TASK_RUNNING --> EXIT_ZOMBIE : exit() TASK_INTERRUPTIBLE --> TASK_RUNNING : 信号唤醒/<br/>事件就绪 TASK_UNINTERRUPTIBLE --> TASK_RUNNING : 事件就绪 TASK_STOPPED --> TASK_RUNNING : SIGCONT EXIT_ZOMBIE --> EXIT_DEAD : 父进程 wait() EXIT_DEAD --> [*]

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 都无法杀死——因为信号无法打断它的等待。

Warning

不可中断睡眠的存在是为了保证内核某些关键操作的原子性。如果一个进程正在等待磁盘 I/O 完成时被信号打断,可能导致数据结构处于不一致状态。不过,长时间处于 D 状态(TASK_UNINTERRUPTIBLE)通常意味着硬件故障或内核 bug。

3.4 TASK_STOPPED(停止)#

进程被暂停执行,通常由以下原因触发:

  • 收到 SIGSTOPSIGTSTPSIGTTINSIGTTOU 信号
  • 被调试器通过 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_RUNNINGR正在运行或就绪
TASK_INTERRUPTIBLES可中断睡眠
TASK_UNINTERRUPTIBLED不可中断睡眠
TASK_STOPPEDT停止
EXIT_ZOMBIEZ僵尸
EXIT_DEADX死亡(瞬时)

五、进程创建:fork() 的完整旅程#

fork() 是 Unix 最经典的系统调用之一——它创建一个与父进程几乎完全相同的子进程。追踪它从用户态到内核态的完整执行路径。

4.1 fork() → copy_process() → dup_task_struct()#

flowchart TD A["用户态调用 fork()"] --> B["syscall 入口<br/>entry_INT80 / syscall指令"] B --> C["kernel/fork.c: _do_fork()"] C --> D["copy_process()"] D --> E["dup_task_struct()"] E --> E1["分配 task_struct + thread_info"] E1 --> E2["复制父进程的 task_struct"] E2 --> F["copy_creds() — 复制凭证"] F --> G{"共享策略判断"} G --> |"CLONE_VM"| H["共享 mm_struct"] G --> |"不共享"| I["dup_mm() — 复制地址空间<br/>(COW)"] I --> J["copy_files() — 复制文件描述符"] J --> K["copy_signal() / copy_sighand()"] K --> L["copy_thread() — 设置子进程寄存器"] L --> M["sched_fork() — 初始化调度信息"] M --> N["返回新 task_struct"] N --> O["wake_up_new_task() — 将子进程加入运行队列"] style A fill:#3498db,color:#fff style O fill:#2ecc71,color:#fff

第一步:dup_task_struct()#

这是 fork 的起点——为子进程分配一个新的 task_struct 和内核栈:

kernel/fork.c
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() 复制地址空间:

kernel/fork.c
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) 的工作原理:

  1. dup_mm() 创建新的 mm_struct,复制父进程的 VMA 链表和页表
  2. 不复制物理页——父子进程的页表项都指向相同的物理页,并标记为只读
  3. 当任一方尝试写入时,CPU 触发缺页异常(Page Fault)
  4. 内核的缺页处理程序检测到这是 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() 会替换整个地址空间。

Tip

COW 不仅节省内存,更节省时间。一个拥有 1GB 地址空间的进程 fork 时,如果不用 COW,需要复制所有页表和物理页;使用 COW 后,只需复制页表(几 MB),物理页在写入时才按需复制。

description: ”#### 第三步:copy_thread()——设置子进程的”起跑线”

copy_thread() 设置子进程的寄存器状态,使其被调度时从 ret_from_fork 开始执行:

arch/x86/kernel/process.c
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 做了大量优化:

  1. COW:避免复制物理页
  2. 共享页表:对于 MAP_SHARED 的 VMA,直接共享页表
  3. 延迟 FPU 复制:子进程首次使用 FPU 时才复制浮点寄存器状态
  4. 内核栈复制优化:使用 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() 的内核实现#

kernel/fork.c
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()
Caution

现代 Linux 中,由于 COW 的存在,vfork() 的性能优势已经很小。除非你非常清楚自己在做什么,否则不应使用 vfork()——它的使用约束太多,容易引入难以调试的 bug。

七、execve():进程蜕变的瞬间#

fork() 创建了父进程的副本,但大多数情况下,子进程需要执行一个全新的程序。execve() 就是完成这一”蜕变”的系统调用——它替换进程的地址空间,加载新程序。

6.1 execve() 的执行流程#

fs/exec.c
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() 对进程地址空间的替换是彻底的

  1. 释放旧地址空间:解除所有旧 VMA 的映射,释放旧页表
  2. 建立新地址空间:根据 ELF 文件的 Program Header 创建新的 VMA
  3. 设置栈:分配用户栈,将 argvenvp 压入栈顶
  4. 设置入口点:将指令指针设为 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
Important

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() 返回)时,内核执行以下步骤:

kernel/exit.c
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():通知父进程#

kernel/exit.c
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 资源释放的顺序#

资源释放的顺序非常重要,必须遵循依赖关系:

  1. 先释放用户态资源:内存映射、文件、信号量等
  2. 再处理进程关系:通知父进程、托付子进程
  3. 最后保留 task_struct:变为僵尸,等待父进程 wait()

九、僵尸进程与孤儿进程#

8.1 僵尸进程(Zombie Process)#

当子进程退出但父进程尚未调用 wait() 时,子进程就变成了僵尸进程。僵尸进程的 task_struct 仍然保留在内核中,保存着以下信息:

  • 退出状态(exit_code
  • 资源使用统计(CPU 时间、内存使用等)
  • 进程 ID(PID)
// 僵尸进程保留的信息(kernel/exit.c)
// release_task() 被调用时才真正释放 task_struct
void 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() 函数会将孤儿进程”托付”给:

  1. 同一线程组内的其他线程(如果父进程是多线程的)
  2. init 进程(PID 1)——这是最常见的收养者
kernel/exit.c
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),大量僵尸进程可能导致无法创建新进程。

处理僵尸进程的方法

  1. 修复父进程:确保父进程正确调用 wait()/waitpid()
  2. 信号处理:捕获 SIGCHLD 并在处理函数中调用 wait()
  3. 杀死父进程:父进程死后,僵尸子进程变为孤儿,被 init 收养并清理
  4. 双 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+CCtrl+Z

9.3 作业控制的内核支持#

graph TD SESSION["会话 SID=1000<br/>(登录 shell)"] --> FG["前台进程组 PGID=1000<br/>vim, cat 等"] SESSION --> BG1["后台进程组 PGID=1234<br/>make -j4"] SESSION --> BG2["后台进程组 PGID=5678<br/>python server.py &"] FG --> TTY["控制终端<br/>/dev/pts/0"] TTY --> |"Ctrl+C"| FG TTY --> |"Ctrl+Z"| FG TTY --> |"输入"| FG style SESSION fill:#9b59b6,color:#fff style FG fill:#e74c3c,color:#fff style TTY fill:#f39c12,color:#fff

当你在终端按 Ctrl+C 时,内核的处理流程:

  1. 终端驱动程序检测到 Ctrl+C,生成 SIGINT 信号
  2. 内核将 SIGINT 发送给前台进程组的所有进程
  3. 前台进程组中的每个进程收到 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_scoreOOM 杀手评分mm → oom_score
/proc/[pid]/cgroupCgroup 归属cgroup 信息
/proc/[pid]/ns/命名空间符号链接nsproxy

10.2 /proc/[pid]/status 详解#

$ cat /proc/self/status
Name: cat // comm 字段
Umask: 0022
State: R (running) // task_struct.state
Tgid: 12345 // tgid
Ngid: 0
Pid: 12345 // pid
PPid: 12344 // real_parent->pid
TracerPid: 0 // 被调试时的调试器 PID
Uid: 1000 1000 1000 1000 // real/effective/saved/fs
Gid: 1000 1000 1000 1000
FDSize: 64 // files->max_fds
Groups: 4 24 27 30 46 118 128
NStgid: 1 // 嵌套命名空间层级
NSpid: 1
VmSize: 5524 kB // mm->total_vm
VmRSS: 820 kB // mm->hiwater_rss
VmData: 1236 kB // mm->data_vm
VmStk: 136 kB // mm->stack_vm
Threads: 1 // 线程组内线程数
SigQ: 0/14378 // 信号队列使用/上限
SigPnd: 0000000000000000 // 私有挂起信号
ShdPnd: 0000000000000000 // 共享挂起信号
SigBlk: 0000000000000000 // 被阻塞的信号
SigIgn: 0000000000000000 // 被忽略的信号
SigCgt: 0000000000000000 // 被捕获的信号

10.3 /proc/[pid]/maps 与内存布局#

$ cat /proc/self/maps
55a1c0000000-55a1c0023000 r--p 00000000 08:01 1234567 /usr/bin/cat
55a1c0023000-55a1c0045000 r-xp 00023000 08:01 1234567 /usr/bin/cat
55a1c0045000-55a1c004e000 r--p 00045000 08:01 1234567 /usr/bin/cat
55a1c004f000-55a1c0050000 r--p 0004e000 08:01 1234567 /usr/bin/cat
55a1c0050000-55a1c0051000 rw-p 0004f000 08:01 1234567 /usr/bin/cat
55a1c1a00000-55a1c1a21000 rw-p 00000000 00:00 0 [heap]
7f4e8c000000-7f4e8c021000 rw-p 00000000 00:00 0
7f4e8c021000-7f4e8c100000 ---p 00000000 00:00 0
7f4e8c400000-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.6
7f4e8c900000-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 追踪系统调用:

fork_exec_demo.c
#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() → 进程退出

实践三:创建僵尸进程#

zombie_demo.c
#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/pid
readlink /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.htask_struct 定义,进程状态常量
kernel/fork.ccopy_process()dup_task_struct()copy_thread()
kernel/exit.cdo_exit()exit_notify()release_task()
fs/exec.cdo_execveat_common()load_elf_binary()
kernel/sys.cgetpid()getppid()setsid() 等系统调用
fs/proc/base.c/proc/[pid]/ 目录下各文件的实现
mm/memory.cCOW 缺页处理 do_wp_page()

推荐阅读顺序

  1. 先读 include/linux/sched.h 中的 task_struct,建立全局视图
  2. 再读 kernel/fork.c 中的 copy_process(),理解进程创建
  3. 然后读 fs/exec.c 中的 do_execveat_common(),理解程序加载
  4. 最后读 kernel/exit.c 中的 do_exit(),理解进程退出

本章小结#

本章深入剖析了 Linux 进程管理的核心机制,以下是关键要点回顾:

  1. 统一模型:Linux 用 task_struct 统一表示进程和线程,通过 clone() 的 flags 控制资源共享粒度
  2. task_struct:进程的”身份证”,包含标识、状态、调度、内存、文件、信号、命名空间等 300+ 字段
  3. 状态机:六种状态(RUNNING、INTERRUPTIBLE、UNINTERRUPTIBLE、STOPPED、ZOMBIE、DEAD)的流转
  4. fork-COW:写时复制让 fork 高效——只复制页表,物理页在写入时才复制
  5. clone():Linux 线程的真正创建者,flags 精细控制资源共享
  6. execve():彻底替换地址空间,文件描述符按 close-on-exec 规则处理
  7. 僵尸与孤儿:僵尸保留退出信息等待父进程 wait(),孤儿被 init 收养
  8. 进程组与会话:作业控制的内核支持,信号按进程组分发
  9. /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 文件系统的完整文档

内核源码#

在线资源#

支持与分享

如果这篇文章对你有帮助,欢迎支持作者或分享给更多人

进程管理
https://blog.souloss.com/posts/linux-internals/process-management/
作者
Souloss
发布于
2024-10-04
许可协议
CC BY-NC-SA 4.0

部分信息可能已经过时