进程与线程
进程与线程 —— 从"菜谱"到"厨房"的完整故事
一、先搞清楚:程序 vs 进程
你写了一个 Java 文件 HelloWorld.java,编译后得到 HelloWorld.class。
这个 .class 文件安静地躺在硬盘上,它就是一个程序(Program)——一段静态的代码。
当你运行它:java HelloWorld,操作系统把这个程序加载到内存中,给它分配 CPU 时间、内存空间、文件描述符等各种资源。
这时候,它就变成了一个进程(Process)——一个正在运行的程序实例。
生活比喻:
- 程序 = 菜谱(一张纸上的做菜步骤)
- 进程 = 按照菜谱正在做的那道菜(需要锅、食材、厨师、时间)
同一个菜谱,可以同时做两道菜。同一个程序,也可以同时运行两个进程。
那同一个程序开两个进程,它们的数据会互相影响吗?
解答: 不会。每个进程有自己独立的内存空间,就像两个厨师各自用各自的锅和食材,互不干扰。进程 A 修改变量 x,进程 B 的 x 完全不受影响。
二、进程到底是什么?—— 从内核角度看
从操作系统的角度看,进程不仅仅是一段代码。它是一个完整的运行环境。
2.1 进程的组成
一个进程包含以下内容:
| 组成部分 | 说明 | 比喻 |
|---|---|---|
| 代码段(Text) | 程序的机器指令 | 菜谱上的步骤 |
| 数据段(Data) | 全局变量、静态变量 | 公共食材仓库 |
| 堆(Heap) | 动态分配的内存(new 出来的对象) |
临时取用的食材 |
| 栈(Stack) | 函数调用栈、局部变量 | 做菜时的临时操作台 |
| PCB(进程控制块) | 操作系统管理进程的"档案" | 厨师的工牌和排班表 |
其中最重要的是 PCB(Process Control Block,进程控制块)。这是操作系统为每个进程建立的"身份证",里面记录了进程的所有关键信息:
- PID:进程的唯一编号
- 状态:就绪、运行、阻塞等
- 程序计数器(PC):下一条要执行的指令地址
- 寄存器值:CPU 寄存器的当前值
- 内存管理信息:页表、内存边界等
- IO 状态:打开的文件、使用的设备
- 调度信息:优先级、调度队列指针
PCB 为什么这么重要?
解答: 因为操作系统需要随时暂停和恢复一个进程。当进程被暂停时,操作系统把它的所有"现场"(寄存器值、程序计数器等)保存到 PCB 中。等轮到它再次运行时,再从 PCB 中恢复"现场"。就像你做菜做到一半被叫走,你需要在纸上记下"做到第几步、火候多大",回来才能继续。
2.2 深入:PCB 在内核中长什么样?
在 Linux 内核中,PCB 对应 task_struct 结构体(定义在 include/linux/sched.h),它是内核中最大的结构体之一,包含 100 多个字段:
struct task_struct {
// 1. 进程状态
volatile long state; // 运行态、就绪态、阻塞态等
// 2. 进程标识
pid_t pid; // 进程 ID
pid_t tgid; // 线程组 ID(主线程的 PID)
struct task_struct *parent; // 父进程
struct list_head children; // 子进程列表
// 3. 调度信息
int prio, static_prio; // 动态优先级和静态优先级
struct sched_entity se; // CFS 调度器的调度实体
// 4. 内存管理
struct mm_struct *mm; // 用户态内存描述符(页表等)
struct mm_struct *active_mm; // 活跃内存描述符
// 5. 文件系统
struct fs_struct *fs; // 文件系统信息(当前工作目录等)
struct files_struct *files; // 打开的文件描述符表
// 6. 信号处理
struct signal_struct *signal; // 信号处理函数
struct sighand_struct *sighand;
sigset_t blocked, real_blocked;
// 7. 时间统计
cputime_t utime, stime; // 用户态/内核态 CPU 时间
// 8. 命名空间(用于容器)
struct nsproxy *nsproxy;
// ... 还有很多字段
};
mm_struct** —— 进程的内存描述符**:
struct mm_struct {
pgd_t *pgd; // 页全局目录(页表的根)
unsigned long start_code, end_code; // 代码段起止地址
unsigned long start_data, end_data; // 数据段起止地址
unsigned long start_brk, brk; // 堆的起止地址
unsigned long start_stack; // 栈的起始地址
// 虚拟内存区域链表
struct vm_area_struct *mmap;
struct rb_root mm_rb;
// 引用计数
atomic_t mm_users; // 使用这个 mm_struct 的线程数
atomic_t mm_count; // 引用计数(包括内核引用)
};
为什么
mm_struct有mm_users和mm_count两个引用计数?解答:
mm_users:有多少个线程在使用这个内存空间(进程内的线程共享 mm)mm_count:有多少个内核引用(比如内核态访问用户态内存时临时引用)当
mm_users减到 0 时,说明没有线程使用这个内存空间了,可以释放大部分资源。
当mm_count减到 0 时,说明内核也没有引用了,可以彻底释放mm_struct。就像一本书:
mm_users是"有多少人正在读这本书",mm_count是"图书馆的借阅记录数"。所有人读完了(mm_users=0),书可以放回书架;但图书馆的借阅记录还在(mm_count>0),直到记录也清除才算彻底归还。
2.3 追问:进程的内存空间是怎么隔离的?
每个进程有独立的虚拟地址空间,范围通常是 04GB(32 位)或 02^64(64 位)。
虚拟地址 → 物理地址的转换:
进程 A 的虚拟地址空间 物理内存
+---------------+ +---------------+
| 0x00000000 | ──页表A──→ | 物理页 1 |
| ... | | 物理页 2 |
| 0x08048000 | ──页表A──→ | 物理页 5 | ← 代码段
| ... | | ... |
| 0xC0000000 | ──页表A──→ | 物理页 8 | ← 内核空间(映射到同一区域)
+---------------+ +---------------+
进程 B 的虚拟地址空间
+---------------+ +---------------+
| 0x00000000 | ──页表B──→ | 物理页 3 |
| ... | | 物理页 4 |
| 0x08048000 | ──页表B──→ | 物理页 6 | ← 代码段(不同的物理页!)
| ... | | ... |
| 0xC0000000 | ──页表B──→ | 物理页 8 | ← 内核空间(和进程 A 一样)
+---------------+ +---------------+
关键点:
- 每个进程有自己的页表,同样的虚拟地址可以映射到不同的物理页
- 用户空间(低地址)是独立的,互相隔离
- 内核空间(高地址)是所有进程共享的(映射到相同的物理内存)
为什么内核空间是所有进程共享的?
解答: 因为内核代码只有一份,所有进程进入内核态时执行的是同一份内核代码。如果每个进程都有独立内核空间,那内核代码要复制很多份,浪费内存。
另外,共享内核空间方便内核在不同进程之间传递数据(比如系统调用参数、IPC)。
就像所有餐厅共用同一个"食品安全监管部门",不需要每个餐厅都建一个独立的监管部门。
三、进程的状态转换
一个进程从创建到结束,会经历多种状态。最基本的有三种:
- 就绪态(Ready):厨师已经穿好工服、准备就绪,等着接单做菜。万事俱备,只差一个"开始"信号。
- 运行态(Running):厨师正在炒菜,占用了灶台(CPU)。
- 阻塞态(Blocked):厨师在等一个食材送来,暂时没法做菜。灶台让给别人用了。
3.1 五状态模型(更完整)
实际操作系统中,还有两个额外的状态:
| 状态 | 说明 | 触发条件 |
|---|---|---|
| 新建态(New) | 进程正在被创建,分配资源 | 执行 fork() 或创建进程 |
| 终止态(Terminated) | 进程结束,回收资源 | 程序执行完或被杀死 |
| 就绪态(Ready) | 已准备好,等 CPU | 时间片用完、IO 完成 |
| 运行态(Running) | 正在使用 CPU | 被调度器选中 |
| 阻塞态(Blocked) | 等待某个事件 | 等待 IO、等待锁、等待信号 |
状态转换图:
新建态 ──fork()──→ 就绪态 ←──────┐
↓ │
调度器选中 │ 时间片用完 / 被抢占
↓ │
运行态 ───────┘
↓
等待 IO / 等待事件
↓
阻塞态
↓
IO 完成 / 事件到达
↓
就绪态
↓
程序结束 / 被杀死
↓
终止态
进程从运行态变成就绪态,和从运行态变成阻塞态,有什么区别?
解答:
- 运行 → 就绪:进程还想继续运行,但 CPU 时间片用完了(或者被更高优先级的进程抢占了)。进程本身没有问题,只是"轮到别人了"。
- 运行 → 阻塞:进程主动放弃 CPU,因为它需要等待某个事件(比如读取文件、等待网络数据)。即使把 CPU 给它,它也没法继续执行。
就像你在餐厅等位:
- 运行→就绪:你正在吃饭,但限时 1 小时,时间到了你得让位(但你还想吃)
- 运行→阻塞:你正在吃饭,但发现没筷子了,你主动离开座位去要筷子(这时候给你座位也没用)
3.2 Linux 中的进程状态
在 Linux 中,用 ps 命令可以看到进程状态:
$ ps aux
USER PID %CPU %MEM VSZ RSS TTY STAT START TIME COMMAND
root 1 0.0 0.1 18532 3200 ? Ss 09:00 0:01 /sbin/init
root 500 0.0 0.2 72340 4500 ? S 09:01 0:00 sshd
root 600 0.0 0.1 21500 2800 pts/0 R+ 09:05 0:00 ps aux
STAT 列的含义:
| 状态码 | 含义 | 说明 |
|---|---|---|
| R | Running / Runnable | 运行中或就绪态 |
| S | Sleeping (Interruptible) | 可中断睡眠(等待某个事件) |
| D | Sleeping (Uninterruptible) | 不可中断睡眠(通常在进行 IO) |
| T | Stopped | 被信号停止(如 Ctrl+Z) |
| Z | Zombie | 僵尸进程(已终止但未被回收) |
| s | Session leader | 会话首领 |
| + | Foreground process | 前台进程 |
什么是"不可中断睡眠(D 状态)"?
解答: 不可中断睡眠是进程正在执行某些不能被中断的操作,通常是磁盘 IO。
为什么不能被中断?因为中断可能导致数据不一致。比如进程正在往磁盘写数据,如果这时候被中断,数据可能只写了一半,文件就损坏了。
D 状态的进程无法被 kill -9 杀死,只能等 IO 完成。如果 D 状态持续很长时间,通常是硬件故障(比如磁盘坏了)。
四、进程太"重"了 —— 引入线程
进程有一个问题:创建和切换的开销太大。
为什么?因为每次创建进程,操作系统都要:
- 分配独立的内存空间
- 复制代码段、数据段
- 创建新的 PCB
- 设置独立的文件描述符表
每次切换进程,操作系统都要保存和恢复整个进程的上下文(寄存器、内存映射、文件状态等)。
生活比喻:进程就像开一家新餐厅——要租场地、雇人、办执照,成本很高。如果我只是想多做一道菜,没必要开新餐厅,只需要在现有餐厅多雇一个厨师就行。
这个"多雇的厨师"就是线程(Thread)。
4.1 线程是什么
线程是进程内的执行单元。一个进程可以包含多个线程,它们共享进程的资源(内存、文件等),但每个线程有自己独立的执行流。
| 特性 | 进程 | 线程 |
|---|---|---|
| 资源 | 独立的内存空间 | 共享进程的内存空间 |
| 创建开销 | 大(分配内存、复制资源) | 小(只需创建栈和 TCB) |
| 切换开销 | 大(切换整个地址空间) | 小(只切换寄存器和栈) |
| 通信 | 需要 IPC 机制(管道、消息队列等) | 直接共享内存,通信方便 |
| 隔离性 | 互相隔离,一个崩不影响另一个 | 一个线程崩溃可能导致整个进程崩溃 |
| 比喻 | 一家独立的餐厅 | 餐厅里的一个厨师 |
线程共享了什么?不共享什么?
解答:
- 共享的:代码段、数据段、堆、打开的文件描述符、信号处理器
- 不共享的:栈(每个线程有自己的调用栈)、寄存器、程序计数器(PC)、线程 ID
这就是为什么多个线程可以访问同一个全局变量——因为它们共享数据段。但也正因为如此,多线程编程需要特别小心同步问题。
4.2 深入:线程控制块(TCB)
线程也有自己的控制块,但比 PCB 小得多:
struct thread_struct {
// 1. 寄存器状态(上下文切换时保存/恢复)
unsigned long sp; // 栈指针(RSP)
unsigned long ip; // 指令指针(RIP)
unsigned long ax, bx, cx, dx; // 通用寄存器
// ... 其他寄存器
// 2. 栈信息
void *stack; // 栈的起始地址
unsigned long stack_size; // 栈大小(通常 8MB)
// 3. 线程状态
int state; // 运行、就绪、阻塞等
// 4. 调度信息
int priority;
// 5. 指向所属进程的指针
struct task_struct *process;
};
TCB 比 PCB 小得多,因为线程不需要维护自己的内存映射、文件描述符表等——这些都在进程级别共享。
五、进程和线程的对比(从内核角度)
| 维度 | 进程 | 线程 |
|---|---|---|
| 定义 | 资源分配的基本单位 | CPU 调度的基本单位 |
| 内存 | 独立地址空间(独立的页表) | 共享进程地址空间(共享页表) |
| 内核数据结构 | task_struct + mm_struct | task_struct(共享 mm_struct) |
| 创建 | fork() / `clone(CLONE_VM |
CLONE_FS |
| 销毁 | 回收所有资源(内存、文件、信号等) | 只回收线程私有资源(栈、寄存器状态) |
| 切换开销 | 切换页表、刷新 TLB、切换内核栈 | 只切换寄存器和栈指针 |
| 通信 | 管道、消息队列、共享内存、Socket | 直接读写共享变量(需加锁) |
| 安全性 | 一个进程崩溃不影响其他 | 一个线程崩溃可能导致整个进程崩溃 |
| 适用场景 | 需要高隔离性的任务 | 需要高并发、共享数据的任务 |
为什么线程切换比进程切换快?具体快在哪里?
解答: 线程切换不需要切换地址空间,也就是不需要切换页表。
进程切换时:
- 保存当前进程的寄存器状态到 PCB
- 切换页表(CR3 寄存器)
- 刷新 TLB(转换检测缓冲区,页表的缓存)
- 加载新进程的寄存器状态
- 跳转到新进程的程序计数器
线程切换时:
- 保存当前线程的寄存器状态到 TCB
- 切换栈指针(RSP)
- 加载新线程的寄存器状态
- 跳转到新线程的程序计数器
关键区别:线程切换不需要切换页表,所以不需要刷新 TLB。TLB 刷新是非常昂贵的操作,因为接下来所有的内存访问都要重新查页表。
就像换教室上课:
- 进程切换 = 换到另一栋楼的教室(需要重新熟悉环境、找座位)
- 线程切换 = 在同一栋楼换教室(环境熟悉,只需要换个座位)
六、Linux 中线程本质上是轻量级进程
6.1 clone 系统调用
在 Linux 中,线程和进程本质上都是 task_struct,区别只在于创建时传入的标志位不同。
创建进程:
// fork() 内部调用 clone()
pid_t fork(void) {
return clone(SIGCHLD, 0);
}
创建线程(POSIX 线程库 pthread 内部):
// pthread_create() 内部调用 clone()
int pthread_create(...) {
return clone(CLONE_VM | CLONE_FS | CLONE_FILES | CLONE_SIGHAND |
CLONE_THREAD | CLONE_SYSVSEM | CLONE_SETTLS |
CLONE_PARENT_SETTID | CLONE_CHILD_CLEARTID,
stack_top);
}
clone 标志位的含义:
| 标志位 | 含义 | 进程(fork) | 线程(pthread) |
|---|---|---|---|
CLONE_VM |
共享内存空间 | 不设置(独立 mm) | 设置(共享 mm) |
CLONE_FS |
共享文件系统信息 | 不设置 | 设置 |
CLONE_FILES |
共享文件描述符表 | 不设置 | 设置 |
CLONE_SIGHAND |
共享信号处理函数 | 不设置 | 设置 |
CLONE_THREAD |
放入同一个线程组 | 不设置 | 设置 |
CLONE_PARENT |
共享父进程 | 不设置 | 设置 |
既然线程和进程都是 task_struct,那 Linux 的线程和 Windows 的线程有什么区别?
解答: Windows 有明确的"进程"和"线程"概念,进程是资源容器,线程是执行单元,两者是不同的内核对象。
Linux 没有严格的"线程"概念,线程只是共享了更多资源的进程。Linux 用
task_struct统一表示,通过 clone 标志位控制共享哪些资源。所以 Linux 中的线程有时被称为 LWP(Light Weight Process,轻量级进程)。
就像:
- Windows:公司和员工是两个不同的概念,员工必须属于某个公司
- Linux:所有人都是"人",只是有些人共享了房子、车子(线程),有些人完全独立(进程)
6.2 查看 Linux 中的线程
# 查看进程的线程
ps -T -p <pid>
# 或者使用 /proc 文件系统
ls /proc/<pid>/task/
# 使用 top 查看线程
top -H -p <pid>
# 使用 htop(更直观)
htop
在 /proc/<pid>/task/ 目录下,每个子目录对应一个线程(包括主线程),目录名就是线程的 TID。
七、进程的上下文切换到底切换了什么?
7.1 上下文切换的完整过程
进程上下文切换时,操作系统需要保存和恢复以下内容:
1. 用户态上下文(保存在 PCB 中):
- 通用寄存器:RAX, RBX, RCX, RDX, RSI, RDI, RBP 等
- 程序计数器(RIP):下一条要执行的指令地址
- 栈指针(RSP):用户态栈顶地址
- 标志寄存器(RFLAGS):条件码、中断使能等
2. 内核态上下文:
- 内核栈指针:切换到新进程的内核栈
- 页表基址(CR3):切换到新进程的页表
3. 其他状态:
- 浮点寄存器(如果使用了浮点运算)
- SIMD 寄存器(XMM/YMM/ZMM)
- 调试寄存器
- 性能监控寄存器
7.2 为什么上下文切换这么贵?
1. 直接开销(CPU 周期):
- 保存/恢复寄存器:几十到几百个 CPU 周期
- 切换页表、刷新 TLB:几百到几千个 CPU 周期
- 切换内核栈:几十个 CPU 周期
2. 间接开销(缓存失效):
- TLB 刷新:页表切换后,TLB 中所有条目都失效了。接下来每次内存访问都要查页表,直到 TLB 重新预热
- CPU 缓存失效:新进程的代码和数据不在当前 CPU 缓存中,需要从内存加载(缓存未命中)
- 分支预测失效:CPU 的分支预测器是基于历史记录的,新进程的执行模式不同,预测准确率下降
TLB 是什么?为什么刷新 TLB 这么贵?
解答: TLB(Translation Lookaside Buffer)是 CPU 内部的页表缓存。
每次内存访问都需要把虚拟地址转换成物理地址,这个转换需要查页表。页表在内存中,查一次需要好几次内存访问,非常慢。
TLB 就是缓存最近用过的"虚拟地址 → 物理地址"映射。如果 TLB 命中,地址转换只需要几个 CPU 周期;如果 TLB 未命中,需要查页表,需要几十到几百个周期。
进程切换时,页表变了,TLB 里的映射全部失效(因为不同进程的同一个虚拟地址映射到不同的物理地址)。接下来所有的内存访问都要重新查页表,直到 TLB 重新填满。
就像你搬家了,手机里的导航地址全都要重新输入一遍。
7.3 线程的上下文切换和进程的区别
| 切换内容 | 进程切换 | 线程切换 |
|---|---|---|
| 寄存器状态 | 保存/恢复 | 保存/恢复 |
| 程序计数器 | 保存/恢复 | 保存/恢复 |
| 栈指针 | 保存/恢复 | 保存/恢复 |
| 页表(CR3) | 切换 | 不切换 |
| TLB | 刷新 | 不刷新 |
| 内核栈 | 切换 | 切换(但开销小) |
| 地址空间 | 改变 | 不变 |
线程切换的开销大约是进程切换的 1/5 ~ 1/10。
八、进程间通信的 6 种方式
进程之间是隔离的,不能直接访问对方的内存。但它们需要协作,所以操作系统提供了多种进程间通信(IPC,Inter-Process Communication)机制。
8.1 管道(Pipe)
原理:管道是内核中的一块环形缓冲区,一个进程往里面写,另一个进程从里面读。
#include <unistd.h>
int pipe(int pipefd[2]);
// pipefd[0] 用于读
// pipefd[1] 用于写
特点:
- 半双工:数据只能单向流动
- 亲缘关系:只能用于父子进程或兄弟进程之间
- 字节流:没有消息边界,需要自行约定格式
使用示例:
int pipefd[2];
pipe(pipefd);
pid_t pid = fork();
if (pid == 0) {
// 子进程:关闭写端,从管道读
close(pipefd[1]);
char buf[100];
read(pipefd[0], buf, sizeof(buf));
printf("子进程收到: %s\n", buf);
close(pipefd[0]);
} else {
// 父进程:关闭读端,往管道写
close(pipefd[0]);
write(pipefd[1], "Hello", 5);
close(pipefd[1]);
}
为什么管道只能用于亲缘进程?
解答: 因为管道是通过文件描述符访问的,而文件描述符是进程私有的。子进程会继承父进程的文件描述符,所以父子进程可以通过继承来的 fd 访问同一个管道。
没有亲缘关系的进程,它们的文件描述符表是独立的,不知道对方的管道 fd 是多少。
就像家里的对讲机:只有住在这个家里的人(亲缘进程)才知道对讲机的频道号(fd),外面的人不知道。
8.2 命名管道(FIFO)
命名管道是管道的升级版,它有一个文件系统路径,没有亲缘关系的进程也可以通过路径访问它。
# 创建命名管道
mkfifo /tmp/my_fifo
# 进程 A 写入
echo "Hello" > /tmp/my_fifo
# 进程 B 读取
cat /tmp/my_fifo
// 代码中创建和使用 FIFO
mkfifo("/tmp/my_fifo", 0666);
int fd = open("/tmp/my_fifo", O_WRONLY);
write(fd, "Hello", 5);
8.3 消息队列(Message Queue)
原理:消息队列是内核维护的消息链表,进程可以往队列里发送有类型的消息,其他进程可以按类型接收。
#include <sys/msg.h>
// 创建消息队列
int msgid = msgget(IPC_PRIVATE, 0666 | IPC_CREAT);
// 发送消息
struct msgbuf {
long mtype; // 消息类型
char mtext[100]; // 消息内容
};
struct msgbuf msg = {1, "Hello"};
msgsnd(msgid, &msg, sizeof(msg.mtext), 0);
// 接收消息(只接收类型为 1 的消息)
struct msgbuf recv;
msgrcv(msgid, &recv, sizeof(recv.mtext), 1, 0);
特点:
- 消息有类型,可以 selective receive
- 可以非亲缘进程之间通信
- 消息有边界,不像管道是字节流
消息队列和管道有什么区别?
解答:
| 特性 | 管道 | 消息队列 |
|---|---|---|
| 消息边界 | 无(字节流) | 有(按消息为单位) |
| 消息类型 | 无 | 有(可以按类型接收) |
| 亲缘关系 | 需要 | 不需要 |
| 持久化 | 随进程结束消失 | 随内核重启消失 |
| 性能 | 较快(内核拷贝少) | 较慢(需要维护消息结构) |
8.4 共享内存(Shared Memory)
原理:多个进程映射同一块物理内存到自己的虚拟地址空间,直接读写这块内存来通信。
#include <sys/shm.h>
// 创建共享内存段
int shmid = shmget(IPC_PRIVATE, 4096, 0666 | IPC_CREAT);
// 映射到进程地址空间
void *addr = shmat(shmid, NULL, 0);
// 直接读写这块内存
strcpy((char*)addr, "Hello from shared memory!");
// 另一个进程也映射这块内存,就能读到同样的内容
为什么共享内存是最快的 IPC 方式?
| IPC 方式 | 数据拷贝次数 | 说明 |
|---|---|---|
| 管道 | 2 次 | 用户态 → 内核缓冲区 → 用户态 |
| 消息队列 | 2 次 | 用户态 → 内核消息结构 → 用户态 |
| 共享内存 | 0 次 | 直接读写同一块物理内存 |
共享内存的通信过程:
进程 A 的虚拟地址空间 物理内存 进程 B 的虚拟地址空间
+------------------+ +--------+ +------------------+
| 0x7f0000000000 | ──────→ | 共享页 | ←────── | 0x7f0000000000 |
| (共享内存映射) | | | | (共享内存映射) |
+------------------+ +--------+ +------------------+
进程 A 写入:*(char*)0x7f0000000000 = 'X'
进程 B 立即看到:*(char*)0x7f0000000000 == 'X'
既然共享内存最快,为什么还需要其他 IPC 方式?
解答: 共享内存虽然快,但有一个致命问题:没有同步机制。
如果进程 A 正在写数据,进程 B 同时来读,可能读到一半新一半旧的数据(数据不一致)。
所以使用共享内存时,通常需要配合信号量或互斥锁来实现同步:
- 进程 A 写之前:获取锁
- 进程 A 写完后:释放锁
- 进程 B 读之前:获取锁
- 进程 B 读完后:释放锁
就像多人共用一块白板:如果不约定"谁写的时候别人不能看",就可能看到乱七八糟的内容。
8.5 信号量(Semaphore)
原理:信号量是一个计数器,用来控制多个进程对共享资源的访问。
#include <sys/sem.h>
// 创建信号量
int semid = semget(IPC_PRIVATE, 1, 0666 | IPC_CREAT);
// 初始化信号量值为 1(二元信号量 = 互斥锁)
union semun arg;
arg.val = 1;
semctl(semid, 0, SETVAL, arg);
// P 操作(等待/获取)
struct sembuf sb = {0, -1, 0}; // 信号量 0,减 1
semop(semid, &sb, 1);
// 访问共享资源...
// V 操作(信号/释放)
sb.sem_op = 1; // 加 1
semop(semid, &sb, 1);
信号量的两种类型:
| 类型 | 初始值 | 用途 |
|---|---|---|
| 二元信号量 | 1 | 互斥锁,保证同一时刻只有一个进程访问资源 |
| 计数信号量 | N | 控制最多 N 个进程同时访问资源(如连接池) |
8.6 Socket
原理:Socket 是网络通信的端点,不仅可以用于不同机器之间的通信,也可以用于同一台机器上的进程间通信(Unix Domain Socket)。
// Unix Domain Socket 示例
#include <sys/socket.h>
#include <sys/un.h>
int sock = socket(AF_UNIX, SOCK_STREAM, 0);
struct sockaddr_un addr;
addr.sun_family = AF_UNIX;
strcpy(addr.sun_path, "/tmp/mysocket");
bind(sock, (struct sockaddr*)&addr, sizeof(addr));
listen(sock, 5);
Socket 的优势:
- 可以跨机器通信(TCP/UDP Socket)
- 也可以本机通信(Unix Domain Socket)
- 是最通用的 IPC 机制
Unix Domain Socket vs TCP Socket(本机通信):
| 特性 | Unix Domain Socket | TCP Socket(127.0.0.1) |
|---|---|---|
| 数据拷贝 | 直接通过内核缓冲区传递 | 经过完整的 TCP/IP 协议栈 |
| 性能 | 更快 | 稍慢 |
| 寻址方式 | 文件路径 | IP + 端口 |
| 权限控制 | 文件权限 | 防火墙规则 |
8.7 信号(Signal)
原理:信号是一种异步通知机制,操作系统或进程可以发送信号给另一个进程,通知它发生了某个事件。
# 常用信号
kill -9 <pid> # SIGKILL:强制终止
kill -15 <pid> # SIGTERM:请求终止(可以捕获)
kill -1 <pid> # SIGHUP:终端断开
kill -2 <pid> # SIGINT:Ctrl+C
#include <signal.h>
// 注册信号处理函数
void handler(int sig) {
printf("收到信号 %d\n", sig);
}
signal(SIGTERM, handler);
信号的特点:
- 异步:发送方不等待接收方处理
- 简单:只能传递信号编号,不能传递复杂数据
- 有限:标准信号只有 64 种
信号和前面几种 IPC 有什么区别?
解答: 信号是通知机制,不是数据传输机制。
- 管道/消息队列/共享内存/Socket:用来传递数据
- 信号:用来通知事件(比如"你该退出了"、“你的子进程死了”)
就像:
- 数据传输 = 寄快递(有具体内容)
- 信号 = 门铃响(只告诉你"有人来了",但不告诉你是谁、来干什么)
8.8 六种 IPC 方式对比总结
| IPC 方式 | 数据方向 | 亲缘要求 | 数据拷贝 | 同步机制 | 适用场景 |
|---|---|---|---|---|---|
| 管道 | 单向 | 需要 | 2 次 | 无 | 父子进程简单通信 |
| 命名管道 | 单向 | 不需要 | 2 次 | 无 | 无关进程简单通信 |
| 消息队列 | 双向 | 不需要 | 2 次 | 无 | 按类型接收消息 |
| 共享内存 | 双向 | 不需要 | 0 次 | 需配合信号量 | 大量数据高速通信 |
| 信号量 | 无(同步用) | 不需要 | 无 | 自身就是同步 | 配合共享内存使用 |
| Socket | 双向 | 不需要 | 2 次 | 无 | 跨网络、最通用 |
| 信号 | 无(通知用) | 不需要 | 无 | 无 | 异步事件通知 |
九、协程是什么?和线程的区别?
9.1 协程的定义
协程(Coroutine) 是一种用户态的轻量级线程,由用户程序自己调度,而不是由操作系统内核调度。
协程的特点:
- 用户态调度:切换不需要进入内核,开销极小
- 非抢占式:协程主动让出 CPU(yield),不会被强制中断
- 共享线程:多个协程可以运行在同一个线程内
- 栈空间小:协程栈通常只有几 KB
9.2 协程 vs 线程 vs 进程
| 特性 | 进程 | 线程 | 协程 |
|---|---|---|---|
| 调度者 | 操作系统内核 | 操作系统内核 | 用户程序/运行时 |
| 切换开销 | 大(~1-10us) | 中(~0.1-1us) | 极小(~纳秒级) |
| 栈大小 | MB 级 | MB 级(通常 8MB) | KB 级(通常 2-8KB) |
| 创建数量 | 几百个 | 几千个 | 几十万个 |
| 切换方式 | 抢占式 | 抢占式 | 协作式(主动让出) |
| 数据共享 | 不共享 | 共享进程内存 | 共享线程内存 |
| 多核利用 | 可以 | 可以 | 需要多线程 + 多协程 |
| 阻塞影响 | 只影响当前进程 | 只影响当前线程 | 阻塞整个线程 |
9.3 协程的工作原理
协程切换的本质:
// 协程切换(简化版)
void coroutine_switch(Coroutine *from, Coroutine *to) {
// 1. 保存当前协程的上下文(寄存器、栈指针)
save_context(&from->context);
// 2. 恢复目标协程的上下文
restore_context(&to->context);
// 3. 跳转到目标协程的执行位置
}
协程切换不需要进入内核,只需要保存/恢复几个寄存器(比线程切换少得多),所以速度极快。
协程的调度:
线程(内核调度)
├── 协程 A(运行中)──yield()──→ 协程 B
├── 协程 B(运行中)──yield()──→ 协程 C
├── 协程 C(运行中)──yield()──→ 协程 A
└── ...
所有协程都在同一个线程内协作式调度,没有抢占。
协程的"阻塞整个线程"是什么意思?
解答: 如果一个协程执行了一个阻塞操作(比如读取文件、网络请求),它会阻塞整个线程,线程里的其他协程也无法执行。
所以使用协程时,必须用非阻塞 IO + 事件循环:
- 协程 A 发起网络请求(非阻塞)
- 协程 A yield,让出 CPU
- 事件循环检查哪个协程的 IO 完成了
- 协程 A 的 IO 完成,事件循环恢复协程 A 的执行
这就像:
- 线程 = 一个厨房,厨师(线程)可以同时做几道菜(协程),但如果一道菜需要等烤箱(阻塞),厨师只能干等着,其他菜也做不了
- 解决办法:厨师不等烤箱,先去做别的菜,烤箱响了再回来(事件循环)
9.4 协程的实际应用
Java 虚拟线程示例(JDK 21+):
// 创建虚拟线程
Thread.startVirtualThread(() -> {
System.out.println("Hello from virtual thread!");
});
// 或者用 ExecutorService
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
executor.submit(() -> {
// 这个任务在虚拟线程上执行
return fetchDataFromNetwork();
});
}
Java 的虚拟线程:
- 底层是协程,但暴露为 Thread API
- JVM 负责把虚拟线程调度到平台线程(内核线程)上
- 阻塞操作会自动让出,不会阻塞平台线程
十、实际项目中的多进程 vs 多线程选择
10.1 选择依据
| 场景 | 推荐方案 | 原因 |
|---|---|---|
| CPU 密集型计算 | 多进程 | 绕过 GIL(Python)、利用多核 |
| IO 密集型(网络/磁盘) | 多线程 / 协程 | 线程切换开销相对 IO 可忽略 |
| 需要高隔离性 | 多进程 | 一个进程崩溃不影响其他 |
| 需要共享大量数据 | 多线程 | 共享内存,通信简单 |
| 高并发连接(C10K+) | 协程 / IO 多路复用 | 上下文切换开销极小 |
| 多核充分利用 | 多进程 + 多线程 | 进程利用多核,线程处理并发 |
10.2 经典案例分析
案例 1:Nginx(多进程 + IO 多路复用)
Master 进程
├── Worker 进程 1(单线程 + epoll)
├── Worker 进程 2(单线程 + epoll)
├── Worker 进程 3(单线程 + epoll)
└── Worker 进程 4(单线程 + epoll)
- 多进程:利用多核,一个 Worker 崩溃不影响其他
- 单线程 + epoll:避免线程切换开销,一个线程处理万级连接
案例 2:MySQL(多线程)
MySQL Server 进程
├── 连接线程 1(处理客户端请求)
├── 连接线程 2
├── 连接线程 3
└── 后台线程(刷盘、日志、锁监控等)
- 多线程:连接之间需要共享数据库缓存(Buffer Pool)
- 线程池:避免创建过多线程
案例 3:Chrome 浏览器(多进程)
Browser 进程(主进程)
├── GPU 进程
├── 渲染进程 1(标签页 A)
├── 渲染进程 2(标签页 B)
├── 渲染进程 3(标签页 C)
├── 插件进程
└── 扩展进程
- 多进程:一个标签页崩溃不会影响其他标签页
- 沙箱隔离:渲染进程在沙箱中运行,防止恶意网页攻击系统
案例 4:Java Web 应用(多线程)
Tomcat 进程
├── 线程池(200 个线程)
│ ├── 线程 1:处理 HTTP 请求 A
│ ├── 线程 2:处理 HTTP 请求 B
│ └── ...
- 多线程:请求之间需要共享数据库连接池、缓存等
- 线程池:控制并发数,避免资源耗尽
10.3 Java 中的选择
// 多线程(Java 最常用的方式)
ExecutorService threadPool = Executors.newFixedThreadPool(10);
threadPool.submit(() -> {
// 处理任务
});
// Java 21+ 虚拟线程(协程)
ExecutorService virtualPool = Executors.newVirtualThreadPerTaskExecutor();
virtualPool.submit(() -> {
// 处理任务(底层是协程)
});
// 多进程(Java 中较少用,通常用多 JVM 实例)
ProcessBuilder pb = new ProcessBuilder("java", "-jar", "worker.jar");
Process process = pb.start();
我的项目应该用多进程还是多线程?
解答: 大部分 Java Web 项目用多线程就够了,因为:
- 请求之间需要共享数据库连接池、Redis 连接、业务缓存等
- Java 的线程模型成熟,Spring 等框架都基于多线程
- 现代 JVM 的线程性能已经很好了
考虑多进程的场景:
- 需要执行不可信代码(比如用户提交的脚本),用子进程隔离
- 需要利用多核做 CPU 密集型计算,且不想被 GIL 限制(Python 场景)
- 需要高可用,一个进程崩溃不能影响整个服务(比如用 Supervisor 管理多个进程)
考虑协程/虚拟线程的场景:
- 超高并发(C10K 以上),线程数量成为瓶颈
- 大量 IO 操作,协程的轻量级切换更有优势
- 使用 Java 21+,可以直接用虚拟线程替换传统线程
十一、进程间关系
11.1 父进程与子进程
在 Linux/Unix 中,进程有"家族关系":
init进程(PID=1)是所有进程的"祖先"- 每个进程都有父进程(除了
init) A创建B,则A是B的父进程,B是A的子进程
init (PID=1)
├── sshd (PID=100)
│ ├── bash (PID=200)
│ │ └── java (PID=300) ← 你的Java程序
│ └── bash (PID=201)
└── nginx (PID=101)
为什么需要父子关系?
- 父进程可以等待子进程结束(
wait()) - 父进程结束时,子进程通常也会被终止
- 方便资源回收——父进程负责回收子进程的资源
什么是"僵尸进程"?
解答: 子进程结束了,但父进程没有调用
wait()来回收它的资源。这时候子进程的 PCB 还留在系统中,变成了"僵尸"。就像一个人去世了,但户口还没注销。如果父进程也结束了,init进程会接管并回收这些僵尸进程。
11.2 前台进程与后台进程
| 类型 | 特点 | 示例 |
|---|---|---|
| 前台进程 | 占用终端,用户可以直接交互 | 你在终端输入命令运行的程序 |
| 后台进程 | 不占用终端,在后台默默运行 | Linux 的 service、daemon |
在 Linux 中,用 & 可以把程序放到后台运行:
java MyApp & # 后台运行
nohup java MyApp & # 后台运行,且关闭终端也不会停止
十二、Java 中的进程和线程
12.1 Java 程序的进程
当你运行 java HelloWorld,JVM 本身就是一个进程。你的 Java 程序运行在这个 JVM 进程内部。
12.2 Java 中创建线程
// 方式一:继承 Thread 类
public class MyThread extends Thread {
@Override
public void run() {
System.out.println("线程 " + Thread.currentThread().getName() + " 正在运行");
}
}
// 使用
MyThread t1 = new MyThread(); // 创建线程
t1.start(); // 启动线程(注意是 start(),不是 run())
// 方式二:实现 Runnable 接口(推荐)
public class MyRunnable implements Runnable {
@Override
public void run() {
System.out.println("线程 " + Thread.currentThread().getName() + " 正在运行");
}
}
// 使用
Thread t2 = new Thread(new MyRunnable()); // 创建线程
t2.start(); // 启动线程
// 方式三:线程池(实际开发最常用)
ExecutorService pool = Executors.newFixedThreadPool(3); // 创建3个线程的线程池
pool.submit(() -> {
System.out.println("线程池中的线程正在执行任务");
});
pool.shutdown(); // 关闭线程池
start()
和run()` 有什么区别?解答:
start()会创建一个新线程,然后在新线程中调用run()。而直接调用run()只是在当前线程中执行方法,不会创建新线程。就像你让厨师去做菜(start())和自己去做菜(run())的区别。
12.3 Java 线程的底层实现
Java 的 Thread 类最终调用的是 JVM 的本地方法,JVM 再调用操作系统的线程创建 API:
Java: new Thread().start()
↓
JVM: JVM_StartThread()
↓
JVM: create_vm_thread()
↓
Linux: clone() 或 pthread_create()
↓
内核: 创建 task_struct,加入调度队列
在 Linux 上,HotSpot JVM 使用 pthread_create() 创建线程,而 pthread_create() 内部调用 clone()。
十三、本章要点回顾
| 概念 | 一句话解释 | 类比 |
|---|---|---|
| 程序 | 静态的代码文件 | 菜谱 |
| 进程 | 正在运行的程序实例 | 正在做的那道菜 |
| PCB | 操作系统管理进程的"档案" | 厨师的工牌 |
| 线程 | 进程内的执行单元 | 餐厅里的一个厨师 |
| 就绪态 | 准备好了,等 CPU | 厨师穿好工服等接单 |
| 运行态 | 正在使用 CPU | 厨师正在炒菜 |
| 阻塞态 | 等待某个事件 | 厨师等食材送来 |
| 上下文切换 | 保存/恢复进程/线程的执行状态 | 换班时交接工作 |
| 管道 | 单向字节流传输 | 一根水管 |
| 消息队列 | 有类型的消息链表 | 带分类的收件箱 |
| 共享内存 | 多个进程映射同一块物理内存 | 多人共用一块白板 |
| 信号量 | 控制资源访问的计数器 | 停车场的车位计数器 |
| Socket | 网络通信端点 | 电话 |
| 信号 | 异步事件通知 | 门铃 |
| 协程 | 用户态轻量级线程 | 厨师同时做几道菜 |
| 僵尸进程 | 子进程结束但未被回收 | 人去世了但户口没注销 |
本文档基于 Linux 内核和通用操作系统原理编写。
openEuler 是由开放原子开源基金会孵化的全场景开源操作系统项目,面向数字基础设施四大核心场景(服务器、云计算、边缘计算、嵌入式),全面支持 ARM、x86、RISC-V、loongArch、PowerPC、SW-64 等多样性计算架构
更多推荐
所有评论(0)