进程生命周期¶
本文你会学到:
fork()的写时复制机制与"零开销"原理- fork 之后父子进程的继承关系
exec族函数的各种变体与用途exit()与_exit()的区别- 父进程对子进程的等待与回收(
wait、waitpid、waitid) - 孤儿进程的自动收养机制
- 僵尸进程产生的原因与消灭方法
- 进程凭证(RUID、EUID、RGID、EGID)的设置与转换
- 守护进程(daemon)的正确创建步骤
- 进程资源限制(ulimit、
setrlimit)的应用
fork() — 复制一个新进程¶
写时复制:为什么 fork() 几乎是"零开销"?¶
当你调用 fork() 时,直觉上以为内核要把整个父进程的内存都复制一遍。如果真是这样,对于一个占用几百 MB 内存的进程来说,fork() 会是极其昂贵的操作。
实际上,现代 Linux 使用**写时复制(Copy-on-Write,CoW)**技术来避免这个开销:
- 代码段(text segment):内核将其标记为只读,父子进程直接共享同一份物理内存页。
- 数据段、堆、栈:父子进程的页表项都指向**同一批物理内存页**,但每一页都被标记为只读。只有当某一方试图**写入**某一页时,内核才为该进程复制一份独立的副本,然后让该进程写自己的副本。
这意味着:如果 fork() 之后立即 exec()(新程序完全替换内存),那些从未被修改过的页面就无需复制,节省了大量时间和内存。
fork() 后的继承关系¶
fork() 返回后,子进程是父进程的**几乎完整副本**,但有几处关键差异:
子进程继承(或复制)的内容:
| 内容 | 说明 |
|---|---|
| 文件描述符 | 子进程获得父进程所有 fd 的**副本**,共享同一个打开文件描述(偏移量/标志共享) |
| 信号处理器 | 继承父进程设置的信号处理函数 |
| 内存映射 | mmap() 的映射(写时复制) |
| 工作目录(CWD) | 继承父进程当前目录 |
| 环境变量 | 继承父进程的 environ |
| 进程凭证 | 继承 RUID/EUID/RGID/EGID |
| 信号掩码 | 继承父进程的阻塞信号集 |
子进程独有/不同的内容:
| 内容 | 说明 |
|---|---|
| PID | 子进程拥有新的唯一 PID |
| PPID | 子进程的 PPID 是父进程的 PID |
fork() 返回值 |
父进程得到子进程 PID;子进程得到 0 |
| 内存锁 | mlock()/mlockall() 不继承 |
| 定时器(timer) | 子进程不继承父进程的间隔定时器 |
| 已注册退出处理程序的状态 | 子进程继承退出处理函数,但 exec() 时会清除 |
fork() 前后的进程关系:
graph TD
A["父进程\nPID=1000"] -->|"fork()"| B["子进程\nPID=1001\nPPID=1000"]
A -->|"wait()"| C["等待子进程退出"]
B -->|"_exit(0)"| D["子进程终止\n状态由父进程收集"]
classDef parent fill:transparent,stroke:#0288d1,color:#adbac7,stroke-width:2px
classDef child fill:transparent,stroke:#388e3c,color:#adbac7,stroke-width:2px
classDef action fill:transparent,stroke:#768390,color:#adbac7,stroke-width:1px
class A parent
class B child
class C,D action
竞争条件:不要假设父子进程的调度顺序
fork() 之后,内核决定先调度父进程还是子进程(Linux 2.6.32+ 默认先调度父进程)。程序**不能依赖**特定的执行顺序——如果需要同步,应使用信号量、管道或信号。
vfork()(已过时,了解即可)¶
vfork() 是早期 BSD 引入的"高效 fork":子进程直接**共享**父进程的内存(不做写时复制),父进程被挂起直到子进程调用 exec() 或 _exit()。
这个设计非常危险——子进程对内存的任何修改都会影响父进程。现代 Linux 的 fork() 已经通过写时复制实现了相近的效率,SUSv4 已将 vfork() 从标准中移除。
新代码不要使用 vfork()
使用 fork() 代替。vfork() 的怪异语义容易导致隐蔽的 bug(栈破坏、父进程数据被污染)。
exec() 族 — 替换进程映像¶
exec() 的六个变体¶
execve() 是唯一真正的系统调用,其余都是 glibc 封装的库函数:
| 函数 | 程序定位 | 参数形式 | 环境变量来源 |
|---|---|---|---|
execve(path, argv[], envp[]) |
路径名 | 数组 | envp 参数 |
execle(path, arg0, ..., NULL, envp[]) |
路径名 | 列表 | envp 参数 |
execlp(file, arg0, ..., NULL) |
文件名 + PATH 搜索 |
列表 | 调用者 environ |
execvp(file, argv[]) |
文件名 + PATH 搜索 |
数组 | 调用者 environ |
execv(path, argv[]) |
路径名 | 数组 | 调用者 environ |
execl(path, arg0, ..., NULL) |
路径名 | 列表 | 调用者 environ |
命名规律:
p— 使用PATH环境变量搜索可执行文件(execlp、execvp)l— 参数以列表(list)形式逐一传入,末尾加(char*)NULLv— 参数以向量(vector,即数组)形式传入e— 显式传入环境变量数组envp
exec() 后的继承与丢弃¶
调用 exec() 成功后,进程的内存空间被新程序完全替换,但**进程本身还在**:
exec() 后保留的内容:
| 保留项 | 说明 |
|---|---|
| PID / PPID | 进程 ID 不变 |
| 文件描述符 | 除非设置了 FD_CLOEXEC(close-on-exec 标志),否则保留 |
| 进程凭证(通常) | RUID/RGID 保留;若文件有 set-user-ID 位则 EUID 改变 |
| 当前工作目录 | 保留 |
| 信号掩码 | 保留 |
| 进程组 / 会话 | 保留 |
| 资源限制(rlimit) | 保留 |
exec() 后丢弃的内容:
| 丢弃项 | 说明 |
|---|---|
| 整个内存映像 | 代码段、数据段、堆、栈全部被新程序替换 |
| 信号处理函数 | 所有自定义 handler 恢复为默认;被忽略的信号保持忽略 |
| 退出处理函数 | atexit() 注册的函数全部清除 |
| 共享内存映射 | 非继承的 mmap() 区域清除 |
#! 脚本解释器机制¶
Linux 内核通过 #!(shebang)机制识别脚本文件。当 execve() 执行的文件的头两个字节为 #! 时,内核会解析后续的解释器路径和可选参数,转而执行解释器程序,并将原脚本路径作为参数传入:
内核层面的执行流程:
execve("script.sh", ...)读取文件头部,发现#!/bin/bash- 内核拼接出实际执行路径:
/bin/bash script.sh - 等同于调用
execve("/bin/bash", ["/bin/bash", "script.sh", ...], ...)
解释器脚本的局限
#!后只能跟一个可选参数(例如#!/usr/bin/python3 -E合法,但#!/usr/bin/python3 -E -s会被视为(-E -s)整体一个参数)- 内核只识别
#!,忽略空格和缩进,格式必须严格:#!<path>[ <single-arg>] - 可通过
execve的argv[0]向解释器传递脚本文件名(某些解释器据此确定脚本目录)
fork + exec:启动新程序的标准范式¶
Linux(UNIX)的进程创建哲学与 Windows 完全不同:
- Windows:
CreateProcess()— 一步完成创建+执行 - UNIX:两步走 —
fork()复制自身,然后子进程调用exec()加载新程序
| fork + exec 范式 | |
|---|---|
两步走的优势在于:fork() 和 exec() 之间有一个**"准备窗口"**,可以做 I/O 重定向、关闭文件描述符、调整信号处理——这些灵活性是一步式 spawn() 难以提供的。
setuid 程序的有效 ID 变化¶
当执行一个设置了 set-user-ID 位(chmod u+s)的可执行文件时,execve() 会将进程的**有效用户 ID(EUID)**改为该文件的属主 ID:
| 查看 setuid 程序 | |
|---|---|
普通用户执行 passwd 时,进程的 EUID 从用户自身的 UID 变为 0(root),这样它才能修改 /etc/shadow。
wait/waitpid — 回收子进程¶
僵尸进程:为什么需要 wait()?¶
子进程调用 exit() 后,内核并不会立即销毁它的所有信息——它会保留一个**"残骸"(zombie 进程)**,其中只存放退出状态码,等待父进程来"收尸"。
如果父进程从不调用 wait(),这些僵尸进程会**一直占据内核进程表中的槽位**。进程表是有大小限制的——僵尸积累过多最终会导致无法创建新进程。
wait() vs waitpid()¶
wait() 的局限:
- 只能等待**任意**一个子进程(无法指定等某个特定的)
- 总是**阻塞**,不能做非阻塞轮询
waitpid() 解决了这些问题:
| waitpid() 用法 | |
|---|---|
重要的 options:
| 选项 | 含义 |
|---|---|
WNOHANG |
非阻塞:若无子进程状态变化,立即返回 0 |
WUNTRACED |
同时返回因信号**停止**(不是终止)的子进程状态 |
WCONTINUED |
同时返回因 SIGCONT 恢复执行的子进程状态 |
退出状态解码¶
wait()/waitpid() 返回的 status 值需要用宏来解析,不能直接读取:
SIGCHLD 异步回收模式¶
对于长期运行的程序(如服务器进程),用阻塞的 wait() 等待子进程不实际。更好的方式是:为 SIGCHLD 信号设置处理函数,在处理函数中循环调用 waitpid() 直到没有更多已退出的子进程:
为什么用循环而不是只调用一次 waitpid()?
当多个子进程同时终止时,多个 SIGCHLD 信号可能被合并(标准信号不排队)。处理函数执行时,可能同时有多个子进程等待回收,必须循环直至 waitpid() 返回 0 才能保证不留僵尸。
进程凭证(Process Credentials)¶
四类用户 ID¶
每个进程都同时携带四个与权限相关的用户 ID:
| ID 名称 | 英文 | 作用 |
|---|---|---|
| 实际用户 ID | Real UID (RUID) | 标识进程的"真实所有者",登录时从 /etc/passwd 读取 |
| 有效用户 ID | Effective UID (EUID) | 内核用它来做**权限检查**(文件访问、系统调用权限等) |
| 保存的 set-user-ID | Saved Set-user-ID (SSUID) | 允许 setuid 程序在特权/非特权间**可逆切换** |
| 文件系统用户 ID | Filesystem UID (FSUID) | Linux 特有,用于文件系统操作的权限检查(通常等于 EUID) |
正常情况下,RUID == EUID == SSUID,普通进程以启动者身份运行。
setuid 程序:passwd 命令的权限魔法¶
为什么普通用户能用 passwd 修改自己的密码,却无法直接编辑 /etc/shadow?
execve() 检测到可执行文件设置了 set-user-ID 位后,将 EUID 置为文件属主(root),进程因此获得了 root 权限,可以写 /etc/shadow。RUID 仍是 1000,表明"谁在运行这个程序"。
保存的 set-user-ID:临时挂起特权¶
一个设计良好的 setuid 程序,不应该在整个运行期间都保持特权。保存的 set-user-ID 提供了"临时放权"的机制:
这种"收放自如"的设计遵循**最小权限原则**:只在真正需要权限时才使用特权身份。
修改凭证的系统调用¶
| 系统调用 | 非特权进程能做什么 | 特权进程(EUID=0)能做什么 |
|---|---|---|
setuid(u) |
仅能将 EUID 改为 RUID 或 SSUID | 将 RUID/EUID/SSUID 全部改为 u(不可逆放权) |
seteuid(e) |
将 EUID 改为 RUID 或 SSUID | 将 EUID 改为任意值 |
setreuid(r, e) |
独立修改 RUID/EUID(限当前 RUID/EUID/SSUID 范围) | 可设为任意值 |
setresuid(r, e, s) |
独立修改 RUID/EUID/SSUID(限当前三者范围) | 可设为任意值 |
setresuid() 是语义最清晰的选择,但可移植性较差(Linux 特有);seteuid() 是可移植性最好的临时切换特权方式。
查看凭证:/proc/PID/status¶
| 查看进程凭证 | |
|---|---|
守护进程(Daemon)¶
守护进程的特征¶
守护进程(daemon)是一类特殊的后台进程,具备三个显著特征:
- 长寿:系统启动时创建,运行到关机
- 后台运行:不与任何用户终端交互
- 无控制终端:使用
ps查看时,TTY列显示?
常见的守护进程:sshd(SSH 服务)、nginx(Web 服务器)、crond(定时任务)、systemd(1 号进程)。
经典创建步骤(double-fork 方法)¶
在没有 systemd 的传统环境中,创建守护进程需要以下步骤:
double-fork 的原因: 调用 setsid() 后,进程成为新会话的领导者(session leader)。会话领导者在特定条件下可以重新获得控制终端。第二次 fork() 产生的子进程**不是**会话领导者,因此它永远无法获取控制终端,完全独立于任何终端。
systemd 时代的守护进程¶
在使用 systemd 的系统上,不需要手动做 double-fork——systemd 负责进程的生命周期管理:
| systemd 服务管理 | |
|---|---|
单实例守护进程:pidfile 机制¶
守护进程通常只应有一个实例在运行。传统的防多开机制是**锁文件(pidfile)**:
进程组与会话(Job Control 底层)¶
进程组(process group)¶
进程组**是一组相关进程的集合,每个进程组有一个**进程组 ID(PGID)。创建管道时,shell 会把管道中的所有进程放到同一个进程组:
| 查看进程组 | |
|---|---|
进程组 ID 通常等于进程组中**第一个进程**(组长,process group leader)的 PID。
会话(session)¶
会话**是一个或多个进程组的集合,关联一个**控制终端(controlling terminal):
graph TD
T["控制终端\n/dev/pts/0"] --> S["会话 SID=1000"]
S --> FG["前台进程组\nPGID=1234"]
S --> BG1["后台进程组\nPGID=1235"]
S --> BG2["后台进程组\nPGID=1236"]
FG --> P1["grep PID=1234"]
FG --> P2["sort PID=1235"]
BG1 --> P3["find PID=1235"]
BG2 --> P4["wget PID=1236"]
classDef term fill:transparent,stroke:#f57c00,color:#adbac7,stroke-width:2px
classDef sess fill:transparent,stroke:#7b1fa2,color:#adbac7,stroke-width:2px
classDef fg fill:transparent,stroke:#0288d1,color:#adbac7,stroke-width:2px
classDef bg fill:transparent,stroke:#388e3c,color:#adbac7,stroke-width:1px
classDef proc fill:transparent,stroke:#768390,color:#adbac7,stroke-width:1px
class T term
class S sess
class FG fg
class BG1,BG2 bg
class P1,P2,P3,P4 proc
同一个会话中,同一时刻只有一个**前台进程组**(terminal 输入/信号发往这里),其余为**后台进程组**。
setsid():创建新会话¶
setsid() 让当前进程脱离原会话,创建一个新会话并成为新会话的领导者:
| setsid() 用法 | |
|---|---|
setsid() 的前提
调用者**不能是**当前进程组的组长(即 PID ≠ PGID),否则调用失败。这就是 daemon 创建中第一次 fork() 的目的——让子进程(不是组长)来调用 setsid()。
为什么 Ctrl+C 能杀死一组进程¶
按下 Ctrl+C 时,终端驱动向**当前会话的前台进程组**中的所有进程发送 SIGINT 信号:
这就是为什么 grep "error" log | sort | uniq -c 这条管道命令,按一次 Ctrl+C 就能同时终止三个进程——它们都在同一个前台进程组里。
资源限制(ulimit / rlimit)¶
软限制与硬限制¶
每种资源限制都有两个层次:
- 软限制(soft limit):进程当前的实际约束,超过则操作失败(如打开文件数上限)
- 硬限制(hard limit):软限制可以调高到的上限,只有 root 才能提高硬限制
非特权进程可以:
- 将软限制降低到任意值
- 将软限制提升,但不能超过硬限制
- 不可逆地降低硬限制
重要限制项速查¶
| 常量 | ulimit 选项 | 含义 | 常见默认值 |
|---|---|---|---|
RLIMIT_NOFILE |
-n |
进程最多打开的文件描述符数量 | 1024 |
RLIMIT_NPROC |
-u |
该用户最多同时运行的进程数 | 约 63000 |
RLIMIT_CORE |
-c |
core dump 文件最大字节数(0=禁止) | 0 |
RLIMIT_STACK |
-s |
栈大小上限(字节) | 8192 KB |
RLIMIT_AS |
-v |
进程虚拟内存地址空间上限 | unlimited |
RLIMIT_CPU |
-t |
CPU 时间上限(秒) | unlimited |
RLIMIT_FSIZE |
-f |
进程可创建文件的最大字节数 | unlimited |
ulimit 命令¶
| ulimit 命令用法 | |
|---|---|
永久修改资源限制¶
编辑 /etc/security/limits.conf,由 pam_limits.so 在登录时应用:
| /etc/security/limits.conf | |
|---|---|
修改后需**重新登录**生效(pam_limits.so 在 login/PAM 会话建立时应用)。
同样编辑 /etc/security/limits.conf,但需确认 /etc/pam.d/login 和 /etc/pam.d/sshd 中已加载 pam_limits.so:
systemd 服务的资源限制在 .service 文件的 [Service] 节中配置:
| service 文件中的资源限制 | |
|---|---|
Linux Capabilities(能力机制)¶
为什么要有 Capabilities¶
传统 UNIX 是二元权限模型:要么是普通用户(受限),要么是 root(全能)。一个需要绑定 80 端口的 Web 服务器,为了调用 bind() 到 1024 以下的端口,不得不以 root 身份启动整个进程——这是严重的安全隐患。
Linux Capabilities 把 root 的特权**拆分成约 40 个独立能力(capability)**,可以单独授予程序,遵循最小权限原则。
常用 capability 速查¶
| Capability | 允许的操作 | 典型用途 |
|---|---|---|
CAP_NET_BIND_SERVICE |
绑定 1024 以下端口 | 让 nginx/httpd 不以 root 绑定 80/443 |
CAP_NET_ADMIN |
网络接口配置、路由表修改 | 网络管理工具 |
CAP_SYS_ADMIN |
大量系统管理操作(挂载、hostname...) | 容器运行时 |
CAP_KILL |
向任意进程发送信号 | 进程管理工具 |
CAP_CHOWN |
修改任意文件的属主 | 文件同步工具 |
CAP_DAC_OVERRIDE |
绕过文件读写执行权限检查 | 备份工具 |
CAP_SYS_PTRACE |
追踪任意进程(ptrace()) |
调试器 |
CAP_NET_RAW |
创建原始套接字 | ping、tcpdump |
每个进程都有三个 capability 集合:
- Permitted(许可集):进程可以拥有的 capabilities 上限
- Effective(有效集):当前实际生效的 capabilities
- Inheritable(可继承集):
exec()时可以传递给新程序的 capabilities
查看与设置¶
| 查看文件的 capabilities | |
|---|---|
| 设置文件 capabilities(代替 setuid) | |
|---|---|
| 用 capsh 调试 capabilities | |
|---|---|
capabilities 与 Docker/容器
容器运行时默认只给容器进程一个有限的 capability 子集(不含 CAP_SYS_ADMIN 等危险能力)。docker run --cap-add CAP_NET_ADMIN 可以单独添加,--privileged 则赋予全部能力(等同于 root,应避免)。