进程与线程
进程是资源分配的最小单位,线程是 CPU 调度的最小单位。理解二者的区别与协作机制,是写出高并发 Java/Go 程序的基础。本文覆盖进程状态机、IPC 方式、线程同步原语、fork/exec 语义以及僵尸进程的成因与处理。
目录
| 章节 | 说明 |
|---|---|
| 进程 vs 线程 vs 协程 | 三者本质差异对比 |
| 进程状态机 | 5 种状态及转换条件 |
| 进程生命周期 | fork/exec/wait/exit |
| 进程间通信(IPC) | 管道/信号/共享内存/Socket/消息队列 |
| 线程同步 | mutex/semaphore/条件变量 |
| 信号处理机制 | 信号的发送与捕获 |
| 孤儿进程与僵尸进程 | 成因与解决方案 |
进程 vs 线程 vs 协程
| 维度 | 进程 | 线程 | 协程 |
|---|---|---|---|
| 资源隔离 | 独立地址空间、文件描述符 | 共享进程地址空间 | 共享线程栈(用户态调度) |
| 切换开销 | 高(页表切换、TLB 刷新) | 中(保存/恢复寄存器) | 极低(用户态切换) |
| 创建开销 | 高(fork 复制页表) | 中(pthread_create) | 极低 |
| 通信方式 | IPC(管道/共享内存等) | 共享全局变量(需加锁) | channel / yield |
| 故障隔离 | 一个进程崩溃不影响其他进程 | 一个线程崩溃可能导致整个进程崩溃 | 同线程内,异常需自己处理 |
| Java 对应 | JVM 进程 | Thread / ForkJoinPool | Virtual Thread(JDK 21) |
| Go 对应 | os/exec.Cmd | goroutine(底层用线程池) | goroutine 本质是协程 |
一句话:进程是"公司",线程是"员工",协程是"任务切换不需要换人"。
进程状态机
Linux 进程有 5 种核心状态:
fork()
|
v
[TASK_NEW]
|
v
┌──── [RUNNING] ────┐
│ (就绪/运行中) │
│ │ │
│ 调度 │ 时间片耗尽│
│ v │
│ [CPU 执行中] │
│ │
│ 等待 I/O / 锁 │
└──→ [INTERRUPTIBLE] │
└──→ [UNINTERRUPTIBLE]│ (D 状态,不可被信号中断)
│
等待结束
v
[ZOMBIE] ←── exit() 后等待父进程 wait()
│
父进程 wait()
v
[消亡]
| 状态 | ps 显示 | 说明 |
|---|---|---|
| 运行/就绪 | R | 正在 CPU 上运行或等待调度 |
| 可中断睡眠 | S | 等待事件(I/O、信号),可被信号唤醒 |
| 不可中断睡眠 | D | 等待 I/O 完成,不能被 kill,常见于磁盘 I/O |
| 僵尸 | Z | 已退出但父进程未 wait() |
| 停止 | T | 被 SIGSTOP 或调试器暂停 |
# 查看进程状态
ps aux
# 查看 D 状态进程(不可中断,通常是 I/O 问题)
ps aux | awk '$8=="D"'
进程生命周期
fork / exec / wait
// fork:复制父进程,返回值区分父子
pid_t pid = fork();
if (pid == 0) {
// 子进程:执行新程序
execvp("ls", args); // 替换进程映像
} else {
// 父进程:等待子进程结束
int status;
waitpid(pid, &status, 0);
}
关键语义:
fork()返回 0 → 子进程;返回 > 0 → 父进程(值为子进程 PID)exec()系列函数:用新程序替换当前进程映像(代码段/数据段/堆/栈全部替换)wait()/waitpid():父进程收割子进程退出状态,防止僵尸进程
写时复制(Copy-on-Write):fork 后父子进程共享物理页,只有在写入时才真正复制,大幅降低 fork 开销。
Java/Go 中的对应
// Java 创建子进程
ProcessBuilder pb = new ProcessBuilder("ls", "-l");
Process p = pb.start();
int exitCode = p.waitFor();
// Go 创建子进程
cmd := exec.Command("ls", "-l")
output, err := cmd.Output() // 等价于 fork + exec + wait
进程间通信(IPC)
1. 管道(Pipe)
# 匿名管道:父子进程间,单向
ps -ef | grep java | awk '{print $2}' | xargs kill -9
# 命名管道(FIFO):任意进程间
mkfifo /tmp/mypipe
echo "hello" > /tmp/mypipe & # 写端阻塞直到有读端
cat < /tmp/mypipe # 读端
特点:单向、基于字节流、内核缓冲区有限(默认 64KB)。
2. 消息队列(Message Queue)
System V IPC,支持有类型的消息,允许接收方按类型选择读取。
# 查看系统消息队列
ipcs -q
# 删除
ipcrm -q <msqid>
3. 共享内存(Shared Memory)
最快的 IPC 方式,多个进程映射同一块物理内存,需配合信号量同步。
# 查看共享内存
ipcs -m
4. 信号量(Semaphore)
用于进程间同步,不传递数据。P 操作(-1)、V 操作(+1)。
5. Socket
跨机器通信的标准方式,Unix Domain Socket 用于本机进程间通信(比 TCP 快,无需网络协议栈)。
# 查看 Unix Domain Socket
ss -xp
6. 信号(Signal)
异步通知机制,用于通知进程某事件发生。
IPC 方式对比
| 方式 | 速度 | 跨机器 | 使用场景 |
|---|---|---|---|
| 管道 | 中 | 否 | 命令行组合、父子进程 |
| 消息队列 | 中 | 否 | 有结构化消息需求 |
| 共享内存 | 最快 | 否 | 高频大量数据交换 |
| Socket | 慢 | 是 | 分布式系统、微服务 |
| 信号 | 快 | 否 | 异步通知(kill、Ctrl+C) |
线程同步
Mutex(互斥锁)
pthread_mutex_t lock = PTHREAD_MUTEX_INITIALIZER;
pthread_mutex_lock(&lock);
// 临界区:同一时刻只有一个线程执行
shared_data++;
pthread_mutex_unlock(&lock);
Java 对应:synchronized、ReentrantLock
Go 对应:sync.Mutex
Semaphore(信号量)
控制同时访问某资源的线程数量(Mutex 是 Semaphore 值为 1 的特例)。
Java 对应:java.util.concurrent.Semaphore
条件变量(Condition Variable)
线程等待某条件成立,避免忙等(busy-wait)。
pthread_mutex_lock(&lock);
while (!condition_met) {
pthread_cond_wait(&cond, &lock); // 原子释放锁并等待
}
// 条件满足,执行逻辑
pthread_mutex_unlock(&lock);
// 另一个线程满足条件后:
pthread_cond_signal(&cond); // 唤醒一个等待线程
Java 对应:Object.wait() / notify() / notifyAll(),或 Condition.await() / signal()
线程数据分类
| 类型 | 说明 | Java 对应 |
|---|---|---|
| 线程栈本地数据 | 函数局部变量,天然隔离 | 方法内局部变量 |
| 进程全局数据 | 所有线程共享,需加锁 | static 字段 |
| 线程私有数据(TSD) | pthread_key_create |
ThreadLocal<T> |
Java ThreadLocal 注意:线程池场景下,线程复用导致 ThreadLocal 值残留,使用完必须调用
remove(),否则内存泄漏且数据污染。
信号处理机制
常用信号:
| 信号 | 编号 | 默认行为 | 触发场景 |
|---|---|---|---|
| SIGTERM | 15 | 终止 | kill <pid>,优雅关闭 |
| SIGKILL | 9 | 强制终止 | kill -9,不可捕获 |
| SIGINT | 2 | 终止 | Ctrl+C |
| SIGHUP | 1 | 终止/重新加载 | 终端断开,Nginx reload |
| SIGCHLD | 17 | 忽略 | 子进程状态变化 |
| SIGSEGV | 11 | 核心转储 | 段错误(空指针/越界) |
# 发送信号
kill -SIGTERM <pid> # 优雅终止
kill -SIGKILL <pid> # 强制终止
kill -SIGHUP <pid> # 重新加载配置(如 Nginx)
# 查看进程收到的信号
cat /proc/<pid>/status | grep -i sig
Java 中注册 ShutdownHook(对应 SIGTERM):
Runtime.getRuntime().addShutdownHook(new Thread(() -> {
// 优雅关闭:关闭连接池、刷新缓存等
log.info("Shutting down gracefully...");
}));
孤儿进程与僵尸进程
僵尸进程(Zombie)
子进程已退出,但父进程未调用 wait() 回收,进程描述符仍占用内核资源。
危害:大量僵尸进程会耗尽 PID 资源(Linux 默认最大 PID 约 32768)。
# 查找僵尸进程
ps aux | grep 'Z'
# 找到僵尸进程的父进程
ps -o ppid= -p <zombie_pid>
# 通知父进程回收(发送 SIGCHLD)
kill -SIGCHLD <parent_pid>
# 若父进程无法修改,只能杀死父进程
kill -9 <parent_pid>
Java 中防止僵尸进程:
Process p = Runtime.getRuntime().exec("cmd");
// 必须消费输出流,否则可能阻塞
p.getInputStream().transferTo(OutputStream.nullOutputStream());
int exitCode = p.waitFor(); // 必须调用,否则子进程成为僵尸
孤儿进程(Orphan)
父进程先于子进程退出,子进程被 init 进程(PID=1)收养,由 init 负责 wait(),不会产生僵尸。
Linux 调度器原理
调度类层次
Linux 为不同优先级的任务设置了多个调度类(Scheduling Class),内核按以下顺序选择下一个运行的任务:
Deadline(dl_rq)> Realtime(rt_rq)> Fair(cfs_rq)> Idle
每个 CPU 维护一个运行队列(runqueue),其中包含这三类子队列。
CFS 完全公平调度器
CFS(Completely Fair Scheduler) 是 Linux 默认的普通进程调度器,目标是让所有进程"公平"地使用 CPU。
核心数据结构:
struct sched_entity {
struct load_weight load; // 调度实体权重(与 nice 值相关)
struct rb_node run_node; // 红黑树节点
u64 exec_start; // 上次被调度的时间
u64 sum_exec_runtime; // 累计真实执行时间
u64 vruntime; // 虚拟运行时间(CFS 核心)
};
vruntime(虚拟运行时间):
vruntime 是 CFS 的灵魂。它代表"进程在理想 CPU 上已运行的时间",计算公式为:
vruntime += 实际运行时间 × (NICE_0_LOAD / 进程权重)
- nice 值越低(优先级越高),权重越大,vruntime 增长越慢,从而被更频繁地调度
- CFS 总是选择 vruntime 最小 的进程运行
- 所有可运行进程按 vruntime 组织在一棵红黑树(rb_tree)中,最左节点就是下一个运行的进程
红黑树(按 vruntime 排序)
[vruntime=100]
/ \
[vruntime=80] [vruntime=150]
↑
下一个运行的进程(vruntime 最小)
nice 值与权重对应关系(部分):
| nice 值 | 权重 | 相对 nice=0 的 CPU 时间比 |
|---|---|---|
| -20(最高) | 88761 | ~3.5x |
| 0(默认) | 1024 | 1x |
| 19(最低) | 15 | ~0.015x |
# 设置进程 nice 值(-20 到 19)
nice -n -5 command # 启动时设置
renice -n 5 -p <pid> # 运行中调整
# 查看进程 nice 值(NI 列)
top
ps -o pid,ni,comm -p <pid>
实时调度(SCHED_RT / SCHED_FIFO)
对延迟敏感的任务可设置为实时调度策略,实时任务总是优先于普通 CFS 任务执行。
| 策略 | 说明 | 优先级范围 |
|---|---|---|
SCHED_FIFO |
先进先出,不会被同级打断,只有更高优先级或主动让出才切换 | 1-99(99 最高) |
SCHED_RR |
轮转,同优先级进程轮流执行,每次有时间片限制 | 1-99 |
SCHED_NORMAL |
默认 CFS 调度 | nice -20 到 19 |
# 将进程设置为 SCHED_FIFO,优先级 50
chrt -f -p 50 <pid>
# 查看进程调度策略
chrt -p <pid>
# 查看实时优先级(PR 列,负数表示实时)
top
注意:实时进程如果出现死循环,会饿死所有普通进程。内核通过
kernel.sched_rt_runtime_us参数限制实时进程最多占用 95% 的 CPU 时间(默认值)。
调度域与 NUMA 亲和性
现代多处理器系统(NUMA)中,CPU 被组织成多个调度域(Scheduling Domain):
NUMA 节点 0 NUMA 节点 1
┌──────────────┐ ┌──────────────┐
│ CPU 0-7 │ ←慢→ │ CPU 8-15 │
│ 本地内存 │ │ 本地内存 │
└──────────────┘ └──────────────┘
- 访问本地 NUMA 节点内存比跨节点快 2-5 倍
- 内核调度器尽量让进程在同一 CPU 或同一 NUMA 节点上运行(CPU 亲和性)
# 绑定进程到指定 CPU(CPU 亲和性)
taskset -c 0,1 command # 启动时绑定到 CPU 0 和 1
taskset -cp 0-3 <pid> # 运行中修改
# 查看进程的 CPU 亲和性
taskset -p <pid>
# NUMA 感知的进程绑定(绑定到 NUMA 节点 0)
numactl --cpunodebind=0 --membind=0 command
# 查看 NUMA 拓扑
numactl --hardware
Java 服务的 NUMA 优化:JVM 支持 NUMA 感知的堆分配:
java -XX:+UseNUMA -XX:+UseParallelGC -jar app.jar
参考资料
- 《趣谈 Linux 操作系统》— 10 进程、11 线程、36-42 IPC 系列(刘超,极客时间)
- 《操作系统实战 45 讲》— 25-27 进程调度(LMOS,极客时间)
- 《Linux 内核技术实战课》— 17 CPU 执行任务(邵亚方,极客时间)
- 《Linux 性能优化实战》— 07-08 不可中断进程和僵尸进程(倪朋飞,极客时间)
- 《Linux Kernel Development》— Robert Love
- 从 C10K 到协程(多进程/多线程的局限是协程诞生的动机)
- 协程的实现机制(协程上下文切换与 OS 线程切换的对比)
评论 (0)
发表评论