操作系统是怎么进行进程管理的?
如果问到进程管理,就从进程通信,同步和死锁三大块来说!
Linux 系统工具
Linux 提供了丰富的命令行工具来查看和监控进程的状态、资源使用、内存布局等信息。这些工具对于系统管理、性能调优、故障排查和安全审计至关重要。
以下是常用的查看进程的工具,按功能分类介绍:
一、基础进程查看工具
1. ps(Process Status)
功能:显示当前进程的静态快照。
常用命令:
# 显示当前终端的所有进程
ps
# 显示所有进程(最常用)
ps aux
# a: 所有终端的进程
# u: 以用户友好的格式显示
# x: 包括没有控制终端的进程
# 显示进程树结构
ps axjf
# 显示进程的完整命令行
ps auxww
# 按 CPU 使用率排序
ps aux --sort=-%cpu | head -10
# 按内存使用率排序
ps aux --sort=-%mem | head -10
# 查看某个进程的详细信息(如 PID 1234)
ps -p 1234 -l输出字段解释:
USER:运行进程的用户PID:进程 ID%CPU:CPU 使用率%MEM:内存使用率VSZ:虚拟内存大小(KB)RSS:物理内存使用量(KB)TTY:终端STAT:进程状态(见下文)START:启动时间TIME:累计 CPU 时间COMMAND:命令行
进程状态(STAT):
R:运行中(Running)S:睡眠(Sleeping,可中断)D:不可中断睡眠(通常在等待 I/O)T:停止(Stopped,如被信号暂停)Z:僵尸进程(Zombie)<:高优先级N:低优先级+:前台进程组l:多线程进程
2. top / htop
功能:动态实时查看进程状态。
top- 按
P:按 CPU 排序 - 按
M:按内存排序 - 按
k:发送信号(如 kill 进程) - 按
r:重新设置进程优先级(renice) - 按
q:退出
htop(更友好的 top 替代品):
htop- 彩色界面
- 支持鼠标操作
- 显示进程树
- 可垂直/水平滚动查看更多信息
- 安装:
sudo apt install htop(Debian/Ubuntu)或sudo yum install htop(CentOS/RHEL)
二、进程关系与树形结构
1. pstree
功能:以树形结构显示进程及其父子关系。
pstree
pstree -p # 显示 PID
pstree -p -s <PID> # 显示某个进程的祖先链示例输出:
systemd─┬─NetworkManager───2*[{NetworkManager}]
├─sshd───sshd───bash───pstree
├─cron
└─syslogd
三、内存与地址空间查看
1. /proc/<PID>/maps
功能:查看进程的内存映射布局。
cat /proc/1234/maps
# 或使用 pmap
pmap 1234
pmap -x 1234 # 详细信息输出示例:
00400000-00401000 r-xp 00000000 08:01 123456 /bin/bash
7fff888a9000-7fff888c9000 rw-p 00000000 00:00 0 [stack]
字段:
- 地址范围
- 权限(rwxp)
- 偏移
- 设备
- inode
- 路径或区域名称(如
[heap],[stack],[vdso])
2. /proc/<PID>/status
功能:查看进程的详细状态信息。
cat /proc/1234/status包含信息:
Name:进程名State:状态(R, S, D, Z, T)Tgid:线程组 IDPid:PIDPPid:父进程 PIDUid,Gid:用户/组 IDVmSize:虚拟内存大小VmRSS:物理内存大小Threads:线程数SigPnd,SigBlk:信号信息
3. /proc/<PID>/stat 和 /proc/<PID>/statm
功能:机器可读的进程统计信息。
cat /proc/1234/stat # 一行,52 个字段
cat /proc/1234/statm # 内存使用(页为单位)四、资源使用监控
1. pidstat(来自 sysstat 包)
功能:按进程统计 CPU、内存、I/O、上下文切换等。
# 每 2 秒报告一次 CPU 使用
pidstat -u 2
# 查看内存使用
pidstat -r 2
# 查看 I/O
pidstat -d 2
# 查看特定 PID
pidstat -u -p 1234 22. iotop
功能:实时查看进程的 I/O 使用情况。
sudo iotop- 类似
top,但显示磁盘 I/O - 需要 root 权限
3. lsof(List Open Files)
功能:列出进程打开的文件(包括网络连接、设备等)。
# 查看某个进程打开的文件
lsof -p 1234
# 查看某个文件被哪些进程使用
lsof /path/to/file
# 查看某个端口的网络连接
lsof -i :80
# 查看所有网络连接
lsof -i五、高级调试与分析工具
1. strace
功能:跟踪进程的系统调用和信号。
# 跟踪某个进程的所有系统调用
strace -p 1234
# 跟踪一个命令的执行
strace ls /tmp
# 记录到文件
strace -o trace.log -p 1234用途:
- 调试程序卡住(看在哪个系统调用阻塞)
- 理解程序行为
- 排查文件/网络访问问题
2. gdb(GNU Debugger)
功能:强大的调试器,可 attach 到运行中的进程。
gdb -p 1234
(gdb) bt # 查看调用栈
(gdb) info threads # 查看线程
(gdb) print var # 打印变量
(gdb) continue # 继续执行3. perf
功能:性能分析工具,可进行 CPU 采样、调用图分析等。
# 记录进程的 CPU 使用
perf record -p 1234
# 生成报告
perf report六、其他实用工具
| 工具 | 功能 |
|---|---|
w | 显示登录用户及其运行的进程 |
who | 显示登录用户 |
pgrep / pkill | 通过名称查找或发送信号给进程 |
kill / killall | 发送信号终止进程 |
nice / renice | 调整进程优先级 |
time | 测量命令执行时间 |
七、总结:常用场景推荐工具
| 场景 | 推荐工具 |
|---|---|
| 快速查看所有进程 | ps aux |
| 实时监控资源使用 | top 或 htop |
| 查看进程树 | pstree |
| 查看内存映射 | pmap <PID> 或 cat /proc/<PID>/maps |
| 查看打开的文件/网络 | lsof -p <PID> |
| 调试系统调用 | strace -p <PID> |
| 分析 CPU 性能 | perf |
| 查看 I/O 使用 | iotop |
| 查看详细状态 | cat /proc/<PID>/status |
进程、线程和协程的区别和联系
一、基本定义
| 概念 | 定义 |
|---|---|
| 进程 | 进程是操作系统进行资源分配和调度的基本单位,是程序的一次执行过程。每个进程都拥有独立的内存空间(代码段、数据段、堆、栈等)和系统资源。 |
| 线程 | 线程是操作系统调度的基本单位,是进程内的一个执行流。一个进程可以包含多个线程,这些线程共享进程的资源(如内存、文件描述符等),但拥有独立的执行栈和寄存器状态。 |
| 协程 | 协程是一种用户态的轻量级线程,由程序员在代码中控制其调度(协作式多任务)。协程在单线程内通过“暂停”和“恢复”实现并发,无需操作系统介入。 |
二、详细对比
| 维度 | 进程 | 线程 | 协程 |
|---|---|---|---|
| 1. 切换情况 | 进程间切换(如从进程 A 切换到进程 B)进程 CPU 环境 (栈、寄存器、页表和文件句柄等) 的保存以及新调度的进程 CPU 环境的设置 | 线程间切换(同一进程内的不同线程)保存和设置程序计数器、少量寄存器和栈的内容 | 协程间切换(在同一个线程内)先将寄存器上下文和栈保存,等切换回来的时候再进行恢复 |
| 2. 切换者 | 操作系统内核(Kernel) | 操作系统内核(Kernel) | 用户程序(User Code / 协程调度器) |
| 3. 切换过程 | 需要保存和恢复整个进程的上下文(包括页表、文件描述符、内存映射等),涉及用户态到内核态的切换,开销大。用户态 →内核态 →用户态 | 仅需保存和恢复线程的上下文(寄存器、栈指针等),共享进程资源,开销较小但仍需内核参与。用户态 →内核态 →用户态 | 完全在用户态完成,只需保存/恢复少量寄存器和栈信息,由 yield 或 await 显式触发,开销极小。用户态 (没有陷入内核) |
| 4. 调用栈 | 每个进程有独立的栈空间(通常较大,MB 级)内核栈 | 每个线程有独立的调用栈(大小可配置,通常几百 KB)内核栈 | 每个协程有独立的调用栈(非常轻量,通常动态分配,KB 级甚至更小)用户栈 |
| 5. 拥有资源 | 拥有独立的地址空间、文件句柄、信号量、环境变量等几乎所有资源。 | 共享所属进程的资源(如内存、文件描述符),但拥有自己的栈、寄存器、程序计数器等执行上下文。 | 不拥有系统资源,完全依赖所在线程的资源。资源由程序员在用户态管理。拥有自己的寄存器上下文和栈。 |
| 6. 并发性 | 多进程可以实现真正的并行(多核 CPU 下),但创建/通信成本高。 | 多线程可并发执行(在单核上为时间片轮转,在多核上可并行),但存在线程安全问题(如竞态条件)。 | 协程是并发而非并行(除非配合多线程),但能高效处理大量 I/O 密集型任务,实现高并发。同一时间只能执行一个协程,而其他协程处于休眠状态,适合对任务进行分时处理 |
| 7. 系统开销 | 开销最大:创建、销毁、切换都需要系统调用,涉及内存映射、权限检查等。切换虚拟地址空间,切换内核栈和硬件上下文,CPU 高速缓存失效、页表切换 | 开销中等:比进程轻,但仍需内核调度和上下文切换。切换时只需保存和设置少量寄存器内容 | 开销最小:完全在用户态,无需系统调用,切换极快。直接操作栈则基本没有内核切换的开销,可以不加锁的访问全局变量 |
| 8. 通信方面 | 进程间通信(IPC)机制复杂:管道、消息队列、共享内存、信号、Socket 等。 | 线程间通信简单:通过共享内存直接读写变量,但需同步机制(互斥锁、条件变量等)避免竞争。 | 协程间通信通常通过通道(Channel) 或 共享变量 + 调度协调 实现(如 Go 的 channel、Python 的 asyncio.Queue),强调非阻塞和协作。共享内存、消息队列 |
三、总结对比表(简洁版)
| 特性 | 进程 | 线程 | 协程 |
|---|---|---|---|
| 所属层级 | 操作系统资源单位 | 进程内的执行单位 | 用户态执行单元 |
| 切换开销 | 最大 | 中等 | 最小 |
| 并行能力 | 支持(多核) | 支持(多核) | 通常不支持(除非多线程 + 协程) |
| 并发能力 | 低(受限于数量) | 中等 | 极高(可创建成千上万个) |
| 调度方式 | 抢占式(操作系统) | 抢占式(操作系统) | 协作式(用户控制) |
| 典型应用场景 | 服务隔离、安全沙箱、独立应用 | 多任务处理、GUI 响应、服务器并发 | 高并发 I/O、Web 服务、爬虫、异步编程 |
五、典型语言支持
- 进程:几乎所有语言都支持(C/Python/Java 等通过
fork或multiprocessing) - 线程:Java(Thread)、Python(threading)、C++(std::thread)等
- 协程:
- Python:
async/await+asyncio - Go:
goroutine(虽称 goroutine,实为协程) - Kotlin:
coroutine - JavaScript:
async/await - C#:
async/await
- Python:
六、选择建议
- CPU 密集型任务 → 使用多进程(充分利用多核)
- I/O 密集型任务 → 使用多线程或协程(协程更高效)
- 高并发网络服务 → 推荐使用协程(如 Go、Python asyncio)
- 需要强隔离性 → 使用多进程
如何选择
在选择使用多进程(Multiprocessing)还是多线程(Multithreading)时,主要取决于任务的性质、编程语言的特性、系统资源以及性能需求。以下是两者的适用场景和关键区别:
一、多线程(Multithreading)适用于
✅ I/O 密集型任务(I/O-bound)
- 典型场景:文件读写、网络请求、数据库操作、用户输入/输出等。
- 原因:线程在等待 I/O 操作完成时会进入阻塞状态,此时 CPU 可以切换到其他线程执行,从而提高整体效率。
- 优势:线程间共享内存,通信简单,创建和切换开销小。
✅ 需要频繁通信或共享数据的任务
- 多个线程可以共享同一进程的内存空间,变量、对象可以直接访问,适合需要频繁数据交换的场景。
✅ GUI 应用程序
- 例如:保持界面响应的同时执行后台任务(如下载、计算),通常用一个主线程处理 UI,其他线程处理耗时操作。
二、多进程(Multiprocessing)适用于
✅ CPU 密集型任务(CPU-bound)
- 典型场景:图像处理、科学计算、加密解密、大规模数据处理等。
- 原因:每个进程有独立的 Python 解释器和内存空间,可以绕过 GIL,真正实现并行计算,充分利用多核 CPU。
✅ 需要高稳定性和容错性的系统
- 进程之间相互隔离,一个进程崩溃不会直接影响其他进程,适合构建健壮的服务器或后台服务。
✅ 需要利用多核并行计算能力
- 多进程可以将任务分配到多个 CPU 核心上并行执行,显著提升性能。
⚠️ 缺点
- 进程间通信(IPC)复杂(需使用 Queue、Pipe、共享内存等)。
- 创建和切换开销比线程大。
- 内存占用更高(每个进程有独立的内存空间)。
三、如何选择?总结对比表
| 特性 | 多线程 | 多进程 |
|---|---|---|
| 适用任务类型 | I/O 密集型 | CPU 密集型 |
| 并行能力(Python) | 受 GIL 限制,不能并行执行 Python 代码 | 可真正并行,绕过 GIL |
| 内存共享 | 共享内存,通信方便 | 独立内存,需 IPC 机制 |
| 开销 | 小(轻量) | 大(重量级) |
| 稳定性 | 一个线程崩溃可能影响整个进程 | 进程隔离,更稳定 |
| 调试难度 | 相对容易 | 较复杂(尤其涉及 IPC) |
资源共享
一、一个线程 独享 的资源(私有资源)
这些资源是每个线程独立拥有的,不与其他线程共享:
| 资源 | 说明 |
|---|---|
| 1. 线程栈(Thread Stack) | 存放局部变量、函数调用信息(返回地址、参数等) 默认大小:Linux 8MB,Java 1MB(可调) |
| 2. 程序计数器(PC / Instruction Pointer) | 指向下一条要执行的指令地址 |
| 3. 寄存器状态(Registers) | 包括通用寄存器、栈指针(SP)、状态寄存器等,保存当前执行上下文 |
| 4. 线程本地存储(TLS, Thread Local Storage) | 每个线程独有的数据,如 C 的 __thread、Java 的 ThreadLocal |
| 5. 信号掩码(Signal Mask) | 控制该线程接收哪些信号 |
| 6. 调度优先级和策略 | 每个线程可有不同的调度优先级(如 SCHED_FIFO、SCHED_RR) |
| 7. 错误码(errno)副本 | 在多线程环境中,errno 是线程局部的(由 libc 实现为 TLS) |
✅ 这些是线程调度和独立执行的基础。
二、同一线程内 共享 的资源(其实是进程级资源)
同一个进程内的所有线程共享以下资源:
| 资源 | 说明 |
|---|---|
| 1. 地址空间(Address Space) | 包括代码段(.text)、数据段(.data、.bss)、堆(Heap)、内存映射区(mmap) |
| 2. 堆内存(Heap) | malloc / new 分配的内存,所有线程可访问(需同步) |
| 3. 文件描述符(File Descriptors) | 打开的文件、Socket、管道等,一个线程打开的文件,其他线程也可读写 |
| 4. 信号处理程序(Signal Handlers) | 信号处理函数是进程级的,但信号可发送给特定线程 |
| 5. 进程 ID(PID) | 所有线程共享同一个 PID(但每个线程有自己的 TID) |
| 6. 用户和组 ID(UID/GID) | 权限控制基于进程 |
| 7. 工作目录(Current Working Directory) | 所有线程共享 |
| 8. 环境变量(Environment Variables) | envp 指针指向同一块内存 |
| 9. 进程间通信资源 | 如 System V IPC(消息队列、信号量、共享内存)、POSIX 信号量等 |
⚠️ 共享意味着高效,但也带来竞态条件(Race Condition),必须使用互斥锁、条件变量等同步机制。
三、一个进程 独享 的资源(进程隔离的基础)
这些资源是进程私有的,不与其他进程共享(除非显式共享):
| 资源 | 说明 |
|---|---|
| 1. 虚拟地址空间(Virtual Memory Space) | 每个进程有独立的页表(Page Table),通过 MMU 隔离 一个进程不能直接访问另一个进程的内存 |
| 2. 页表(Page Table) | CPU 的 CR3 寄存器指向当前进程的页表基地址 |
| 3. 进程控制块(PCB / task_struct in Linux) | 内核中描述进程的数据结构,包含状态、优先级、资源列表等 |
| 4. 打开文件列表(file descriptor table) | 虽然线程共享 fd,但不同进程的 fd 表是独立的 |
| 5. 信号屏蔽字(Signal Mask) | 进程级信号屏蔽(线程可有自己的屏蔽) |
| 6. 资源限制(rlimit) | 如最大打开文件数、CPU 时间、内存使用上限等 |
| 7. 进程树关系(Parent/Child) | 每个进程有唯一的父进程(init 或 systemd 除外) |
| 8. 环境变量副本 | 虽然 fork() 会复制,但之后各自独立修改 |
✅ 这些独享资源实现了进程间的隔离性、安全性和稳定性。
四、不同进程 可共享 的资源(通过 IPC 机制)
虽然进程默认隔离,但操作系统提供了多种进程间通信(IPC) 机制来显式共享资源:
| 共享资源 | 实现机制 | 说明 |
|---|---|---|
| 1. 共享内存(Shared Memory) | shmget / mmap(匿名或文件映射) | 最快的 IPC 方式,多个进程映射同一块物理内存 |
| 2. 文件 | 普通文件、临时文件 | 多个进程可读写同一个文件(需文件锁同步) |
| 3. 消息队列(Message Queue) | System V 或 POSIX 消息队列 | 进程间传递结构化消息 |
| 4. 信号量(Semaphore) | 用于进程间同步,控制对共享资源的访问 | |
| 5. 套接字(Socket) | 本地 Socket(Unix Domain Socket)或网络 Socket | 进程间通信,支持双向数据流 |
| 6. 管道(Pipe) | 匿名管道(父子进程)、命名管道(FIFO) | 单向数据流 |
| 7. 环境变量(间接) | fork() 后继承环境变量,但之后独立 | |
| 8. 内存映射文件(mmap) | 多个进程映射同一文件到内存,实现共享 |
🔁 共享是“可选的”,必须通过系统调用显式创建和连接。
五、总结对比表(清晰版)
| 资源类型 | 线程独享 | 线程共享(同进程) | 进程独享 | 进程间可共享(IPC) |
|---|---|---|---|---|
| 栈(Stack) | ✅ | ❌ | ✅(每个进程至少一个) | ❌ |
| 堆(Heap) | ❌ | ✅ | ✅(独立堆空间) | ✅(通过共享内存) |
| 代码段 | ❌ | ✅ | ✅(独立地址空间) | ✅(动态库、mmap) |
| 全局变量 | ❌ | ✅ | ✅(独立数据段) | ✅(共享内存) |
| 寄存器/PC | ✅ | ❌ | ✅(每个线程有) | ❌ |
| 文件描述符 | ❌ | ✅ | ✅(独立表) | ✅(通过继承或传递) |
| 地址空间 | ❌ | ✅ | ✅ | ❌(除非共享内存) |
| 页表 | ❌ | ✅ | ✅ | ❌ |
| 信号处理函数 | ❌ | ✅ | ✅ | ❌(但可发送信号) |
| 进程 ID(PID) | ❌ | ✅ | ✅ | ❌ |
| 线程 ID(TID) | ✅ | ❌ | ✅(每个线程唯一) | ❌ |
| 环境变量 | ❌ | ✅ | ✅(独立副本) | ✅(通过继承) |
| 工作目录 | ❌ | ✅ | ✅ | ❌ |
| 信号量 / 锁 | ❌ | ✅(线程锁) | ✅(进程私有) | ✅(进程间锁) |
TODO ?
- 调度:线程是调度的基本单位(PC,状态码,通用寄存器,线程栈及栈指针);进程是拥有资源的基本单位(打开文件,堆,静态区,代码段等)。
- 同一线程共享的有堆、全局变量、静态变量、指针,引用、文件等,而独自占有栈
- 拥有资源:线程不拥有系统资源,但一个进程的多个线程可以共享隶属进程的资源;进程是拥有资源的独立单位。
- 系统开销:线程创建销毁只需要处理 PC 值,状态码,通用寄存器值,线程栈及栈指针即可;进程创建和销毁需要重新分配及销毁 task_struct 结构。
七、编程实践提示
- ✅ 多线程编程时,全局变量要加锁(或使用原子操作)
- ✅ 避免栈内存逃逸(不要返回局部变量地址)
- ✅ 进程间通信优先使用 Socket 或共享内存,避免频繁读写文件
- ✅ 使用
pthread_create时注意栈大小限制 - ✅ 高并发场景考虑 协程(如 Go、asyncio),避免线程爆炸
多进程/多线程的同步与通信
一、多线程同步与通信(Multi-threading)
- 多个线程属于同一个进程
- 共享同一地址空间(堆、全局变量、代码段)
- 有独立的栈和寄存器
✅ 同步机制(Synchronization)
用于协调线程对共享资源的访问,避免竞态条件(race condition)。
| 机制 | 说明 |
|---|---|
| 互斥锁(Mutex) | 最常用,确保同一时间只有一个线程访问临界区 |
| 读写锁(RWLock) | 读多写少场景,允许多个读线程并发 |
| 条件变量(Condition Variable) | 配合 mutex 使用,实现线程等待/通知 |
| 自旋锁(Spinlock) | 忙等待,适合短临界区 |
| 屏障(Barrier) | 多个线程在某点同步 |
| 信号量(Semaphore) | 计数信号量,控制资源访问数量 |
| 原子操作(Atomic) | 无锁编程基础,如 std::atomic |
✅ 通信机制(Communication)
由于线程共享内存,通信非常直接:
| 方式 | 说明 |
|---|---|
| 共享内存(直接) | 线程直接读写同一块堆内存或全局变量 |
| 消息队列(线程安全) | 使用无锁队列或加锁队列传递消息 |
| 条件变量通知 | 一个线程设置标志 + pthread_cond_signal() 通知另一个线程 |
| 事件(Event) | 自定义标志 + 条件变量实现 |
📌 特点:
- 通信成本低(内存访问)
- 同步是主要挑战(需防止数据竞争)
二、多进程同步与通信(Multi-processing)
- 多个进程有独立的地址空间
- 不共享内存(除非显式映射)
- 通过操作系统进行隔离
✅ 通信机制(IPC, Inter-Process Communication)
由于内存不共享,必须通过内核提供的 IPC 机制交换数据。
| 机制 | 说明 |
|---|---|
| 管道(Pipe) | 半双工,父子进程间通信 |
| 命名管道(FIFO) | 有名字的管道,无关进程也可通信 |
| 消息队列(Message Queue) | 存储消息的内核队列,支持优先级 |
| 共享内存(Shared Memory) | 最快的 IPC,多个进程映射同一块物理内存 |
| 信号(Signal) | 异步通知,如 SIGTERM、SIGKILL |
| 套接字(Socket) | 不仅可用于网络,也可用于本地进程通信(Unix Domain Socket) |
| 文件 | 通过读写文件交换数据(慢,但简单) |
✅ 同步机制(Synchronization)
当多个进程访问共享资源(如共享内存、文件)时,需要同步。
| 机制 | 说明 |
|---|---|
| POSIX 信号量(sem_t) | 支持进程间同步,可用于保护共享内存 |
| System V 信号量 | 老式 IPC,功能强大但复杂 |
| 文件锁(flock, fcntl) | 对文件加锁,防止多个进程同时写 |
| 互斥锁(pthread_mutex_t)在共享内存中 | 将 mutex 放在 mmap 的共享内存中,并设置 PTHREAD_PROCESS_SHARED |
| 记录锁(Record Locking) | 对文件的某一段加锁 |
📌 关键点:
- 共享内存 + 信号量/互斥锁 是高性能进程间同步的常见组合。
- 同步通常用于保护共享内存区域。
三、对比总结表
| 特性 | 多线程 | 多进程 |
|---|---|---|
| 内存模型 | 共享地址空间 | 独立地址空间 |
| 通信方式 | 直接共享内存、消息队列 | 管道、FIFO、消息队列、共享内存、Socket |
| 同步机制 | Mutex、Cond、RWLock、Barrier | 信号量、文件锁、共享内存中的互斥锁 |
| 通信开销 | 低(内存访问) | 较高(系统调用) |
| 启动开销 | 低(pthread_create) | 高(fork + exec) |
| 容错性 | 一个线程崩溃,整个进程崩溃 | 进程间隔离,一个崩溃不影响其他 |
| 安全性 | 较低(共享内存) | 较高(隔离) |
| 典型场景 | 高性能计算、服务器内部并发 | 服务间通信、浏览器多进程、安全沙箱 |
🔚 总结
- 多线程:共享内存 → 同步为主,通信为辅
- 多进程:独立内存 → 通信为主,同步为辅(用于保护共享资源)
进程间通信
进程默认是相互隔离的(拥有独立的地址空间),但现代操作系统提供了多种机制让进程之间能够安全、高效地交换数据和同步操作,即进程间通信(Inter-Process Communication, IPC) 。
- 功能分离:不同进程负责不同任务(如 Web 服务器与数据库)
- 安全隔离:关键服务(如浏览器渲染进程)与主进程隔离
- 并行处理:多个进程协同完成复杂任务
- 资源共享:多个进程访问同一数据或设备
主要的进程间通信方式(7 种核心机制)
1. 管道 (Pipe):基于内核缓冲区的 “单向字节流” 通信
原理
实际结构/组成:
- 内核缓冲区:一个由操作系统内核维护的环形缓冲区(FIFO 队列)。这是数据实际存储的地方。
- 文件描述符 (File Descriptors):创建管道时,会返回两个文件描述符:
fd[0]:用于读取数据。fd[1]:用于写入数据。
- 匿名性:没有文件系统中的名称,仅通过文件描述符在相关进程间传递。
Pipe
进程间通信的本质是:写进程通过写端 FD 把数据写入内核缓冲区,读进程通过读端 FD 从内核缓冲区读取数据—— 内核缓冲区充当 “中间快递站”,且数据只能单向流动(半双工)。
生命周期:
- 创建:通过
pipe()系统调用创建。 - 存在:只要至少有一个进程持有读或写文件描述符,管道就存在。
- 销毁:当所有持有该管道文件描述符的进程都关闭了它们的描述符(调用
close())后,内核会自动回收该管道的资源。
使用方式:
- 通常在
fork()创建子进程之前调用pipe()。 - 父子进程通过继承的文件描述符进行通信。
- 一个进程关闭其不需要的端(如子进程关闭写端
fd[1],父进程关闭读端fd[0]),实现单向通信。 - 使用标准的
read()和write()系统调用进行数据读写。
特点:
- 半双工 (Half-duplex):数据只能单向流动(从写端到读端)。
- 亲缘关系限制:通常只用于具有亲缘关系的进程(父子、兄弟进程),因为文件描述符需要通过
fork()继承。 - 简单高效:实现简单,内核开销相对较小。
- 字节流:传输的是无结构的字节流。
注意事项:
- 避免死锁:确保通信双方正确关闭不需要的文件描述符。如果写端不关闭,即使没有数据写入,读端的
read()调用也可能不会返回 0(表示 EOF)。 - 阻塞/非阻塞:默认情况下,读写操作是阻塞的(读空时阻塞,写满时阻塞)。可通过
fcntl()设置为非阻塞模式。 - 容量限制:管道有内核设定的固定大小(通常是 64KB),写满后写操作会阻塞。
例子
假设用户在终端执行 ls | grep .txt(ls 列出文件,grep 筛选含 .txt 的结果):
- shell(命令行解释器)创建一个管道,在内核中分配 1KB 大小的缓冲区,生成读端 FD=3、写端 FD=4;
- shell 通过
fork()创建子进程 1(运行ls):关闭子进程 1 的读端 FD=3,仅保留写端 FD=4; - shell 再创建子进程 2(运行
grep):关闭子进程 2 的写端 FD=4,仅保留读端 FD=3; - 子进程 1(
ls)执行时,将 “文件列表” 通过 FD=4 写入内核缓冲区; - 子进程 2(
grep)通过 FD=3 从内核缓冲区读取数据,筛选后输出结果。 整个过程中,ls和grep从未直接交互,所有数据都通过内核缓冲区中转。
2. 命名管道 (Named Pipe / FIFO):基于文件系统标识的 “持久化管道”
原理
实际结构/组成:
- 内核缓冲区:与匿名管道相同,是一个内核维护的环形缓冲区。
- 文件系统节点:在文件系统中创建一个特殊的文件(设备文件类型为
p),通过路径名(如/tmp/myfifo)标识。这个节点是访问管道的入口。 - 文件描述符:进程通过
open()系统调用打开这个特殊文件来获取读/写文件描述符。
生命周期:
- 创建:通过
mkfifo()或mknod()系统调用在文件系统中创建特殊文件。 - 存在:特殊文件存在于文件系统中,直到被显式删除。
- 销毁:
- 特殊文件:通过
unlink()系统调用或rm命令删除。 - 内核缓冲区:当所有打开该 FIFO 的进程都关闭了文件描述符后,内核回收缓冲区。
- 特殊文件:通过
命名管道与匿名管道的内核缓冲区逻辑完全一致,唯一区别是:匿名管道没有文件系统中的 “实体标识”,只能通过 fork() 继承文件描述符(仅限父子 / 兄弟进程);而命名管道会在文件系统中创建一个特殊的 “管道文件”(用 mkfifo 命令创建,类型为 p)。
FIFO
通信原理:任意进程只要知道该 “管道文件” 的路径,就能通过
open()系统调用获取其读 / 写端 FD,后续通过内核缓冲区读写数据 —— 本质是用 “文件系统路径” 替代 “进程继承”,实现无亲缘关系进程的通信。
使用方式:
- 一个进程调用
mkfifo()创建 FIFO。 - 通信双方(可以是任意进程,无需亲缘关系)通过
open("/path/to/fifo", O_RDONLY/O_WRONLY/O_RDWR)打开它。 - 使用
read()和write()进行通信。 - 使用完毕后,进程调用
close(),创建者或管理员调用unlink()删除特殊文件。
特点:
- 突破亲缘限制:允许任意两个进程通信,只要它们知道 FIFO 的路径名并有访问权限。
- 半双工:通常也用于单向通信,虽然可以
O_RDWR打开,但行为复杂,一般不推荐。 - 持久性:特殊文件在文件系统中是持久的(直到被删除)。
- 字节流:传输无结构字节流。
注意事项:
- 权限管理:创建 FIFO 时需设置合适的权限(
mode参数),确保目标进程有读/写权限。 - 阻塞行为:
open()行为特殊:- 以
O_RDONLY打开:如果此时没有进程以写方式打开,open()会阻塞,直到有写方出现。 - 以
O_WRONLY打开:如果此时没有进程以读方式打开,open()会阻塞,直到有读方出现。 - 可使用
O_NONBLOCK标志使open()非阻塞。
- 以
- 清理:程序应负责在适当时候删除 FIFO 文件,避免文件系统残留。
例子
假设需要让 “日志收集进程 A” 和 “应用服务进程 B” 通信(A 收集 B 的日志):
- 管理员先执行
mkfifo /tmp/log_fifo,在/tmp目录下创建命名管道文件log_fifo(内核自动为其分配缓冲区); - 进程 A(日志收集)调用
open("/tmp/log_fifo", O_RDONLY),获取该管道的读端 FD; - 进程 B(应用服务)调用
open("/tmp/log_fifo", O_WRONLY),获取该管道的写端 FD; - 进程 B 每产生一条日志,就通过写端 FD 写入内核缓冲区;
- 进程 A 通过读端 FD 从内核缓冲区读取日志,写入本地日志文件。
即使 A 和 B 是不同用户启动、无任何亲缘关系的进程,只要能访问
/tmp/log_fifo路径,就能通过该管道通信。
3. 消息队列 (Message Queue):基于内核 “消息链表” 的 “结构化数据” 通信
原理
实际结构/组成:
- 内核链表:内核中维护的一个或多个链表结构。每个节点代表一个消息。
- 消息结构:每个消息包含:
- 消息类型 (msg_type):一个长整型,用于消息分类和选择性接收。
- 消息正文 (msg_text):用户定义的数据,有最大长度限制(由
msg_qbytes控制)。
- 标识符 (ID):通过
msgget()获取一个队列 ID(类似文件描述符),用于后续操作。
Message Queue
消息队列是内核中维护的一个双向链表,链表的每个节点是一个 “消息块”(包含:消息类型、消息长度、消息数据)。操作系统为每个消息队列分配一个唯一的 “队列 ID”,进程通过 ID 访问队列。
通信原理:
- 发送进程通过
msgsnd()系统调用,指定 “队列 ID、消息类型、数据”,内核将这些信息封装成消息块,插入链表尾部; - 接收进程通过
msgrcv()系统调用,指定 “队列 ID、目标消息类型”(可按类型筛选消息),内核从链表中找到匹配类型的消息块,将数据拷贝到接收进程的用户空间,并删除该消息块; - 若队列空(接收时)或满(发送时),进程会阻塞(或返回错误),直到队列有数据 / 空闲空间。 相比管道的 “字节流”,消息队列通过 “消息类型” 实现了数据结构化和按类型读取,避免了管道的 “粘包” 问题。
生命周期:
- 创建/获取:通过
msgget(key, flags)创建新队列或获取已有队列的 ID。key通常由ftok()生成。 - 存在:队列存在于内核中,与创建它的进程无关。
- 销毁:通过
msgctl(msqid, IPC_RMID, ...)系统调用显式删除。否则,队列会一直存在,直到系统重启(除非设置了IPC_PRIVATE且所有进程都退出)。
使用方式:
- 进程通过
msgget()获取队列 ID。 - 发送方调用
msgsnd(msqid, &msg, msg_len, flags)发送消息。 - 接收方调用
msgrcv(msqid, &msg, max_len, msg_type, flags)接收消息(可指定接收特定类型的消息)。 - 使用
msgctl()进行控制(如删除、获取状态)。
特点:
- 有结构消息:传输的是有类型的消息,不是字节流。
- 异步通信:发送方无需等待接收方就绪,消息暂存于内核队列。
- 多进程通信:多个进程可向同一队列发送或从同一队列接收。
- 选择性接收:接收方可以按消息类型接收,实现灵活的通信模式。
- 无亲缘限制。
注意事项:
- 资源限制:受内核参数限制(如最大消息数
msg_qnum、最大消息大小msg_max、队列总字节数msg_qbytes)。 - 必须清理:必须显式调用
msgctl(..., IPC_RMID, ...)删除队列,否则会永久占用内核资源(” 孤儿 ” 队列)。 - 阻塞/非阻塞:
msgsnd()和msgrcv()可以设置为阻塞或非阻塞(IPC_NOWAIT)。 - 性能:消息在内核和用户空间之间拷贝,且涉及内核链表操作,性能低于共享内存。
例子
假设 “订单系统进程 C” 和 “库存系统进程 D” 通信(C 发送订单请求,D 返回库存状态):
- 进程 C 先通过
msgget()创建消息队列,获取队列 ID=100; - 进程 C 构造 “订单消息”:类型 = 1(表示 “订单请求”)、数据 =“商品 ID:123, 数量:5”,通过
msgsnd(100, 消息)写入队列; - 进程 D 通过
msgget(100)获取队列 ID,调用msgrcv(100, 1)(读取类型 1 的消息),从队列中拿到订单数据; - 进程 D 计算库存后,构造 “库存消息”:类型 = 2(表示 “库存响应”)、数据 =“库存充足”,通过
msgsnd(100, 消息)写入队列; - 进程 C 调用
msgrcv(100, 2),读取类型 2 的响应消息,完成一次通信。 通过 “消息类型”,C 和 D 能在同一个队列中区分 “请求” 和 “响应”,无需创建多个管道。
4. 信号量 (Semaphore):基于内核 “计数器” 的 “同步互斥” 工具
原理
实际结构/组成:
- 内核计数器:一个或一组由内核维护的整型计数器。
- 操作集合:主要操作是
P(wait, decrement) 和V(signal, increment)。这些操作是原子的。 - 标识符 (ID):通过
semget()获取信号量集 ID。 - 类型:
- System V 信号量:更复杂,支持信号量集。
- POSIX 信号量:更简单,分为命名 (
sem_open) 和未命名 (sem_init) 两种。
Semaphore
信号量本质是内核维护的一个整数计数器,用于协调多个进程对 “共享资源”(如共享内存、打印机)的访问,避免 “竞态条件”(多个进程同时修改共享数据导致错误)。核心操作是两个原子操作(不可中断):
- P 操作(Wait):若计数器 > 0,则计数器 - 1;若计数器 = 0,则进程阻塞,直到计数器 > 0;
- V 操作(Signal):计数器 + 1,若有进程因 P 操作阻塞,则唤醒一个。 通信原理(以 “互斥” 为例):将信号量初始化为 1(表示 “资源仅允许 1 个进程访问”),进程访问共享资源前执行 P 操作(抢占资源),访问后执行 V 操作(释放资源)—— 本质是通过内核计数器的原子操作,实现 “同一时间仅一个进程进入临界区(操作共享资源)”。
生命周期:
- 创建/获取:
semget()(SysV) 或sem_open()/sem_init()(POSIX)。 - 存在:存在于内核中(SysV, POSIX 命名)或进程/线程间共享内存中(POSIX 未命名)。
- 销毁:
- SysV:
semctl(..., IPC_RMID, ...) - POSIX 命名:
sem_unlink() - POSIX 未命名:
sem_destroy()
- SysV:
使用方式:
- 主要用于同步,而非数据传输。
- 进程通过 ID 访问信号量。
- 调用
semop()(SysV) 或sem_wait()/sem_post()(POSIX) 执行P/V操作。 - 通常与共享内存等机制配合使用,保护对共享资源的访问。
特点:
- 同步原语:核心作用是进程/线程同步和互斥。
- 原子操作:
P/V操作是不可分割的。 - 计数器:值可大于 1,可用于管理资源池(如数据库连接)。
注意事项:
- 死锁风险:不当使用(如等待顺序错误)极易导致死锁。
- 必须初始化:信号量必须先初始化(
semctl(..., SETVAL, ...)或sem_init())。 - 必须清理:必须显式删除(SysV/POSIX 命名)或销毁(POSIX 未命名)。
- 不是锁:虽然常用于互斥,但信号量本身不保证所有权,需编程约定。
例子
进程 E 和 F 通过共享内存传递视频帧,需确保 “E 写完一帧后,F 再读;F 读完后,E 再写下一帧”(避免 E 覆盖未读的帧):
- 创建两个信号量:
sem_empty(初始值 = 1,标识 “共享内存是否为空,可写入”)、sem_full(初始值 = 0,标识 “共享内存是否有数据,可读取”); - 进程 E(写)的逻辑:
- 执行
P(sem_empty):若sem_empty=1,则减为 0(抢占 “写入权”);若为 0(F 未读完),则阻塞; - 向共享内存写入一帧数据;
- 执行
V(sem_full):sem_full从 0 变为 1,唤醒阻塞的 F;
- 执行
- 进程 F(读)的逻辑:
- 执行
P(sem_full):若sem_full=1,则减为 0(抢占 “读取权”);若为 0(E 未写完),则阻塞; - 从共享内存读取一帧数据;
- 执行
V(sem_empty):sem_empty从 0 变为 1,唤醒阻塞的 E; 通过两个信号量的 P/V 操作,实现了 E 和 F 的 “同步”—— 数据不会被覆盖,也不会读空。
- 执行
5. 共享内存 (Shared Memory):基于 “物理内存映射” 的 “零拷贝” 通信
原理
实际结构/组成:
- 物理内存页:一块由内核分配的物理内存区域。
- 虚拟地址映射:多个进程的虚拟地址空间通过页表映射到同一块物理内存页上。
- 标识符 (ID):通过
shmget()获取共享内存段 ID。 - 访问指针:进程调用
shmat()后获得一个指向该内存区域的指针,后续通过指针直接读写。
Shared Memory
共享内存是效率最高的 IPC 方式,本质是:操作系统在物理内存中开辟一块连续空间,然后将这块物理内存 “映射” 到两个进程各自的 “虚拟地址空间” 中。
通信原理(以 Linux 的 shmget/shmat 为例):
- 进程 1 通过
shmget()向内核申请一块物理内存(如 4KB),内核分配物理地址0x12340000,并返回 “共享内存 ID”; - 进程 1 通过
shmat()(attach)将物理地址0x12340000映射到自己的虚拟地址空间(如0x7fff8000); - 进程 2 通过
shmget()获取同一共享内存 ID,再通过shmat()将物理地址0x12340000映射到自己的虚拟地址空间(如0x7fff9000); - 此时,进程 1 向
0x7fff8000写入数据,本质是写入物理内存0x12340000;进程 2 读取自己的0x7fff9000,本质也是读取同一物理内存 ——数据无需在进程间拷贝(零拷贝),直接操作同一块物理内存实现通信。
生命周期:
- 创建/获取:
shmget()。 - 存在:存在于内核中,与进程无关。
- 销毁:通过
shmctl(..., IPC_RMID, ...)显式删除。否则会一直存在。
使用方式:
- 进程通过
shmget()获取 ID。 - 调用
shmat(shmid, addr, flags)将共享内存段附加 (attach) 到进程的地址空间,返回一个指针。 - 通过该指针像操作普通内存一样进行
memcpy、赋值等操作。 - 使用完毕调用
shmdt()将其分离 (detach)。 - 最后由某个进程调用
shmctl(..., IPC_RMID, ...)删除。
特点:
- 最快:IPC 方式中速度最快,因为数据直接在进程间共享,无需内核拷贝。
- 大数据量:非常适合传输大量数据。
- 无亲缘限制。
注意事项:
- 同步是必须的:共享内存本身不提供任何同步机制!多个进程同时读写会导致竞态条件 (Race Condition)。必须配合信号量、互斥锁等同步原语使用。
- 复杂性高:编程复杂,需要仔细设计同步逻辑。
- 安全风险:所有映射的进程都能直接访问内存,需注意数据安全和内存越界。
- 必须清理:必须显式删除,否则造成内核内存泄漏。
- 地址冲突:
shmat()时指定的地址可能与进程现有映射冲突。
例子
假设 “视频解码进程 E” 和 “视频渲染进程 F” 需要传递高清帧数据(每帧 1MB,实时性要求高):
- 进程 E 通过
shmget()申请 10MB 物理内存(缓存 10 帧数据),获取共享内存 ID=200; - E 将物理内存映射到自己的虚拟地址
0x7ff00000,F 将同一物理内存映射到自己的虚拟地址0x7ff10000; - E 解码一帧视频后,直接将 1MB 帧数据写入
0x7ff00000(实际写入物理内存); - F 无需等待 E “发送” 数据,直接读取
0x7ff10000就能拿到最新帧数据,立即渲染到屏幕。 相比管道(数据需 “用户态→内核态→用户态” 两次拷贝),共享内存省去了拷贝开销,满足视频实时传输的低延迟需求。
6. 信号 (Signal):基于内核中断的 “事件通知” 通信
原理
实际结构/组成:
- 内核位图/队列:内核为每个进程维护一个信号 pending 位图(记录哪些信号已到达但未处理)和一个阻塞掩码(block mask)。
- 信号处理函数:用户可为大部分信号注册自定义处理函数(
signal()或sigaction()),或选择默认/忽略行为。 - 信号编号:每个信号有一个唯一的整数编号(如
SIGINT=2,SIGKILL=9,SIGUSR1=10)。
Signal
信号是操作系统为进程设计的异步事件通知机制,本质是内核向目标进程发送一个 “整数编号”(如
SIGINT=2表示中断,SIGKILL=9表示强制终止),触发进程预设的 “信号处理函数”。
通信原理:
- 内核维护每个进程的信号掩码(记录哪些信号被屏蔽)和信号处理表(记录每个信号对应的处理动作:默认、忽略、自定义函数);
- 发送进程通过
kill()等系统调用,向内核请求 “给目标进程发送 X 号信号”; - 内核在目标进程的 “上下文切换时机”(如进程从内核态返回用户态时),检查该进程是否有未处理的信号;
- 若有,则暂停进程当前执行的代码,跳转到信号处理表中预设的函数执行;执行完后,回到原代码继续运行。 信号不传递数据,仅传递 “事件发生” 的通知(类似硬件的 “中断请求”)。
生命周期:
- 生成:由内核(如硬件中断、错误)或其他进程(
kill(),raise())产生。 - 传递 (Delivery):内核将信号传递给目标进程。如果信号未被阻塞,且进程处于可运行状态,则执行处理函数或采取默认动作。
- 处理:执行用户定义的函数、默认动作(终止、暂停等)或忽略。
- 瞬时性:信号一旦被传递和处理,其 ” 事件 ” 就消失了。不携带数据(除了信号编号本身)。
使用方式:
- 发送:
kill(pid, sig),raise(sig)(发给自己),sigqueue()(可携带少量数据,POSIX 实时信号)。 - 接收/处理:
- 忽略:
signal(sig, SIG_IGN) - 默认:
signal(sig, SIG_DFL) - 自定义:
signal(sig, handler_func)或更安全的sigaction()
- 忽略:
- 阻塞/解除阻塞:
sigprocmask()。
特点:
- 异步事件通知:主要用于通知进程发生了某个事件(如用户按 Ctrl+C、子进程终止、定时器超时)。
- 轻量级:开销非常小。
- 不能传递数据:标准信号只传递编号。POSIX 实时信号 (
SIGRTMIN~SIGRTMAX) 可通过sigqueue()携带少量数据 (sigval)。 - 不可靠:信号可能会被合并(如多个
SIGUSR1可能只收到一个)。
注意事项:
- 异步信号安全:在信号处理函数中只能调用异步信号安全 (async-signal-safe) 的函数(如
write(),sigprocmask()),避免使用printf(),malloc()等。 - 竞态条件:信号处理可能打断主程序的任何执行点,需注意数据一致性。
- 不可靠性:不应依赖信号精确传递次数或携带复杂数据。
- 关键信号:
SIGKILL和SIGSTOP不能被捕获、阻塞或忽略。
例子
用户在终端运行一个 Python 脚本(进程 PID=1234),按 Ctrl+C 终止脚本:
Ctrl+C被终端驱动捕获,转化为SIGINT信号(编号 2),并通知内核;- 内核找到 PID=1234 的 Python 进程,检查其信号处理表:默认情况下,
SIGINT的处理动作是 “终止进程”; - 当 Python 进程下次从内核态返回用户态(如执行完一次系统调用后),内核触发
SIGINT处理逻辑; - Python 进程停止当前代码执行,释放内存等资源,最终退出。
若用户提前通过代码为
SIGINT注册了自定义函数(如 “保存数据后再退出”),则进程会先执行自定义函数,再按逻辑处理。
7. 套接字 (Socket) :基于 “网络协议” 的 “跨主机 / 本地” 通信
原理
实际结构/组成:
- 网络协议栈对象:对于网络套接字(AF_INET/AF_INET6),是 TCP/IP 协议栈中的连接或端点对象(包含端口号、IP 地址、状态等)。
- 文件描述符:套接字操作也通过文件描述符进行。
- 本地套接字 (Unix Domain Socket, AF_UNIX):
- 路径名:在文件系统中有一个特殊文件作为地址(如
/tmp/socket.sock)。 - 内核缓冲区:数据在内核中通过缓冲区传递,不经过网络协议栈,效率高于网络套接字。
- 路径名:在文件系统中有一个特殊文件作为地址(如
- 类型:
SOCK_STREAM(可靠字节流,TCP),SOCK_DGRAM(不可靠数据报,UDP),SOCK_SEQPACKET(有序数据报)。
Socket
套接字(Socket)本质是操作系统提供的 “网络通信接口”,基于 TCP/UDP 等网络协议实现数据传输。即使是同一主机内的进程通信,也可通过 “本地回环地址(127.0.0.1)” 或 “UNIX 域套接字”(仅本地,基于文件系统)实现;跨主机通信则通过网卡传输。
通信原理(以 TCP 本地通信为例):
- 服务端进程:
- 创建 Socket(
socket()):指定协议族(如AF_INET表示 IPv4)、类型(SOCK_STREAM表示 TCP); - 绑定地址(
bind()):将 Socket 绑定到本地 IP(127.0.0.1)和端口(如 8080); - 监听连接(
listen()):进入 “等待客户端连接” 状态; - 接受连接(
accept()):与客户端建立 TCP 连接,生成 “连接 Socket”(用于后续读写);
- 创建 Socket(
- 客户端进程:
- 创建 Socket,调用
connect():向服务端的 127.0.0.1:8080 发起连接请求; - TCP 三次握手完成后,客户端也生成 “连接 Socket”;
- 创建 Socket,调用
- 通信阶段:
- 客户端通过
write()向连接 Socket 写入数据,内核将数据封装成 TCP 报文,通过 “本地回环接口”(不经过网卡)发送到服务端; - 服务端通过
read()从连接 Socket 读取数据,完成通信。 本地 Socket 通信的底层会优化(如避免网络协议栈的部分开销),但原理与跨主机 TCP 通信一致。
- 客户端通过
生命周期:
- 创建:
socket()。 - 绑定/连接:
bind()(服务器),connect()(客户端)。 - 通信:
send()/recv()或write()/read()。 - 关闭:
close()。对于 TCP,有复杂的连接关闭过程(四次挥手)。 - 本地套接文件:Unix Domain Socket 的特殊文件需要手动
unlink()删除。
使用方式:
- 网络通信:标准的 TCP/UDP 客户端/服务器编程模型。
- 本地通信:使用
AF_UNIX地址族,通过文件路径通信。可以是流式或数据报。
特点:
- 通用性:最通用的 IPC 机制,既可用于本地进程通信,也可用于跨网络的主机间通信。
- 灵活性:支持多种协议和通信模式(流、数据报)。
- 标准 API:POSIX 标准,跨平台性好。
- 面向连接/无连接:
SOCK_STREAM需建立连接,SOCK_DGRAM无需连接。
注意事项:
- 复杂性:API 相对复杂,特别是网络编程涉及地址转换、错误处理等。
- 性能:网络套接字有网络延迟和协议开销。本地套接字性能很好,但仍不如共享内存。
- 错误处理:网络通信可能因各种原因失败(网络中断、超时),需完善的错误处理。
- 资源管理:及时
close()套接字,避免文件描述符泄漏。Unix Domain Socket 文件需清理。 - 缓冲区:注意发送/接收缓冲区大小和阻塞行为。
例子
本地 “Web 服务器进程 G”(如 Nginx)和 “后端应用进程 H”(如 Python Flask)通过 Socket 通信:
- G(服务端)绑定 127.0.0.1:8000,监听客户端连接;
- H(客户端)启动后,向 127.0.0.1:8000 发起 TCP 连接;
- 用户通过浏览器访问 G 的 80 端口,G 接收到 HTTP 请求后,通过与 H 的 Socket 连接,将请求数据(如 “/api/get_user”)发送给 H;
- H 处理请求后,通过 Socket 将响应数据(如 “{name: ‘Alice’}”)返回给 G;
- G 将响应转发给浏览器,完成一次请求。 即使 G 和 H 在同一主机,通过 Socket 通信也能实现清晰的 “客户端 - 服务端” 架构,且后续若 H 迁移到其他主机,只需修改连接地址即可。
总结与选择
| IPC 机制 | 主要用途 | 速度 | 复杂度 | 是否需亲缘 | 是否同步 | 典型场景 |
|---|---|---|---|---|---|---|
| 管道 (Pipe) | 简单流式数据 | 快 | 低 | 是 | 否 | ps | grep |
| 命名管道 (FIFO) | 无关进程流式数据 | 快 | 低 | 否 | 否 | 日志服务、客户端 - 服务器(简单) |
| 消息队列 | 异步、结构化消息 | 中 | 中 | 否 | 否 | 任务队列、事件通知 |
| 信号量 | 同步、互斥 | 极快 (操作) | 高 | 否 | 是 | 保护共享内存、资源计数 |
| 共享内存 | 大数据量共享 | 最快 | 很高 | 否 | 必须配合同步 | 高性能数据交换、缓存 |
| 信号 | 异步事件通知 | 极快 | 低 (但安全难) | 否 | 是 (异步) | 进程控制、超时处理 |
| 套接字 | 通用通信 (本地/网络) | 中 (本地)/慢 (网络) | 高 | 否 | 否 | 网络服务、本地复杂通信 |
| 需求 | 推荐方式 |
|---|---|
| 父子进程简单数据传递 | 管道(Pipe) |
| 无关进程间通信 | 命名管道(FIFO) 或 Unix Socket |
| 高频、大数据量通信 | 共享内存 + 信号量 |
| 结构化消息传递 | 消息队列 |
| 进程控制与通知 | 信号(Signal) |
| 同步多个进程 | 信号量(Semaphore) |
| 跨网络通信 | TCP/UDP Socket |
| 本地服务通信(如数据库) | Unix Domain Socket |
| 高并发、异步通信 | Socket + epoll/kqueue |
跨机 IPC(依赖网络硬件)
-
网络套接字(TCP/UDP Socket):
- 原理:基于 TCP/IP 协议的网络通信,通过 IP: 端口定位进程。
- 特性:支持跨主机、跨网络,TCP 可靠但慢,UDP 快但不可靠。
- 依赖硬件:网卡(NIC)、交换机、路由器等网络设备。
-
远程过程调用(RPC):
- 原理:封装网络通信细节,让跨机调用像本地函数调用(如 gRPC、Thrift)。
- 特性:简化跨机协作,支持多种序列化协议(Protobuf、JSON)。
- 依赖硬件:网络设备,高性能 RPC 依赖 RDMA 网卡(低延迟)。
-
消息中间件(如 Kafka、RabbitMQ):
- 原理:通过中间代理节点转发消息,支持异步、解耦的跨机通信。
- 特性:支持高并发、消息持久化,适合分布式系统。
- 依赖硬件:集群服务器、磁盘(消息存储)、高速网络。
其他
| 机制 | 说明 |
|---|---|
| D-Bus | Linux 桌面环境常用的消息总线系统(如 GNOME、KDE) |
| RPC(Remote Procedure Call) | 如 gRPC、Thrift,让进程像调用本地函数一样调用远程服务 |
| 内存映射文件(mmap + 文件) | 多个进程映射同一文件,实现持久化共享内存 |
| 文件 + 锁(flock) | 简单场景下通过文件和文件锁实现通信 |
总结对比表
| 通信方式 | 速度 | 是否持久化 | 是否支持多进程 | 通信方向 | 典型场景 | 同步机制 | 核心载体(内核 / 硬件) | 通信本质 |
|---|---|---|---|---|---|---|---|---|
| 1. 管道(Pipe) | 快 | 否 | 有限 | 单向 | 父子进程、shell 命令链 | 无 | 内核缓冲区 | 字节流中转 |
| 2. 命名管道(FIFO) | 快 | 否 | 是 | 单向 | 无关进程间通信 | 无 | 内核缓冲区 | 字节流中转 |
| 3. 信号(Signal) | 极快 | 否 | 是 | 控制流 | 进程控制、异常通知 | 内置 | 内核信号表 + 中断机制 | 事件编号通知 |
| 4. 消息队列(Message Queue) | 中 | 否(内存中) | 是 | 双向 | 结构化数据传递 | 内置 | 内核消息链表 | 结构化消息(带类型) |
| 5. 共享内存(Shared Memory) | 最快 | 否(内存中) | 是 | 双向 | 高频、大数据量通信 | 需额外(信号量) | 物理内存 + 虚拟地址映射 | 零拷贝访问同一块物理内存 |
| 6. 信号量(Semaphore) | 快 | 否 | 是 | 同步 | 进程间同步与互斥 | 内置 | 内核计数器 | 原子 P/V 操作协调资源访问 |
| 7. 套接字(Socket) | 中 | 可选 | 是 | 双向 | 本地通信 | 内置 | 网络协议栈 + 网卡 / 回环 | 基于 TCP/UDP 的数据包传输 |
| TCP Socket | 慢(相对) | 可选 | 是 | 双向 | 网络通信 | 内置 |
不同硬件场景下的 IPC 实践
1. 单机多核 CPU(共享内存架构)
- 核心需求:低延迟、高吞吐量,充分利用本地内存。
- 优选方式:
- 共享内存:适合高频、大数据量通信(如数据库进程间共享缓存,如 PostgreSQL 的共享缓冲区)。
- 信号量 + 共享内存:结合使用,保证共享内存的线程安全(如操作系统内核与用户态进程通信)。
- 本地 Socket:适合需要标准化接口的场景(如浏览器与本地插件通信,用
AF_UNIX套接字)。
- 硬件优化:
- 利用 CPU 缓存:共享内存数据尽量放入 L3 缓存,减少内存访问延迟。
- 避免缓存颠簸:通过内存对齐、数据分区减少多进程对同一缓存行的竞争。
2. 分布式集群(多机通过网络连接)
- 核心需求:跨节点协作,容忍网络延迟,支持高可用。
- 优选方式:
- TCP Socket/RPC:适合实时性要求中等的场景(如微服务间调用,用 gRPC 基于 HTTP/2)。
- 消息中间件:适合异步通信(如电商订单系统,订单进程与支付进程通过 Kafka 解耦)。
- RDMA(远程直接内存访问):高性能计算集群(如超级计算机)中,用 RDMA 绕过内核直接访问远程内存,延迟降至微秒级(依赖 RDMA 网卡)。
- 硬件优化:
- 高速网络:用 100G/400G 以太网或 InfiniBand,减少网络延迟。
- 就近部署:进程按物理机亲和性调度,减少跨机架通信(如 Kubernetes 的节点亲和性)。
3. 异构设备(CPU 与 GPU/FPGA 协作)
- 核心需求:CPU 与加速设备间高效数据传输(避免成为性能瓶颈)。
- 优选方式:
- 共享内存(统一内存架构):如 NVIDIA 的 Unified Memory,CPU 和 GPU 直接访问同一块内存(依赖支持统一寻址的 GPU 硬件,如 Pascal 及以上架构)。
- 设备专用内存拷贝:通过 PCIe 总线传输数据(如 CUDA 的
cudaMemcpy,CPU 将数据拷贝到 GPU 全局内存)。
- 硬件优化:
- 高带宽 PCIe:用 PCIe 4.0/5.0,提升 CPU 与 GPU 间的数据传输速率(如 NVIDIA H100 GPU 支持 PCIe 5.0)。
- 显存本地化:计算任务尽量在 GPU 内部完成,减少与 CPU 的数据交互。
4. 嵌入式系统(资源受限,低功耗)
- 核心需求:轻量、低功耗,通信量小。
- 优选方式:
- 管道 / FIFO:简单且占用资源少(如嵌入式 Linux 中,传感器进程与数据处理进程用管道通信)。
- 信号:用于紧急事件(如设备断电时,内核向应用进程发送信号触发保存状态)。
- 硬件适配:
- 避免复杂 IPC(如消息队列),减少内存和 CPU 占用(嵌入式设备内存通常为 MB 级)。
✅ 最终结论
IPC 方式的选择需结合硬件架构(共享内存 / 网络)、性能需求(延迟 / 吞吐量)、场景特性(同步 / 异步):
- 单机内优先用共享内存、本地 Socket,依赖内存和 CPU 缓存;
- 跨机优先用RPC、消息中间件,依赖高速网络;
- 异构设备优先用统一内存或专用总线传输,依赖 PCIe 等硬件接口。
线程同步与通信:
- 线程同步 是基础,确保数据安全;
- 线程间通信 是目的,实现协作;
- 两者相辅相成,同步机制常常被用来实现通信;
- 掌握这些机制,是编写正确、高效多线程程序的关键。
线程间通信与同步
一、线程间通信 Vs 线程同步:关系与区别
| 概念 | 定义 |
|---|---|
| 线程间通信(Thread Communication) | 指多个线程之间传递数据或信息的行为。例如:线程 A 计算出结果,通知线程 B 继续处理。 |
| 线程同步(Thread Synchronization) | 指协调多个线程的执行顺序,避免竞态条件(Race Condition),确保共享资源的正确访问。 |
线程间很多资源都是共享的,所以线程间没有像进程间那样有许多数据交换机制。如果进程在时间片结束前阻塞或结束,则 CPU 当即进行切换。
- 线程间通信主要目的是为了线程同步。
✅ 线程同步是线程间通信的基础,通信往往依赖于同步机制来实现。
- 同步 ≠ 通信,但通信常需要同步
- 例如:你不能简单地“告诉”另一个线程“我完成了”,而必须通过某种同步原语(如条件变量)来安全地传递这个信息。
| 维度 | 线程同步 | 线程间通信 |
|---|---|---|
| 目的 | 防止数据竞争,保证正确性 | 传递数据或状态信息 |
| 手段 | 锁、信号量、屏障等 | 共享变量、消息队列、条件变量等 |
| 是否涉及数据内容 | 否(只控制访问顺序) | 是(传递具体数据) |
| 典型场景 | 多个线程修改同一个计数器 | 生产者 - 消费者模型 |
🔁 总结:
- 同步 是“谁可以访问资源”的问题。
- 通信 是“线程之间如何交换信息”的问题。
- 很多机制(如条件变量)既是同步工具,也是通信手段。
二、常见的线程同步方式(Synchronization Mechanisms)
线程间同步是为了解决同一进程内多个线程对共享资源(如全局变量、堆内存)的并发访问问题,避免数据不一致或逻辑错误。操作系统提供了多种线程同步机制,它们的实现原理和适用场景各有不同。以下从实现方式、同步原理、特点及实例进行说明:
一、互斥锁(Mutex, Mutual Exclusion):“独占式” 资源保护
1. 操作系统内的实现
互斥锁本质是内核维护的一个状态变量(如 0 表示 “未锁定”,1 表示 “已锁定”),搭配一个等待队列(存放阻塞的线程)。核心依赖两条原子指令:
-
test_and_set:检查锁状态,若未锁定则立即锁定(原子操作,不可被中断); -
线程阻塞 / 唤醒机制:当锁已被占用时,内核将当前线程放入等待队列,切换为阻塞状态;锁释放时,从等待队列唤醒一个线程并分配锁。
-
作用:保证同一时间只有一个线程能进入临界区。
-
使用场景:保护共享变量(如全局计数器、链表)。
-
操作:
lock()/unlock() -
问题:可能造成死锁、优先级反转。
2. 同步原理
通过 “独占访问” 实现同步:线程访问共享资源前必须先 “获取锁”,访问结束后 “释放锁”。若锁已被其他线程占用,当前线程会阻塞等待,直到锁被释放。
3. 特点与注意事项
- 特点:严格保证同一时间只有一个线程访问共享资源(互斥性);支持 “所有权”(只有获取锁的线程能释放锁)。
- 注意事项:
- 避免死锁:若线程持有锁时阻塞(如等待另一个锁),可能导致所有相关线程互相等待;
- 不可递归:同一线程不能重复获取已持有的互斥锁(会导致自死锁)。
✅ 最基本、最常用的同步机制。
4. 计算机例子
假设有一个全局变量 balance(银行账户余额),两个线程同时执行转账操作(balance += 100):
- 若不加锁:可能出现 “两个线程同时读取余额(如 1000),各自加 100 后写回,最终余额为 1100(正确应为 1200)” 的错误;
- 加互斥锁后:
import threading
balance = 1000
lock = threading.Lock()
def transfer():
global balance
lock.acquire() # 获取锁
balance += 100
lock.release() # 释放锁
# 两个线程执行转账
t1 = threading.Thread(target=transfer)
t2 = threading.Thread(target=transfer)
t1.start()
t2.start()
t1.join()
t2.join()
print(balance) # 正确输出1200锁确保了 balance += 100 的 “读 - 改 - 写” 过程不会被打断,避免数据错误。
二、自旋锁(Spin Lock):“忙等式” 轻量同步
1. 操作系统内的实现
自旋锁与互斥锁类似,核心是一个原子状态变量,但没有等待队列。当线程获取锁失败时,不会进入阻塞状态,而是通过循环不断检查锁状态(“自旋”),直到锁被释放。
- 作用:线程在锁被占用时不断循环检查(忙等待),而不是挂起。
- 优点:避免线程切换开销,适合锁持有时间极短的场景。
- 缺点:浪费 CPU 资源。
2. 同步原理
通过 “忙等待” 实现同步:线程获取锁失败时不放弃 CPU,持续循环尝试获取,直到成功。适用于 “锁持有时间极短” 的场景(避免线程阻塞 / 唤醒的开销)。
3. 特点与注意事项
- 特点:轻量(无内核态切换开销);适合多核 CPU(自旋时其他核心可运行);不适合锁持有时间长的场景(浪费 CPU 资源)。
- 注意事项:
- 避免在单 CPU 系统使用:自旋会占用 CPU,导致持有锁的线程无法运行,形成 “死循环”;
- 可能导致优先级反转:低优先级线程自旋持有锁时,高优先级线程一直等待,浪费资源。
✅ 适用于内核编程或高性能场景。
4. 计算机例子
在多核 CPU 中,两个线程频繁读写一个共享计数器(每次操作仅需 10 纳秒):
-
若用互斥锁:每次获取 / 释放锁需要切换内核态(约 1 微秒),开销远大于实际操作;
-
用自旋锁:线程获取锁失败时仅循环几纳秒,锁释放后立即获取,总开销更小:
// 伪代码 spinlock_t lock = 0; // 0表示未锁定 int counter = 0; void increment() { while (spin_lock(&lock)) {} // 自旋等待锁 counter++; spin_unlock(&lock); }适合高频、短时间的同步操作(如内核中的中断处理)。
三、信号量(Semaphore):“计数器” 式同步
1. 操作系统内的实现
信号量是内核维护的一个整数计数器和一个等待队列。核心操作是两条原子指令:
-
P操作:计数器减 1,若结果 < 0,当前线程进入等待队列阻塞; -
V操作:计数器加 1,若结果≤0,从等待队列唤醒一个线程。 -
作用:维护一个计数器,控制对资源的访问数量。
-
分为:
- 二值信号量:等价于互斥锁(0 或 1)
- 计数信号量:允许多个线程同时访问(如数据库连接池)
-
操作:
wait()(P 操作,减 1)、post()(V 操作,加 1)
2. 同步原理
通过 “资源计数” 实现同步:计数器代表可用资源的数量。线程通过 P 操作 “申请资源”(计数器减 1),通过 V 操作 “释放资源”(计数器加 1),当资源不足时阻塞等待。
3. 特点与注意事项
- 特点:灵活(可用于互斥或同步);计数器值可大于 1(支持多个线程同时访问资源);无所有权(任何线程可释放信号量)。
- 注意事项:
- 信号量值需正确初始化(互斥时初始化为 1,同步时初始化为资源数量);
- 避免 “信号量泄露”(线程申请资源后未释放,导致计数器永久减少)。
✅ 信号量比互斥锁更灵活,可用于资源池、同步多个线程等。
4. 计算机例子
一个线程池有 3 个工作线程,需限制同时运行的任务不超过 2 个(模拟资源限制):
-
初始化信号量
sem = 2(允许 2 个并发任务); -
每个任务执行前调用
P(sem)(申请资源),执行后调用V(sem)(释放资源):import threading import time sem = threading.Semaphore(2) # 最多2个线程同时执行 def task(name): sem.acquire() # P操作:申请资源 print(f"任务{name}开始执行") time.sleep(1) # 模拟任务执行 print(f"任务{name}完成") sem.release() # V操作:释放资源 # 3个任务同时启动 for i in range(3): threading.Thread(target=task, args=(i,)).start()输出会显示 “前 2 个任务同时执行,第 3 个任务等待 1 秒后执行”,实现了并发数控制。
四、条件变量(Condition Variable):“事件触发” 式同步
1. 操作系统内的实现
条件变量通常与互斥锁配合使用,内核为其维护一个等待队列。核心操作:
-
wait():释放关联的互斥锁,将线程放入等待队列并阻塞;被唤醒时重新获取互斥锁; -
signal()/broadcast():从等待队列唤醒一个(或所有)线程,通知 “条件已满足”。 -
作用:允许线程等待某个条件成立,并由其他线程通知它。
-
必须与互斥锁配合使用。
-
操作:
wait()、signal()、broadcast()
2. 同步原理
通过 “等待 - 通知” 机制实现同步:线程在共享资源不满足条件时(如缓冲区为空),通过 wait() 释放锁并阻塞等待;当其他线程修改资源使条件满足时(如缓冲区有数据),通过 signal() 唤醒等待线程。
3. 特点与注意事项
- 特点:必须与互斥锁配合使用(保护条件判断的原子性);支持精准唤醒(针对特定条件);避免 “虚假唤醒”(需在循环中检查条件)。
- 注意事项:
wait()前必须持有互斥锁,否则会导致未定义行为;- 唤醒后需重新检查条件(因可能被虚假唤醒或其他线程已修改资源)。
✅ 条件变量是实现线程通信的核心机制之一:它不仅同步访问,还传递“状态变化”信息。
4. 计算机例子
生产者 - 消费者模型中,消费者等待缓冲区有数据时才读取:
import threading
import queue
q = queue.Queue(3) # 缓冲区容量3
cond = threading.Condition() # 条件变量
def producer():
for i in range(5):
with cond: # 自动获取/释放锁
while q.full():
cond.wait() # 缓冲区满,等待消费者通知
q.put(i)
print(f"生产:{i}")
cond.notify() # 通知消费者有数据
def consumer():
for _ in range(5):
with cond:
while q.empty():
cond.wait() # 缓冲区空,等待生产者通知
data = q.get()
print(f"消费:{data}")
cond.notify() # 通知生产者有空间
threading.Thread(target=producer).start()
threading.Thread(target=consumer).start()条件变量确保了消费者不会在缓冲区空时读取,生产者不会在缓冲区满时写入。
五、读写锁(Reader-Writer Lock):“读共享,写独占”
1. 操作系统内的实现
读写锁维护两个计数器(read_count 读线程数、write_count 写线程数)和一个等待队列。核心逻辑:
-
读锁:若当前无写线程,
read_count加 1;否则阻塞等待; -
写锁:若当前无读 / 写线程,
write_count设为 1;否则阻塞等待; -
释放时根据计数器状态唤醒等待的读 / 写线程(读优先或写优先)。
-
作用:允许多个读线程同时访问,但写线程独占。
-
适用场景:读多写少的数据结构(如配置表、缓存)。
2. 同步原理
通过 “区分读写操作” 实现同步:多个读线程可同时获取读锁(共享资源),写线程必须独占资源(获取写锁时不允许任何读 / 写线程)。
3. 特点与注意事项
- 特点:提高读操作并发效率(适合读多写少场景);支持读优先或写优先策略;比互斥锁复杂。
- 注意事项:
- 避免写饥饿:读优先策略下,持续的读操作可能导致写线程长期等待;
- 读锁不可升级为写锁(可能死锁)。
✅ 提高并发性能,避免读操作阻塞。
4. 计算机例子
一个新闻网站的 “文章阅读”(读操作)和 “文章更新”(写操作):
-
成千上万的用户同时读取文章(读线程),可并发执行;
-
编辑更新文章时(写线程),需独占资源,防止读者读到不完整内容:
import threading rw_lock = threading.RLock() # 简化模拟读写锁(实际需专用实现) article = "初始内容" def read_article(user): with rw_lock: # 获取读锁 print(f"用户{user}读取:{article}") def write_article(editor, new_content): with rw_lock: # 获取写锁 global article article = new_content print(f"编辑{editor}更新:{article}") # 10个读者并发读取 for i in range(10): threading.Thread(target=read_article, args=(i,)).start() # 1个编辑更新文章 threading.Thread(target=write_article, args=(0, "新内容")).start()读写锁确保了 “多读者并发” 和 “写者独占” 的平衡,提升系统效率。
六、线程屏障(Thread Barrier):“集合同步”
线程屏障(Thread Barrier)是一种用于协调多个线程同步执行节奏的机制,确保所有参与线程都到达某个 “checkpoint(检查点)” 后,再同时继续执行后续操作。它特别适合需要 “阶段式协作” 的场景,例如分步骤计算任务(所有线程完成当前阶段后,才能进入下一阶段)。
一、线程屏障的实现方式(操作系统层面)
操作系统内核或线程库(如 POSIX 的 pthread_barrier_t)会为屏障维护以下核心数据结构:
- 计数器:记录已到达屏障的线程数量;
- 参与线程总数(n):初始化时指定的需要同步的线程总数;
- 等待队列:存放已到达屏障但需等待其他线程的线程。
核心操作逻辑:
- 线程调用
barrier_wait()时,计数器原子加 1; - 若计数器未达到总数
n:当前线程进入等待队列,阻塞休眠(释放 CPU); - 若计数器达到总数
n:唤醒等待队列中所有线程,同时将计数器重置(允许屏障重复使用); - 所有线程被唤醒后,继续执行
barrier_wait()之后的代码。
二、同步原理:“集齐所有线程,再统一出发”
线程屏障的核心是 **“阻塞 - 唤醒” 的集体同步 **:每个线程执行到屏障点时必须停下等待,直到所有预设数量的线程都到达此处,屏障才会 “放行”,让所有线程同时继续执行。
可以类比为:“春游时老师要求所有同学在景区门口集合,等最后一个同学到达后,大家再一起进入景区”—— 景区门口就是 “屏障点”,老师的清点就是 “计数器”,等所有人到齐(计数器满)才统一行动。
三、特点与注意事项
特点:
- 集体同步:不是 “一对一” 唤醒,而是 “所有到齐后一起唤醒”;
- 可重用性:一次同步完成后,屏障可重置计数器,支持多轮同步(如多阶段任务);
- 必须指定参与数量:初始化时需明确
n(参与同步的线程总数),否则无法判断何时 “到齐”。
注意事项:
- 线程数量匹配:若实际参与的线程数不等于初始化时的
n,会导致永久阻塞(如n=3但只有 2 个线程到达,第 3 个线程未出现); - 避免屏障嵌套:多个屏障嵌套使用可能导致死锁(线程在等待内层屏障时,外层屏障永远等不齐);
- 性能开销:线程阻塞 / 唤醒涉及内核态操作,频繁使用可能影响性能。
✅ 实现“集体同步”。
四、计算机例子:多线程分阶段数据处理
假设有一个图像处理任务,需要 3 个线程协作完成:
- 阶段 1:每个线程分别处理图像的红、绿、蓝三个通道;
- 阶段 2:所有通道处理完成后,再合并三个通道生成最终图像。
此时可用屏障确保 “阶段 2 必须在阶段 1 全部完成后才开始”:
import threading
import time
# 初始化屏障:3个线程参与同步
barrier = threading.Barrier(3)
def process_channel(channel):
# 阶段1:处理单个通道
print(f"线程{channel}:开始处理{channel}通道...")
time.sleep(1) # 模拟处理耗时
print(f"线程{channel}:{channel}通道处理完成,等待其他线程...")
# 到达屏障点,等待所有线程
barrier.wait() # 所有3个线程到达后,才会继续执行
# 阶段2:合并通道(所有线程都完成阶段1后才执行)
print(f"线程{channel}:开始合并通道...")
# 创建3个线程,分别处理R、G、B通道
threads = [
threading.Thread(target=process_channel, args=("R",)),
threading.Thread(target=process_channel, args=("G",)),
threading.Thread(target=process_channel, args=("B",))
]
for t in threads:
t.start()
for t in threads:
t.join()执行结果(顺序可能略有不同,但阶段 2 一定在所有阶段 1 之后)
线程R:开始处理R通道...
线程G:开始处理G通道...
线程B:开始处理B通道...
线程R:R通道处理完成,等待其他线程...
线程G:G通道处理完成,等待其他线程...
线程B:B通道处理完成,等待其他线程...
线程R:开始合并通道...
线程G:开始合并通道...
线程B:开始合并通道...可以看到:3 个线程必须都完成 “通道处理”(阶段 1)并到达屏障点后,才会同时进入 “合并通道”(阶段 2),避免了 “某个通道未处理完就开始合并” 的错误。
总结:线程同步方式对比
| 同步方法 | 核心机制 | 核心作用 | 适用场景 | 优点 | 缺点 |
|---|---|---|---|---|---|
| 互斥锁(Mutex) | 独占锁 + 阻塞等待,通过内核维护的状态变量和等待队列实现 | 保证同一时间只有一个线程访问共享资源 | 任意需要互斥访问的场景(如修改全局变量、操作共享数据结构) | 1. 无 CPU 浪费(未获取锁时线程阻塞) 2. 支持所有权(只有持有者可释放) | 1. 阻塞 / 唤醒有内核态切换开销 2. 可能导致死锁(如锁顺序错误) |
| 自旋锁(Spin Lock) | 独占锁 + 忙等待,通过原子操作循环检查锁状态 | 短时间独占资源,避免线程阻塞开销 | 锁持有时间极短的场景(如内核中断处理、多核 CPU 下的轻量同步) | 1. 无内核态切换开销(用户态完成) 2. 适合高频、短时操作 | 1. 忙等待浪费 CPU 资源(锁持有久时严重) 2. 单 CPU 系统中可能导致死锁 |
| 信号量(Semaphore) | 计数器 + 阻塞等待,通过 P/V 原子操作控制资源数量 | 控制并发访问数量或线程执行顺序 | 1. 限制资源并发数(如线程池大小) 2. 线程间顺序同步(如生产者 - 消费者的缓冲区控制) | 1. 灵活性高(可用于互斥或同步) 2. 支持多线程并发访问(计数器 > 1 时) | 1. 无所有权(任意线程可释放,易出错) 2. 初始化值需精确设置,否则易导致逻辑错误 |
| 条件变量(Condition Variable) | 等待 - 通知机制,与互斥锁配合使用,通过内核等待队列实现 | 线程因条件不满足时阻塞,满足后被唤醒 | 依赖共享资源状态的同步(如缓冲区空 / 满时的等待) | 1. 精准控制唤醒时机(基于条件) 2. 减少无效轮询(条件不满足时阻塞) | 1. 必须与互斥锁配合使用(否则易出错) 2. 可能发生虚假唤醒(需循环检查条件) |
| 读写锁(Reader-Writer Lock) | 区分读写操作,读共享 / 写独占,通过计数器控制访问权限 | 提高读多写少场景的并发效率 | 读操作频繁、写操作较少的场景(如缓存读取、数据库查询) | 1. 读操作可并发(提升效率) 2. 写操作独占(保证数据一致性) | 1. 实现复杂,性能略低于互斥锁 2. 可能导致写饥饿(读优先策略下) |
| 屏障(Barrier) | 计数器 + 集体唤醒,等待所有线程到达后统一继续执行 | 协调多线程分阶段执行,确保阶段同步 | 多阶段协作任务(如并行计算的各阶段同步、流水线任务) | 1. 简洁实现多线程集体同步 2. 支持重复使用(多轮同步) | 1. 初始化时需固定线程数量(不灵活) 2. 线程数量不匹配时会导致永久阻塞 |
三、线程间通信的常见方式(基于同步机制)
虽然“通信”强调数据传递,但其实现往往依赖同步原语:
| 通信方式 | 实现机制 | 说明 |
|---|---|---|
| 共享变量 + 条件变量 | volatile 变量 + cond_wait/signal | 最常见,如生产者 - 消费者 |
| 消息队列 | 共享队列 + 互斥锁 + 条件变量 | 线程安全队列,传递结构化消息 |
| 管道(Pipe)或 FIFO | 文件描述符共享 | 跨线程数据流(较少用) |
| 事件标志(Event Flag) | 布尔变量 + 条件变量 | 通知某个事件发生 |
| Future / Promise | 封装异步结果 + 锁 | 高级语言(如 Java、C++)常用 |
最佳实践建议
| 语言 | 同步/通信机制 |
|---|---|
| Java | synchronized、ReentrantLock、BlockingQueue、CountDownLatch |
| C++ | std::mutex、std::condition_variable、std::atomic |
| Python | threading.Lock、threading.Condition、queue.Queue |
| Go | channel(首选)、sync.Mutex、sync.WaitGroup |
| Rust | Mutex<T>、Arc<T>、channel(标准库或 tokio) |
- ✅ 优先使用高级封装:如
std::mutex、std::condition_variable、ConcurrentQueue - ✅ 避免死锁:按固定顺序加锁,使用
std::lock()一次性获取多个锁 - ✅ 减少临界区大小:只在必要时加锁
- ✅ 用条件变量代替轮询:避免 CPU 浪费
- ✅ 高并发场景考虑无锁编程(Lock-free):如原子操作、CAS
💡 Go 的
channel是通信与同步的完美结合:通过“通信来共享内存”,而不是“通过共享内存来通信”。
经典的进程/线程同步与互斥模型
要理解这三个经典问题,首先需要明确核心概念:它们本质是进程 / 线程同步与互斥的抽象模型 —— 既可以用于描述进程间的协作与竞争,也可用于线程间(同一进程内或不同进程内)的同步,具体取决于实际场景中共享资源的归属(进程级共享资源需进程同步,线程级共享资源需线程同步)。三者的核心矛盾均源于 “多个执行单元(进程 / 线程)对共享资源的并发访问”,需通过信号量、互斥锁等同步机制解决。
1. 生产者 —— 消费者问题(Producer-Consumer Problem)
问题本质
“生产者”(进程 / 线程)负责生成数据(如计算结果、采集的传感器数据),“消费者”(进程 / 线程)负责处理数据(如存储、解析数据),二者通过一个有限容量的共享缓冲区传递数据。核心矛盾是:
- 缓冲区满时,生产者不能继续写入(避免数据溢出);
- 缓冲区空时,消费者不能继续读取(避免读取无效数据);
- 多个生产者 / 消费者并发访问缓冲区时,需避免 “同时修改缓冲区索引” 等竞态条件(互斥)。
**出现的问题:**为了时刻监视缓冲区大小,需要有一个变量 count 来映射。但是这个变量就是映射的共享内存,生产者消费者都可以修改这个变量。由于这里面对 count 没有加以限制会出现竞争。比如当缓冲区为空时,count=0,消费者读取到 count 为 0,这个时候 cpu 的执行权限突然转移给了生产者。然后生产者发现 count=0,就会马上生产一个数据放到缓冲区中,此时 count=1,接着会发送一个 wakeup 信号给消费者,因为由于之前 count=0,生产者以为消费者进入了阻塞状态。但事实上我们知道消费者还没有进入阻塞状态,因此生产者的这个 wakeup 信号会丢失。接着 CPU 执行权限有转移到消费者这里,消费者查看自己的进程表项中存储的信息发现 count=0 然后进入阻塞,永远不去取数据。这个时候生产者迟早会把缓冲区填满,然后生产者也会进入阻塞,然后两个进程都在阻塞下去,出现了问题。
这个模型涉及到了两种关系:
-
生产者和消费者之间的同步关系。当缓冲区满时,必须等消费者先行动。当缓冲区空时,必须等生产者先行动。
-
生产者和消费者之间的互斥关系。就是刚才提到的问题,对缓冲区的操作必须与其他进程互斥才行。不然很容易死锁。
同步对象:进程 / 线程均可
- 若共享缓冲区是进程级资源(如共享内存、管道),则解决的是进程同步(如:一个进程生成日志(生产者),另一个进程分析日志(消费者));
- 若共享缓冲区是线程级资源(如同一进程内的全局数组),则解决的是线程同步(如:一个线程采集用户输入(生产者),另一个线程处理输入(消费者))。
经典解决逻辑(以信号量为例)
需 3 个核心信号量实现同步与互斥:
-
empty:记录缓冲区中空闲位置的数量(初始值 = 缓冲区容量,如 5); -
full:记录缓冲区中已填充数据的数量(初始值 = 0); -
mutex:保护缓冲区访问的互斥锁(初始值 = 1,确保同一时间只有一个执行单元操作缓冲区)。 -
生产者流程:
- P(
empty):申请空闲位置,若缓冲区满则阻塞; - P(
mutex):申请缓冲区访问权,避免并发修改; - 将数据写入缓冲区,更新写入索引;
- V(
mutex):释放缓冲区访问权; - V(
full):通知消费者 “有新数据可用”。
- P(
-
消费者流程:
- P(
full):申请已填充数据,若缓冲区空则阻塞; - P(
mutex):申请缓冲区访问权; - 从缓冲区读取数据,更新读取索引;
- V(
mutex):释放缓冲区访问权; - V(
empty):通知生产者 “有新空闲位置”。
- P(
2. 哲学家进餐问题(Dining Philosophers Problem)
问题本质
5 个哲学家围坐在圆桌旁,每人左右各有 1 根筷子(共 5 根),哲学家的行为只有 “思考” 和 “进餐”:进餐时需同时拿起左右两根筷子,进餐结束后放下筷子。核心矛盾是避免死锁(如:所有哲学家同时拿起左手筷子,导致无人能拿到右手筷子,或者拿起左手,然后查看右手,没有就放下左手,有就拿起右手,五人同时拿起放下,这两种都会永久阻塞)和避免饥饿(如:某哲学家始终无法获得两根筷子)。
同步对象:进程 / 线程均可
- 若 “哲学家” 是进程,“筷子” 是进程级共享资源(如共享内存中的互斥锁标记),则解决的是进程同步;
- 若 “哲学家” 是同一进程内的线程,“筷子” 是线程级互斥锁(如
pthread_mutex_t),则解决的是线程同步。
经典解决逻辑(避免死锁)
常见方案是打破 “对称竞争”(所有哲学家按同一规则拿筷子):
- 方案 1:最多允许 4 个哲学家同时拿左手筷子(剩余 1 根筷子可让 1 个哲学家拿到右手筷子,完成进餐并释放筷子);
- 方案 2:编号为偶数的哲学家先拿左手筷子,编号为奇数的先拿右手筷子(避免所有人同时竞争同一侧筷子);
- 方案 3:哲学家必须同时申请左右两根筷子(通过 “原子操作” 确保要么同时拿到,要么都不拿)。
3. 读者 —— 写者问题(Reader-Writer Problem)
问题本质
问题描述:有读者和写者两组OLDER}两组OLDER}两组有读者和写者有读者和写者并发进程,共享一个文件,当两个或以上的读进程同时访问共享数据时不会产生副作用,但是如果有写进程在修改数据库的时候不能有其他读进程或写进程访问数据库。因此要求:①允许多个读者可以同时对文件执行读操作;②只允许一个写者往文件中写信息;③任一写者在完成写操作之前不允许其他读者或写者工作;④写者执行写操作前,应让已有的读者和写者全部退出。
多个 “读者”(进程 / 线程)仅读取共享资源(如配置文件、数据库表),多个 “写者”(进程 / 线程)仅修改共享资源。核心矛盾是:
- 互斥性:写者与写者之间、写者与读者之间需互斥(避免 “写者写一半时读者读” 导致数据不一致,或 “两个写者同时写” 覆盖数据);
- 并发性:读者与读者之间可并发(无需互斥,因为读取不修改资源,并发读可提升效率)。
同步对象:进程 / 线程均可
- 若共享资源是OLDER}两组(如磁盘文件、共享内存块),则 “读者 / 写者” 是进程,解决的是OLDER}两组(如:多个进程读同一日志文件,一个进程写日志文件);
- 若共享资源是有读者和写者(如进程内的全局变量、链表),则 “读者 / 写者” 是线程,解决的是进程级资源(如:多个线程读同一缓存,一个线程更新缓存)。
经典解决逻辑(以 “读者优先” 为例)
需 2 个核心同步机制:
-
rw_mutex:写者之间、写者与读者之间的互斥锁(初始值 = 1); -
reader_count_mutex:保护 “读者计数” 的互斥锁(初始值 = 1,避免多个读者并发修改计数导致统计错误); -
reader_count:当前正在读的读者数量(初始值 = 0)。 -
进程同步:
- P(
reader_count_mutex):申请修改读者计数的权限; - 若
reader_count == 0(第一个读者),则 P (rw_mutex):锁定共享资源,阻止写者介入; reader_count += 1(读者计数 + 1);- V(
reader_count_mutex):释放读者计数权限; - 读取共享资源;
- P(
reader_count_mutex):再次申请修改计数权限; reader_count -= 1(读者计数 - 1);- 若
reader_count == 0(最后一个读者),则 V (rw_mutex):释放共享资源,允许写者访问; - V(
reader_count_mutex):释放读者计数权限。
- P(
-
线程级资源:
- P(
rw_mutex):申请锁定共享资源(若有读者或其他写者,則阻塞); - 修改共享资源;
- V(
rw_mutex):释放共享资源。
- P(
注:“读者优先” 可能导致写者饥饿(若读者持续到来,写者始终无法获得
rw_mutex),可通过 “写者优先” 或 “公平调度”(如使用信号量的优先级机制)优化。
总结:三者的同步对象归属
| 经典问题 | 可用于进程同步? | 可用于线程同步? | 核心矛盾 |
|---|---|---|---|
| 生产者 —— 消费者问题 | 是 | 是 | 缓冲区空 / 满的同步 + 互斥访问 |
| 哲学家进餐问题 | 是 | 是 | 避免死锁 / 饥饿 + 资源竞争 |
| 读者 —— 写者问题 | 是 | 是 | 写互斥 + 读并发 |
本质上,这三个问题是线程同步,具体对应进程还是线程同步,仅取决于 “共享资源的粒度” 和 “执行单元的类型”,而非问题本身的属性。
生产者消费者模型
一、什么是生产者 - 消费者模型?
- 读者流程:生成数据或任务,并将其放入一个写者流程。
- 并发执行单元(进程 / 线程)对共享资源访问规则的抽象:从缓冲区中取出数据或任务进行处理。
- 生产者(Producer):一个线程/进程安全的队列,用于解耦生产与消费的速度差异。
模型结构图
+-------------+ +------------------+ +-------------+
| 生产者 | --> | 共享缓冲区 | --> | 消费者 |
| (Producer) | | (Blocking Queue)| | (Consumer) |
+-------------+ +------------------+ +-------------+
主要目的
- 共享缓冲区:生产者和消费者无需知道对方的存在。
- 消费者(Consumer):生产快时,数据暂存缓冲区;消费慢时,缓冲区堆积。
- 缓冲区(Buffer):多个消费者可并行处理任务。
- 解耦:避免忙等待或阻塞。
二、关键问题与解决方案
❓ 1. 缓冲区满时,生产者怎么办?
- 异步处理:生产者等待,直到消费者消费后通知。
- 负载均衡:丢弃新数据(如日志系统)。
- 资源利用率高:动态增长缓冲区(如无界队列)。
❓ 2. 缓冲区空时,消费者怎么办?
- 阻塞:消费者等待,直到生产者放入数据。
- 丢弃:尝试一段时间后放弃。
✅ 解决方案依赖
- 扩容:互斥锁(Mutex)、条件变量(Condition Variable)
- 阻塞:如
BlockingQueue(Java)、queue.Queue(Python)
三、在超时返回中的体现(最常见场景)
🧪 示例:多线程任务处理
import threading
import queue
import time
# 共享缓冲区
q = queue.Queue(maxsize=5)
def producer():
for i in range(10):
q.put(f"Task-{i}") # 阻塞直到有空间
print(f"Produced: Task-{i}")
time.sleep(0.5)
def consumer():
while True:
item = q.get() # 阻塞直到有任务
if item is None:
break
print(f"Consumed: {item}")
time.sleep(1)
q.task_done()
# 启动线程
t1 = threading.Thread(target=producer)
t2 = threading.Thread(target=consumer)
t1.start()
t2.start()
t1.join()
q.put(None) # 通知消费者结束
t2.join()
🔍 关键点
queue.Queue是线程同步机制put()和get()内部使用了 线程安全队列 实现同步与通信- 多个生产者/消费者可并发工作
四、在线程中的体现
进程间没有共享内存,因此需要线程安全的阻塞队列。常见实现方式:
1. 使用 互斥锁 + 条件变量
// System V 消息队列
int msqid = msgget(key, IPC_CREAT | 0666);
// 生产者进程
struct msgbuf msg;
msg.mtype = 1;
strcpy(msg.mtext, "Hello");
msgsnd(msqid, &msg, sizeof(msg.mtext), 0);
// 消费者进程
msgrcv(msqid, &msg, 256, 1, 0);
✅ 操作系统提供内核级消息队列,支持进程间阻塞通信。
2. 使用 进程
// 共享内存作为环形缓冲区
int shmid = shmget(key, sizeof(Buffer), IPC_CREAT | 0666);
Buffer *buf = (Buffer*)shmat(shmid, NULL, 0);
// 信号量控制访问
sem_t *empty = sem_open("/empty", O_CREAT, 0666, BUFFER_SIZE);
sem_t *full = sem_open("/full", O_CREAT, 0666, 0);
sem_t *mutex = sem_open("/mutex", O_CREAT, 0666, 1);
// 生产者
sem_wait(empty);
sem_wait(mutex);
buf->data[buf->in] = item;
buf->in = (buf->in + 1) % SIZE;
sem_post(mutex);
sem_post(full);
// 消费者类似
✅ 性能最高,但编程复杂,需手动同步。
3. 使用 跨进程的共享缓冲区
# 生产者写入
echo "data" > /tmp/myfifo
# 消费者读取(阻塞直到有数据)
cat /tmp/myfifo
✅ 简单,但只能单向通信。
4. 使用 消息队列(Message Queue)
- 构建一个简单的“任务服务器”,生产者发送任务,消费者连接并接收任务。
- 支持双向、多客户端。
✅ 灵活,适合复杂场景(如 Web 服务器与工作进程)。
五、在现代系统中的体现
-
共享内存 + 信号量
-
命名管道(FIFO)
-
本地套接字(Unix Domain Socket)
-
消息队列中间件
-
Web 服务器架构
✅ Go 的
channel是生产者 - 消费者模型的GUI 事件循环。
六、模型变体
| 变体 | 说明 |
|---|---|
| 日志系统 | 最简单,常用于无锁队列(Lock-free Queue) |
| Go 语言的 Channel | 如日志系统 |
| 语言级原生支持 | 如任务调度系统 |
| 单生产者 - 单消费者 | 最复杂,需强同步机制 |
| 多生产者 - 单消费者 | 固定大小,生产者可能阻塞 |
| 单生产者 - 多消费者 | 动态增长,可能耗尽内存 |
七、总结:生产者 - 消费者的本质
| 维度 | 说明 |
|---|---|
| 多生产者 - 多消费者 | 解耦生产与消费,通过缓冲区实现异步 |
| 有界缓冲区 | 必须使用互斥锁、条件变量、信号量等机制保证线程/进程安全 |
| 无界缓冲区 | 共享内存、消息队列、管道、Socket 等 |
| 核心思想 | 线程级、进程级、分布式系统级 |
| 同步需求 | 缓冲区大小、锁粒度、是否阻塞 |
✅ 最终结论
- 通信方式。
- 在适用层级中:通过性能关键实现。
- 在生产者 - 消费者模型是并发编程的基石中:通过线程等 IPC 机制实现。
- 在阻塞队列 + 条件变量中:通过进程等消息中间件实现。
掌握这个模型,就掌握了消息队列、共享内存、FIFO、Socket的核心思维。
BlockingQueue
分布式系统 是多线程编程中Kafka、RabbitMQ的关键组件。
一、什么是阻塞队列(Blocking Queue)?
🎯 定义
一个线程安全的队列,当:
- 高并发、异步处理、解耦设计,消费者线程调用
take()或pop()会阻塞队列(Blocking Queue)的线程安全数据结构之一,它是实现最核心、最实用,直到有元素可用。 - 的线程安全数据结构之一,它是实现,生产者线程调用
put()或push()会的线程安全数据结构之一,它是实现,直到有空间可用。
✅ 核心特性
- 生产者 - 消费者模型:多个线程可并发访问。
- 队列为空时:自动等待条件满足,无需轮询。
- 阻塞:生产者不关心谁消费,消费者不关心谁生产。
二、阻塞队列的原理
1. 核心组件
| 组件 | 作用 |
|---|---|
| 队列满时 | 通常使用 std::queue + std::deque 或循环数组 |
| 阻塞 | 保护队列的并发访问,防止数据竞争 |
| 线程安全 | 用于线程间通信: |
not_empty:通知“队列非空”,消费者可以取not_full:通知“队列非满”,生产者可以放 |
2. 关键操作流程
put(item) —— 生产者调用
1. 加锁(lock)
2. while (队列满) {
wait(not_full) // 阻塞等待
}
3. 将 item 加入队列
4. signal(not_empty) // 通知消费者:有新数据了
5. 解锁(unlock)
take() —— 消费者调用
1. 加锁(lock)
2. while (队列空) {
wait(not_empty) // 阻塞等待
}
3. 从队列取出一个元素
4. signal(not_full) // 通知生产者:有空位了
5. 解锁(unlock)
6. 返回元素
⚠️ 使用
while而不是if是为了防止阻塞操作
三、C++ 实现一个线程安全的阻塞队列
下面是一个**虚假唤醒(spurious wakeup)**的 C++ 实现,使用 std::queue、std::mutex、std::condition_variable。
#include <queue>
#include <mutex>
#include <condition_variable>
#include <thread>
#include <iostream>
#include <chrono>
template <typename T>
class BlockingQueue {
private:
std::queue<T> queue_;
mutable std::mutex mutex_; // mutable 允许在 const 方法中加锁
std::condition_variable not_empty_;
std::condition_variable not_full_;
size_t max_size_;
public:
explicit BlockingQueue(size_t max_size = 1000)
: max_size_(max_size) {}
// 禁止拷贝(线程安全容器不应被拷贝)
BlockingQueue(const BlockingQueue&) = delete;
BlockingQueue& operator=(const BlockingQueue&) = delete;
// 放入元素(阻塞)
void put(const T& item) {
std::unique_lock<std::mutex> lock(mutex_);
// 等待队列不满
not_full_.wait(lock, [this] { return queue_.size() < max_size_; });
queue_.push(item);
// 通知等待的消费者:队列非空
not_empty_.notify_one();
}
// 放入元素(右值,更高效)
void put(T&& item) {
std::unique_lock<std::mutex> lock(mutex_);
not_full_.wait(lock, [this] { return queue_.size() < max_size_; });
queue_.push(std::move(item));
not_empty_.notify_one();
}
// 取出元素(阻塞)
T take() {
std::unique_lock<std::mutex> lock(mutex_);
// 等待队列非空
not_empty_.wait(lock, [this] { return !queue_.empty(); });
T item = std::move(queue_.front());
queue_.pop();
// 通知等待的生产者:队列有空位
not_full_.notify_one();
return item;
}
// 尝试取出(带超时)
bool try_take(T& item, int timeout_ms = 100) {
std::unique_lock<std::mutex> lock(mutex_);
if (not_empty_.wait_for(lock, std::chrono::milliseconds(timeout_ms),
[this] { return !queue_.empty(); })) {
item = std::move(queue_.front());
queue_.pop();
not_full_.notify_one();
return true;
}
return false; // 超时
}
// 获取当前大小
size_t size() const {
std::lock_guard<std::mutex> lock(mutex_);
return queue_.size();
}
// 检查是否为空
bool empty() const {
std::lock_guard<std::mutex> lock(mutex_);
return queue_.empty();
}
};
四、使用示例:生产者 - 消费者模型
#include <vector>
int main() {
BlockingQueue<std::string> bq(5); // 最多5个元素
// 生产者线程
std::thread producer([&bq]() {
for (int i = 0; i < 10; ++i) {
std::string task = "Task-" + std::to_string(i);
std::cout << "Producing: " << task << std::endl;
bq.put(task);
std::this_thread::sleep_for(std::chrono::milliseconds(100));
}
});
// 消费者线程
std::thread consumer([&bq]() {
for (int i = 0; i < 10; ++i) {
std::string task = bq.take();
std::cout << "Consuming: " << task << std::endl;
std::this_thread::sleep_for(std::chrono::milliseconds(200)); // 消费慢
}
});
producer.join();
consumer.join();
std::cout << "Done!" << std::endl;
return 0;
}
输出示例
Producing: Task-0
Producing: Task-1
Consuming: Task-0
Producing: Task-2
Consuming: Task-1
Producing: Task-3
...
✅ 可以看到消费者慢,生产者会解耦生产与消费,不会无限堆积。
五、关键设计要点解析
| 特性 | 说明 |
|---|---|
| 1. 底层容器 | std::unique_lock 支持 std::lock_guard,unique_lock 不支持 |
| 2. 互斥锁(Mutex) | 自动释放锁并阻塞,被唤醒后重新获取锁 |
| 3. 条件变量(Condition Variable) | wait() 防止虚假唤醒 |
| 生产级风格 | lock_guard 唤醒一个等待线程(推荐),wait(condition) 唤醒所有(可能造成惊群) |
| 自动阻塞 | 提高性能,避免拷贝大对象 |
wait(lock, []{ return ...; }) vs notify_one() | 允许 notify_all() 方法(如 one)中加锁 |
六、高级变体与优化
1. 无界阻塞队列
- 去掉
all限制,T&&永不阻塞 - 风险:可能耗尽内存
2. 优先级阻塞队列
- 使用
mutable mutex_替代const - 高优先级任务先被消费
3. 无锁阻塞队列(Lock-free)
- 使用原子操作(
size())和 CAS(Compare-And-Swap) - 性能极高,但实现复杂
- 适用于高频交易、游戏引擎等场景
4. 多生产者 - 多消费者优化
- 使用**
max_size_** + 分离锁(如生产者锁、消费者锁) - 减少锁竞争
死锁
**
在计算机领域,死锁是**put() vs std::priority_queue**的状态。它是并发编程中最隐蔽且危害极大的问题(如程序卡死、资源泄漏),必须从 “原理 - 条件 - 场景 - 避免” 全链路理解。
简单来说:
std::queue
一、死锁的四个必要条件(Coffman 条件)
死锁的本质是 lambda 条件判断 的叠加:
- 每个参与方都持有部分资源,同时等待其他参与方持有的资源;
- 没有任何一方愿意主动释放已持有的资源;
- 操作系统 / 调度器无法打破这种循环(因为线程 / 进程的执行逻辑是 “不拿到资源不干活”)。
死锁的发生必须同时满足以下 4 个条件,右值引用 std::atomic(只要破坏其中任意一个,死锁就不会发生)。这是理解和解决死锁的核心理论。
| 必要条件 | 核心含义 | 通俗举例(线程 A、B 竞争锁 1、锁 2) |
|---|---|---|
| 1. 资源独占(Mutual Exclusion) | 资源只能被一个线程 / 进程占用,无法被多个共享(如互斥锁、打印机)。 | 锁 1 被 A 持有后,B 无法再获取锁 1;锁 2 被 B 持有后,A 无法获取锁 2。 |
| 2. 持有并等待(Hold and Wait) | 线程 / 进程已持有至少一个资源,同时又在等待其他资源(且这些资源被其他方持有)。 | A 持有锁 1,等待锁 2;B 持有锁 2,等待锁 1。 |
| 3. 不可剥夺(No Preemption) | 已分配给线程 / 进程的资源,不能被强制回收,只能由持有者主动释放。 | 操作系统不能从 A 手里 “抢” 走锁 1,也不能从 B 手里 “抢” 走锁 2。 |
| 4. 循环等待(Circular Wait) | 多个线程 / 进程形成闭环等待链:A 等 B 的资源,B 等 C 的资源,…,最后 X 等 A 的资源。 | A→B→A(A 等 B 的锁 2,B 等 A 的锁 1),形成 2 节点循环。 |
三、死锁的典型场景(计算机领域实例)
死锁并非抽象概念,在实际并发编程中很容易触发,以下是 3 个最常见的场景:
场景 1:多线程竞争多把互斥锁(最经典)
假设有 2 个线程(T1、T2),需要先后获取 2 把互斥锁(L1、L2)才能执行临界区代码,但获取顺序不一致:
// 线程T1的逻辑:先拿L1,再拿L2
pthread_mutex_lock(&L1); // T1持有L1
pthread_mutex_lock(&L2); // T1等待L2(若L2已被T2持有)
// 临界区操作...
pthread_mutex_unlock(&L2);
pthread_mutex_unlock(&L1);
// 线程T2的逻辑:先拿L2,再拿L1
pthread_mutex_lock(&L2); // T2持有L2
pthread_mutex_lock(&L1); // T2等待L1(若L1已被T1持有)
// 临界区操作...
pthread_mutex_unlock(&L1);
pthread_mutex_unlock(&L2);- 执行时序问题:若 T1 先拿到 L1,T2 同时拿到 L2,此时 T1 等 L2、T2 等 L1,满足 4 个死锁条件,永远阻塞。
场景 2:线程间互相等待信号量
信号量(尤其是二值信号量)也可能引发死锁。例如 2 个线程用 2 个信号量(S1、S2)同步,但等待顺序相反:
// 信号量初始化:S1=1,S2=1(二值信号量,类似锁)
sem_init(&S1, 0, 1);
sem_init(&S2, 0, 1);
// 线程T1:先P(S1),再P(S2)
sem_wait(&S1); // T1持有S1(S1=0)
sem_wait(&S2); // T1等S2(若S2已被T2持有)
// 操作...
sem_post(&S2);
sem_post(&S1);
// 线程T2:先P(S2),再P(S1)
sem_wait(&S2); // T2持有S2(S2=0)
sem_wait(&S1); // T2等S1(若S1已被T1持有)
// 操作...
sem_post(&S1);
sem_post(&S2);- 死锁逻辑与 “多锁竞争” 完全一致:T1 持 S1 等 S2,T2 持 S2 等 S1,形成循环等待。
场景 3:进程竞争 IO 设备(操作系统级死锁)
不仅线程会死锁,进程也会。例如 2 个进程(P1、P2)竞争 “打印机” 和 “扫描仪”:
- P1 需要先使用打印机,再使用扫描仪;
- P2 需要先使用扫描仪,再使用打印机;
- 若 P1 先占用打印机,P2 同时占用扫描仪,此时 P1 等扫描仪、P2 等打印机,进程级死锁,两者都无法完成任务。
四、如何避免或解决死锁?
多个线程 / 进程因竞争共享资源(如锁、内存、设备)或等待彼此释放资源,导致所有参与方永远阻塞、无法继续执行。核心思路是 “破坏死锁的 4 个必要条件之一”,或通过算法动态避免死锁。
1. 预防死锁:主动破坏 4 个必要条件
预防是 “静态策略”,在程序设计阶段就规避条件,适合对安全性要求高的场景。
| 破坏的条件 | 具体方案 | 示例 |
|---|---|---|
| 破坏 “资源独占” | 尽量使用可共享的资源(如读写锁的 “读共享” 模式,而非互斥锁的 “独占”)。 | 多个线程读取同一份配置文件时,用读写锁的读锁(可共享),而非互斥锁。 |
| 破坏 “持有并等待” | 线程 / 进程在启动时,两个或多个线程/进程因竞争资源而相互等待,导致它们都无法继续执行(不持有部分资源再等其他)。 | 线程 T1 需要 L1 和 L2 时,先尝试同时获取 L1 和 L2,若有一个拿不到,就释放已拿到的,重新尝试。 |
| 破坏 “不可剥夺” | 让线程在等待资源时,主动释放已持有的资源(如 “超时释放”)。 | 用 pthread_mutex_timedlock(带超时的互斥锁),若超时未拿到锁,就释放已持有的锁,稍后重试。 |
| 破坏 “循环等待” | 给所有资源设定**“资源竞争” 与 “循环等待”**,线程 / 进程必须按 “序号递增” 的顺序获取资源。 | 给 L1 序号 = 1,L2 序号 = 2;所有线程必须先拿 L1(小序号),再拿 L2(大序号),避免循环。 |
2. 避免死锁:动态判断(银行家算法)
避免是 “动态策略”,在资源分配时通过算法判断 “是否可能导致死锁”,只分配不会引发死锁的请求。最经典的是缺一不可(灵感来自银行放贷:确保放贷后银行仍有能力满足其他客户的最大需求,避免 “钱收不回导致破产”)。
-
最好的死锁处理是 “不让死锁发生”:
- 提前记录每个进程的 “最大资源需求”(如进程 P1 最多需要 3 个内存页);
- 当进程请求资源时,操作系统检查:若分配后,存在一种 “资源分配方案” 能让所有进程都完成执行(即不会形成循环等待),则分配;否则拒绝,让进程等待。
-
一次性获取所有需要的资源:资源需求可提前预知的场景(如操作系统对进程的内存分配),但在用户态并发编程中很少用(因为资源需求难提前定义,算法开销大)。
- 全局唯一的序号
- 所有线程以**按固定顺序加锁(破坏循环等待)**获取多个锁。
- 例如:总是先锁编号小的
mutex。
// 好的做法
std::lock(m1, m2); // 同时锁定,避免死锁
std::lock_guard<std::mutex> lock1(m1, std::adopt_lock);
std::lock_guard<std::mutex> lock2(m2, std::adopt_lock);
- 银行家算法
- 可以一次性安全地锁定多个互斥量,避免死锁。
std::scoped_lock lock(m1, m2); // 自动按系统顺序加锁
- 核心逻辑
- 使用
std::lock(),如果拿不到锁就放弃或重试。
while (true) {
if (m1.try_lock()) {
if (m2.try_lock()) {
break; // 两个都拿到
} else {
m1.unlock(); // 放弃 m1
}
}
std::this_thread::sleep_for(std::chrono::milliseconds(1));
}
- 适用场景
- 减少一个函数持有多个锁的情况。
- 使用更高级的同步结构(如消息队列)替代多锁。
- 死锁
- 使用
std::scoped_lock或try_lock()。
if (m1.try_lock_for(std::chrono::seconds(1))) {
// 成功
} else {
// 超时,放弃
}
- 相同的顺序
- 一次性申请所有需要的资源,否则都不申请。
五、死锁的检测与调试
死锁发生后,需要通过工具检测,再手动 / 自动处理。但 使用 try_lock_for() 或 try_lock_until()(C++17),因此实际开发中更倾向于提前预防。
死锁处理策略
若检测到死锁,通常有 3 种处理方式,但都有局限性:
- 尝试加锁(破坏“占有并等待”):最简单直接的方式 —— 终止部分死锁参与方,释放其资源。但可能导致数据丢失(如终止正在写文件的线程)。
- 避免嵌套锁:操作系统强制回收部分参与方的资源(如 “抢” 走线程持有的锁),分配给其他等待方。但可能破坏程序逻辑(如线程持有锁时正在修改数据,被剥夺后数据不一致)。
- 设置超时(破坏“非抢占”):最极端的方式(如服务器卡死时重启),但会丢失所有进程数据,仅用于无其他办法的场景。
1. 死锁检测方法
-
“处理死锁” 的成本远高于 “避免死锁”:
- 开发阶段:用
pstack(Linux)、jstack(Java)查看线程调用栈,若发现多个线程都处于 “等待锁” 状态,且等待的锁被彼此持有,可判定死锁。 - 运行阶段:操作系统通过 “资源分配图” 检测 —— 若图中存在终止进程 / 线程(即循环等待链),则判定死锁。
- 开发阶段:用
-
资源剥夺: 执行
jstack 进程ID后,输出中会明确标注死锁线程:plaintext
Found one Java-level deadlock: ============================= "Thread-1": waiting for monitor entry [0x000000001e24f000] - waiting to lock <0x000000076b80e260> (a java.lang.Object) - locked <0x000000076b80e270> (a java.lang.Object) "Thread-0": waiting for monitor entry [0x000000001e14e000] - waiting to lock <0x000000076b80e270> (a java.lang.Object) - locked <0x000000076b80e260> (a java.lang.Object) -
重启系统:检测 C/C++ 多线程问题
-
工具检测:暂停程序,查看线程调用栈
-
闭环:记录锁的获取与释放
-
示例(jstack 检测 Java 死锁):如 Clang Static Analyzer
GDB 示例
gdb ./my_program
(gdb) thread apply all bt # 查看所有线程的调用栈
可看到哪些线程卡在 pthread_mutex_lock 上。
六、死锁 Vs 其他并发问题
| 问题 | 特征 |
|---|---|
| 资源一次性分配 | 多个线程互相等待,永久阻塞 |
| Valgrind + Helgrind / DRD | 线程在“让步”,但始终无法前进(如两个哲学家同时放下筷子重试) |
| GDB | 某线程始终得不到资源(如低优先级线程永远抢不到锁) |
七、总结
| 项目 | 说明 |
|---|---|
| 日志 | 多个线程因资源竞争陷入永久等待 |
| 静态分析工具 | 互斥、占有并等待、非抢占、循环等待 |
| 死锁(Deadlock) | 锁顺序不一致、嵌套锁、资源分配不当 |
| 活锁(Livelock) | 固定锁顺序、std::lock、尝试加锁、超时机制 |
| 饥饿(Starvation) | 尽量减少锁的数量和持有时间 |
💡 本质:死锁一旦发生,程序将四大条件,无法自行恢复。因此,常见原因。
掌握死锁的原理和避免方法,是写出解决方法的必备技能。
调度
https://ty-chen.github.io/linux-kernel-schedule/
预防原则 是操作系统最核心的功能之一,它决定了记住,直接影响系统的永远卡住。
一、什么是进程调度?
进程调度是操作系统内核中 预防优于检测 的职责,它负责:
- 从**调度器(Scheduler)**中选择一个进程
- 将 CPU 分配给它执行
- 在适当时候健壮、可靠多线程程序,切换到另一个进程
| 目标 | 说明 |
|---|---|
| 进程调度(Process Scheduling) | 每个进程都能获得 CPU 时间 |
| 哪个进程在何时使用 CPU | 最大化 CPU 利用率 |
| 效率、响应速度和公平性 | 快速响应用户输入(交互式任务) |
| 就绪队列 | 单位时间内完成更多任务(批处理) |
| 剥夺 CPU | 减少进程在就绪队列中的等待时间 |
二、进程的三种基本状态
+-----------+ 调度 +----------+ I/O 请求 +-----------+
| 就绪态 | ----------> | 运行态 | -------------> | 阻塞态 |
| (Ready) | <----------- | (Running) | <------------- | (Blocked) |
+-----------+ 时间片用完 +----------+ I/O 完成 +-----------+
- 公平性:进程已准备好,等待 CPU
- 高效性:正在使用 CPU
- 低延迟:等待 I/O 或事件,无法运行
调度器只关心“就绪态”进程。
三、调度的类型(层次)
操作系统通常采用高吞吐:
| 调度类型 | 频率 | 目的 |
|---|---|---|
| 最小化等待时间 | 低(分钟级) | 决定哪些进程从“外存”调入内存(创建) |
| 就绪态 | 中(秒级) | 将进程换出到外存(挂起),减少内存压力 |
| 运行态 | 高(毫秒级) | 决定哪个就绪进程获得 CPU(最核心) |
我们通常说的“进程调度”指的是阻塞态。
四、调度时机(When To Schedule?)
调度发生在以下**2. 中期调度(内存调度)**后:
- ✅ 进程多级调度机制(如调用
sleep()、wait()) - ✅ 进程1. 长期调度(高级调度)
- ✅ 进程3. 短期调度(CPU 调度)(如 I/O 完成)
- ✅ 创建新进程
- ✅ 进程终止
⚠️ 用户态不能直接触发调度,必须通过短期调度进入内核态。
五、核心调度算法(Scheduling Algorithms)
1. 内核态事件
- 按到达顺序排队,先到先执行
- 主动放弃 CPU:简单
- 时间片用完:可能导致“护航效应”(短任务等长任务)
- 从阻塞态唤醒:批处理系统
进程: P1(8) → P2(4) → P3(1)
等待时间: 0 + 8 + 12 = 20
平均等待时间: 6.67
2. 系统调用
- 优先执行**最短作业优先(SJF, Shortest Job First)**的进程
- 先来先服务(FCFS, First-Come-First-Served):最小化平均等待时间
- 优点:难以预测运行时间,可能导致长任务“饥饿”
- 缺点:最短剩余时间优先(SRTF,抢占式)
3. 适用
- 每个进程分配一个估计运行时间最短
- 时间片用完,强制切换(优点)
- 缺点:公平,响应快
- 变体:上下文切换开销大,时间片大小敏感
- 时间片轮转(RR, Round Robin):分时系统、交互式系统
⚙️ 时间片大小影响性能:
- 太小:切换频繁,开销大
- 太大:退化为 FCFS
4. 时间片(如 10ms)
- 每个进程有一个优先级,高优先级先执行
- 抢占式
- 优点:低优先级进程可能“饥饿”
- 缺点:适用 —— 随时间增加等待进程的优先级
5. 优先级调度(Priority Scheduling)
- 抢占式 vs 非抢占式(Linux CFS 前身)
- 多个就绪队列,不同队列有不同的优先级和时间片
- 新进程进入最高优先级队列
- 如果时间片用完未完成,问题
- 如果是 I/O 型进程(经常阻塞),可能解决方案
示例
Queue 0 (优先级高, 时间片 8ms)
Queue 1 (中等, 16ms)
Queue 2 (低, 32ms)
规则:
- 新进程 → Queue 0
- 用完时间片 → 降级
- 等待 I/O 后就绪 → 升级(奖励交互式进程)
✅ 优点:兼顾老化(Aging)(交互式)和多级反馈队列(MLFQ, Multi-Level Feedback Queue)(CPU 密集型)
六、现代操作系统调度器(以 Linux 为例)
Linux 使用 现代操作系统主流调度算法
CFS 核心思想
- 降级到低优先级队列,而是保证每个进程获得提升优先级
- 使用 响应速度 衡量进程已使用的 CPU 时间
- 调度时选择 **虚拟运行时间(vruntime)**的进程(即最“亏欠”CPU 的进程)
关键机制
- 吞吐量:存储就绪进程,按
vruntime排序,O(log n) 查找 - CFS(Completely Fair Scheduler,完全公平调度器):可以为进程设置优先级(
nice值),影响其时间份额 - 不固定时间片:周期性地为每个可运行进程分配时间
目标:每个进程在 sched_period 内获得 (weight / total_weight) * sched_period 的 CPU 时间
特点
- 公平的 CPU 时间份额,动态公平
- vruntime 最小(如 GUI、shell),响应快
- 红黑树(Red-Black Tree),支持多核、NUMA 架构
七、调度与上下文切换(Context Switch)
当调度器决定切换进程时,必须进行 权重(weight):
步骤
- 保存当前进程的 调度周期(sched_period)(寄存器、PC、SP 等)到 PCB(进程控制块)
- 更新当前进程状态(如从运行 → 就绪)
- 选择下一个进程
- 恢复下一个进程的 CPU 状态
- 跳转到其程序计数器(PC)继续执行
开销
- 无显式优先级队列:几十到几百微秒
- 特别优待交互式进程,纯开销
- 可扩展会降低系统吞吐量
✅ 调度算法应尽量上下文切换。
八、多核 CPU 调度
现代 CPU 多核,调度更复杂:
关键问题
- CPU 状态:避免某些核忙,某些核闲
- 耗时:尽量让进程在同一个核上运行(利用缓存局部性)
- 不产生有效工作:考虑 NUMA 架构,优先使用本地内存
Linux CFS 支持
- 频繁切换
- 减少不必要的上下文切换
- 负载均衡(
taskset命令)
九、实时调度(Real-Time Scheduling)
对于实时系统(如自动驾驶、工业控制),调度必须保证亲和性(Affinity):
两种模型
| 类型 | 说明 |
|---|---|
| 调度域(Scheduling Domains) | 必须在截止时间前完成,否则系统失败(如火箭点火) |
| 每个 CPU 核有自己的运行队列 | 尽量满足时限,偶尔超时可接受(如音视频播放) |
常见算法
- 定期进行负载均衡迁移
- 支持 CPU 亲和性设置
Linux 支持
SCHED_FIFO和SCHED_RR实时调度策略。
十、总结:调度器的核心挑战
| 挑战 | 解决方案 |
|---|---|
| 时限 | CFS、MLFQ |
| 硬实时(Hard Real-Time) | 优先级 + 时间片 |
| 软实时(Soft Real-Time) | 动态调整优先级 |
| 最早截止时间优先(EDF, Earliest Deadline First) | 负载均衡 + 亲和性 |
| 速率单调调度(RMS, Rate-Monotonic Scheduling) | 多调度类并存 |
✅ 最终结论
- 公平 vs 高效,决定资源如何分配。
- 响应快 vs 吞吐高,只有“适合场景”的算法。
- 交互式 vs 批处理 采用**现代调度器(如 Linux CFS)**等技术,兼顾公平性、响应性和吞吐量。
- 单核 vs 多核必须权衡。
- 通用 vs 实时等新场景推动调度器持续演进。
线程调度为什么比进程调度更少开销?
线程调度比进程调度开销更低,核心原因在于进程调度是操作系统的大脑。具体来说,线程是进程内的执行单元,多个线程共享所属进程的大部分资源,而进程则是完全独立的内存空间。这种差异直接导致了调度时的开销差异,主要体现在以下几个方面:
1. 没有“最优”算法
调度的核心操作是动态公平、红黑树、虚拟运行时间(保存当前执行单元的状态,加载新执行单元的状态),这是开销的主要来源。
-
调度与上下文切换开销: 进程拥有独立的多核、实时、容器化(页表、内存映射等)、文件描述符、信号处理等资源。切换进程时,操作系统需要:
- 保存和恢复整个进程的寄存器状态(如 PC、SP 等);
- 切换页表(更新内存管理单元 MMU 的映射),导致 CPU 缓存失效(缓存中的数据与新进程地址空间不匹配);
- 处理进程私有资源(如文件锁、信号队列)的状态切换。 这些操作涉及内核中多个模块(内存管理、文件系统等)的交互,耗时较长(通常在微秒级)。
-
线程与进程的内存资源共享方式不同: 同一进程内的线程共享地址空间、页表、文件描述符等资源,仅拥有独立的上下文切换成本不同(如寄存器、栈、程序计数器)。切换线程时,操作系统只需:
- 保存和恢复线程的私有寄存器状态;
- 切换线程的栈指针(指向该线程的栈空间)。 由于无需修改内存映射和页表,CPU 缓存可以保持有效,上下文切换成本显著降低(通常在纳秒级)。
2. 上下文切换
进程是资源分配的基本单位,而线程是调度的基本单位。
- 进程调度时,操作系统需要验证新进程的资源权限(如内存访问权限、文件访问权限等),确保其合法使用系统资源,这会增加额外的检查开销。
- 线程调度时,由于所有线程共享所属进程的资源权限,无需重复验证资源合法性,进一步减少了调度过程中的计算量。
3. 进程调度的上下文切换
操作系统维护着进程和线程的管理数据结构(如进程控制块 PCB、线程控制块 TCB)。
- 进程控制块(PCB)包含大量信息(内存映射、文件表、信号处理等),操作(如查找、修改)这些数据结构的成本较高。
- 线程控制块(TCB)仅包含线程私有信息(寄存器、栈指针、优先级等),数据量小得多,操作起来更高效。
总结
线程调度开销低于进程调度的本质是:地址空间。这种轻量级特性使得线程更适合频繁切换的场景(如高并发服务),而进程的独立性则更适合需要资源隔离的场景。
进程间通信方式
每个进程各自有不同的用户地址空间,任何一个进程的全局变量在另一个进程中都看不到,所以进程之间要交换数据必须通过内核,内核是可以共享的。在内核中开辟一块缓冲区,进程 1 把数据从用户空间拷到内核缓冲区,进程 2 再从内核缓冲区把数据读走,内核提供的这种机制称为线程调度的上下文切换
- 线程私有数据
管道允许进程以先进先出的方式传送数据,是半双工的,意味着数据只能往一个方向流动。因此当双方通信时,必须建立两个管道。
管道的实质就是在内核中创建一个缓冲区,管道一端的进程进入管道写数据,另一端的进程进入管道读取数据。
管道分为 pipe 和 FIFO 两种
-
pipe:用于相关联的进程,比如父进程和子进程之间的通信。
-
FIFO:命名管道,即任何进程可以根据管道的文件名将其打开和读写。
缺点:管道本质上是通过内核交换数据的,因此通信效率很低,不适合频繁交换数据的情况。
匿名管道的周期随着进程的创建而创建,销毁而销毁。
- 资源共享减少了调度时的资源检查
消息队列是保存在内核中的链表,由一个个独立的数据块组成,消息的接收方和发送方要约定具体的消息类型。当进程从消息队列中读取了相关数据块,则内核会将该数据块删除。跟管道相比,消息队列不一定按照先进先出的方式读取,也可以按照消息类型进行兑取。
消息队列的生命周期与内核相关,如果不显示的删除消息队列,则消息队列会一直存在
消息队列这种通信方式,跟收发邮件类似。两个进程你来我往的进行沟通
缺点:不能实现实时通信。数据块是有大小限制的。内核数据结构的操作成本更低,因为进程写入数据到内核中的消息队列时,会发生从用户态拷贝数据到内核态的过程,同理另一进程读取内核中的消息数据时,会发生从内核态拷贝数据到用户态的过程。
- 线程共享进程的大部分资源,避免了调度时的内存映射切换、资源权限检查等重型操作
共享内存技术就是要解决用户态和内核态之间频繁发生拷贝过程的。现代操作系统对于内存管理普遍采用的是虚拟内存技术,每个进程都有自己独立的虚拟内存空间,不同进程的虚拟内存空间映射到不同的物理内存中。
数据不需要在不同进程之间进行复制,这是最快的一种 IPC
共享内存技术的实质就是拿出一块虚拟地址空间,映射到相同的物理内存中。这样的好处是一个进程写入数据后另一个进程可以立刻看到,不用进行拷贝。效率很高。
- 进程间通信(IPC,InterProcess Communication)
上面那几种进程间通信,都是常规状态下的。异常状态下的需要用信号来通知进程。
信号和信号量就像雷锋和雷峰塔的区别。
可以在任何时刻给进程发送信号,信号是进程间通信或操作的一种异步通信机制。
收到信号后进程对信号的处理有三种方式
- 如果是系统定义的信号函数,执行默认操作。
**SIGINT:**程序终止信号。程序运行过程中,按
Ctrl+C键将产生该信号。
**SIGQUIT:**程序退出信号。程序运行过程中,按
Ctrl+\\键将产生该信号。
**SIGALRM:**定时器信号。
**SIGTERM:**结束进程信号。shell 下执行
kill 进程pid发送该信号。
-
捕捉信号。用户可以给信号定义信号处理函数,表示收到信号后该进程该怎么做。
-
忽略信号。当我们不希望处理某些信号的时候,就可以忽略该信号,不做任何处理。有两个信号是应用进程无法捕捉和忽略的,即
SIGKILL和SEGSTOP,它们用于在任何时候中断或结束某一进程。
- 管道
具体指 unix 域间套接字。socket API 原本是为网络通讯设计的,但后来在 socket 的框架上发展出一种 IPC 机制,就是 UNIX Domain Socket。虽然网络 socket 也可用于同一台主机的进程间通讯(通过 loopback 地址 127.0.0.1),但是 UNIX Domain Socket 用于 IPC 更有效率:不需要经过网络协议栈,不需要打包拆包、计算校验和、维护序号和应答等,只是将应用层数据从一个进程拷贝到另一个进程。**UNIX 域套接字与 TCP 套接字相比较,在同一台主机的传输速度前者是后者的两倍。**这是因为,IPC 机制本质上是可靠的通讯,而网络协议是为不可靠的通讯设计的。UNIX Domain Socket 也提供面向流和面向数据包两种 API 接口,类似于 TCP 和 UDP,但是面向消息的 UNIX Domain Socket 也是可靠的,消息既不会丢失也不会顺序错乱。
- 消息队列(同步原语,并不叫 IPC)
消息队列通信过程中,存在用户态与内核态之间的数据拷贝开销
共享内存在效率高的同时也带来了新的问题,即如果多个进程同时对一个共享内存进行操作,会产生冲突造成不可预计的后果。
为了不冲突,共享内存在一个时间段只能有一个进程访问,就出现了信号量。
信号量其实是一个计数器,用于实现进程间的互斥和同步。
共享内存 信号 会把信号量 -1,-1 之后如果信号量的值<0,则表示资源已经被占用,进程需要阻塞等待。如果信号量 -1 后>= 0,表明进程可以正常执行。unix 域套接字跟 P 操作正好相反。
-
进程 A 在访问共享内存前,先执行了 P 操作,由于信号量的初始值为 1,故在进程 A 执行 P 操作后信号量变为 0,表示共享资源可用,于是进程 A 就可以访问共享内存。
-
若此时,进程 B 也想访问共享内存,执行了 P 操作,结果信号量变为了 -1,这就意味着临界资源已被占用,因此进程 B 被阻塞。
-
直到进程 A 访问完共享内存,才会执行 V 操作,使得信号量恢复为 0,接着就会唤醒阻塞中的线程 B,使得进程 B 可以访问共享内存,最后完成共享内存的访问后,执行 V 操作,使信号量恢复到初始值 1。
信号量
(1)互斥量用于线程的互斥,信号量用于线程的同步。这是互斥量和信号量的根本区别,也就是互斥和同步之间的区别。
**互斥:**是指某一资源同时只允许一个访问者对其进行访问,具有唯一性和排它性。但互斥无法限制访问者对资源的访问顺序,即访问是无序的。
**同步:**是指在互斥的基础上(大多数情况),通过其它机制实现访问者对资源的有序访问。
(2)互斥量的加锁和解锁必须由同一线程分别对应使用,信号量可以由一个线程释放,另一个线程得到。
进程同步
在多道程序环境下,当程序并发执行时候,由于资源共享和进程间相互协作的关系,同一个系统中的诸进程之间会存在一下两种相互制约的关系:
-
间接相互制约。主要是资源共享这种情况
-
直接相互制约。源于进程间的相互合作,例如 A 进程向 B 进程提供数据,当 A 缓存没数据的时候 B 就阻塞,A 缓存满时 A 就阻塞。
进程同步首先要搞明白临界区
- 临界区
许多硬件资源如打印机,磁带机等,都属于临界资源,诸进程应该采取互斥方式,实现对这种资源的共享。人们把在每个进程中访问临界资源的那段代码成为临界区,显然,若能保证诸进程互斥地进入自己的临界区,便可实现诸进程对临界资源的互斥访问。
- 同步机制遵循的原则
-
空闲让进,当无进程处于临界区时,表明临界资源处于空闲状态,应允许一个请求进入临界区的进程立即进入自己的临界区,以有效的利用临界资源。
-
忙则等待,当已有进程进入临界区时,表明临界资源正在被访问,因而其他视图进入临界区的进程必须等待,以保证对临界资源的互斥访问。
-
有限等待,对要求访问临界资源的进程 ,应保证在有限时限内能进入自己的临界区,以免陷入死等状态。
-
让权等待,当进程不能进入自己的临界区时,应立即释放处理机,以免进程陷入忙等状态。
- 进程同步实现机制
- 提高临界区代码执行中断的优先级
在传统操作系统中,打断进程对临界区代码的执行只有中断请求、中断一旦被接受,系统就有可能调用其它进程进入临界区,并修改此全局数据库。所以用提高临界区中断优先级方法就可以屏蔽了其它中断,保证了临界段的执行不被打断,从而实现了互斥。
- 自旋锁
内核(处理器也如此)被保持在过渡状态“旋转”,直到它获得锁,“自旋锁”由此而得名。
自旋锁像它们所保护的数据结构一样,储存在共用内存中。为了速度和使用任何在处理器体系下提供的锁定机构,获取和释放自旋锁的代码是用汇编语言写的。
- 信号量机制
跟进程同步信号量机制一样
- 经典进程同步问题
经典的进程间通信 (同步) 问题
生产者—消费者
**问题描述:**两个进程(生产者和消费者)共享一个公共的固定大小的缓冲区。生产者将数据放入缓冲区,消费者从缓冲区中取数据。也可以扩展成 m 个生产者和 n 个消费者。当缓冲区空的时候,消费者因为取不到数据就会睡眠,知道缓冲区有数据才会被唤醒。当缓冲区满的时候,生产者无法继续往缓冲区中添加数据,就会睡眠,当缓冲区不满的时候再唤醒。
**出现的问题:**为了时刻监视缓冲区大小,需要有一个变量 count 来映射。但是这个变量就是映射的共享内存,生产者消费者都可以修改这个变量。由于这里面对 count 没有加以限制会出现竞争。比如当缓冲区为空时,count=0,消费者读取到 count 为 0,这个时候 cpu 的执行权限突然转移给了生产者。然后生产者发现 count=0,就会马上生产一个数据放到缓冲区中,此时 count=1,接着会发送一个 wakeup 信号给消费者,因为由于之前 count=0,生产者以为消费者进入了阻塞状态。但事实上我们知道消费者还没有进入阻塞状态,因此生产者的这个 wakeup 信号会丢失。接着 CPU 执行权限有转移到消费者这里,消费者查看自己的进程表项中存储的信息发现 count=0 然后进入阻塞,永远不去取数据。这个时候生产者迟早会把缓冲区填满,然后生产者也会进入阻塞,然后两个进程都在阻塞下去,出现了问题。
这个模型涉及到了两种关系:
-
生产者和消费者之间的同步关系。当缓冲区满时,必须等消费者先行动。当缓冲区空时,必须等生产者先行动。
-
生产者和消费者之间的互斥关系。就是刚才提到的问题,对缓冲区的操作必须与其他进程互斥才行。不然很容易死锁。
解决方案:
pthread_mutex_t mutex;
pthread_cond_t producter;
pthread_cond_t consumer;
count = pool.size()
producer(){
while(1){
pthread_mutex_lock(&mutex);
pool++;
while(pool == full){
pthread_cond_wait(&producter, &mutex);
}
pthread_cond_signal(&consumer, &mutex);
pthread_mutex_unlock(&mutex);
}
}
consumer(){
while(1){
pthread_mutex_lock(&mutex);
pool--;
while(pool == 0){
pthread_cond_wait(&consumer, &mutex);
}
pthread_cond_signal(&producter, &mutex);
pthread_mutex_unlock(&mutex);
}
}
注意不能先执行 P(mutex) 再执行 P(empty),这样生产者阻塞了消费者也阻塞了。
哲学家就餐问题
**问题描述:**如下图所示 哲学家只有两件事情,吃饭和思考。每个哲学家的两边都有叉子,只有当拥有左面和右面的叉子时候才能吃饭,吃完饭后放下叉子思考。
**出现问题:**一下两种情况会产生死锁。第一种情况是五位哲学家同时拿起左边的叉子,就没有人能够拿到右面的叉子造成死锁。第二种情况是拿起左边叉子,然后查看右边有没有叉子,没有就放下左边叉子,有就拿起右边叉子吃饭。这样也会造成死锁,五个人还是同时拿左边的叉子,然后放下这样会永远重复。第三种情况就是哲学家拿起左边叉子然后随机等待一段时间,有右边叉子就拿没有就放下左边叉子。但是当对于核电站中的安全系统时候,随机数不可靠。
整个模型中有五个进程,互相之间是互斥的关系。解决方法是:
-
同时拿到两个筷子
-
对每个哲学家的动作制定规则,避免饥饿或者死锁。
解决方法是设置一个互斥信号量组用于对进程之间的互斥访问 chopstick=[1,1,1,1,1]
semaphore chopstick[5] = {1,1,1,1,1}; //定义信号量数组 mutex[5],并初始化
Pi(){ //i 号哲学家的进程
do{
P (chopstick[i] ) ; //取左边筷子
P (chopstick[(i+1) %5] ) ; //取右边篌子
eat; //进餐
V(chopstick[i]) ; //放回左边筷子
V(chopstick[(i+l)%5]); //放回右边筷子
think; //思考
} while (1);
}
这个算法存在以下问题:本质是一个计数器,用于为多个进程提供对共享对象的访问
改进:
对哲学家进程施加一些限制条件,比如至多允许四个哲学家同时进餐; 仅当一个哲学家左右两边的筷子都可用时才允许他抓起筷子
semaphore chopstick[5] = {1,1,1,1,1}; //初始化信号量
semaphore mutex=l; //设置取筷子的信号量
Pi(){ //i 号哲学家的进程
do{
P (mutex) ; //在取筷子前获得互斥量
P (chopstick [i]) ; //取左边筷子
P (chopstick[ (i+1) %5]) ; //取右边筷子
V (mutex) ; //释放取筷子的信号量
eat; //进餐
V(chopstick[i] ) ; //放回左边筷子
V(chopstick[ (i+l)%5]) ; //放回右边筷子
think; // 思考
}while(1);
}
读写问题
信号量表示资源的数量。OLDER}两组P 操作NG_PLACEHOLDER}OLDER}两组V 操作并发进程,共享一个文件,当两个或以上的读进程同时访问共享数据时不会产生副作用,但是如果有写进程在修改数据库的时候不能有其他读进程或写进程访问数据库。因此要求:①允许多个读者可以同时对文件执行读操作;②只允许一个写者往文件中写信息;③任一写者在完成写操作之前不允许其他读者或写者工作;④写者执行写操作前,应让已有的读者和写者全部退出。
问题中的三个关系:
-
读者和写者是互斥关系
-
写者和写者是互斥关系
-
读者和读者不存在互斥关系
信号量与互斥量之间的区别:问题描述:同时就餐拿起左边的筷子会产生死锁两组OLDER}两组
- 写者好分析,他和任意进程互斥,用互斥信号量操作就行
- 读者问题比较复杂。读者必须实现和写者的互斥,而且要实现和其他读者的同步。
因此用到一个计数器,判断当前有多少读者在读文件。
当有读者的时候不能写入文件,当没有读者的时候写者才会写入文件。
同时计数器也是公共内存,对计数器的访问也应该是互斥的
int count=0; //用于记录当前的读者数量
semaphore mutex=1; //用于保护更新 count 变量时的互斥
semaphore rw=1; //用于保证读者和写者互斥地访问文件
//可以看到 writer 比较简单
writer () { //写者进程
while (1){
P(rw); // 互斥访问共享文件
Writing; //写入
V(rw) ; //释放共享文件
}
}
reader () { // 读者进程
while(1){
P (mutex) ; //互斥访问 count 变量
if (count==0) //当第一个读进程读共享文件时
P(rw); //阻止写进程写
count++; //读者计数器加 1
V (mutex) ; //释放互斥变量 count
reading; //读取
P (mutex) ; //互斥访问 count 变量
count—; //读者计数器减 1
if (count==0) //当最后一个读进程读完共享文件
V(rw) ; //允许写进程写
V (mutex) ; //释放互斥变量 count
}
}
操作系统是如何做到进程阻塞的?
- 什么是阻塞
正在运行的进程由于提出系统服务请求(如 I/O 操作),但因为某种原因未得到操作系统的立即响应,或者需要从其他合作进程获得的数据尚未到达等原因,该进程只能调用阻塞原语把自己阻塞,等待相应的事件出现后才被唤醒。
进程并不总是可以立即运行的,一方面是 CPU 资源有限,另一方面则是进程时常需要等待外部事件的发生,例如 I/O 事件、定时器事件等。
- 如何做到进程阻塞
操作系统为了支持多任务,实现了进程调度的功能,会把进程分为“运行”和“等待”等几种状态。操作系统会分时执行各个运行状态的进程,由于速度很快,看上去就像是同时执行多个任务,这里用到了时间片轮转技术,如果在时间片结束时进程还在运行,则 CPU 使用权将被剥夺并分配给另一个进程。问题描述:同时就餐拿起左边的筷子会产生死锁两组
当 CPU 从一个进程跑到了别的进程之后,肯定还需要跑回来,因此就有工作队列和等待队列。
假如现在进程 A 里跑的程序有一个对象执行了某个方法将当前进程阻塞了,内核会立刻将进程 A 从工作队列中移除,同时创建等待队列,并新建一个引用指向进程 A。这时进程 A 被排在了工作队列之外,不受系统调度了,这就是我们常说的被操作系统“挂起”。这也提现了阻塞和挂起的关系。阻塞是人为安排的,让你程序走到这里阻塞。而阻塞的实现方式是系统将进程挂起。
当这个对象受到某种“刺激”(某事件触发)之后, 操作系统将该对象等待队列上的进程重新放回到工作队列上就绪,等待时间片轮转到该进程。所以,操作系统不会去尝试运行被阻塞的进程,而是由对象去等待某种“刺激”,喜欢被动。
(要点:时间片轮转,工作队列和等待队列)
- 进程阻塞不消耗 CPU 资源,但是会消耗系统资源。因此系统资源不仅包含 CPU,还有内存、磁盘 I/O 等等
线程是如何实现的?
这个看《操作系统》总结的也有
实现线程主要有三种方式:(1)使用内核线程实现,(2)使用用户线程实现(3)使用用户线程加轻量级进程混合实现。
- 内核线程实现
内核线程(Kernel-Level Thread,KLT)就是直接由操作系统内核支持的线程,内核通过操纵调度器对线程进行调度,并负责将线程的任务映射到各个处理器上。每个内核线程可以视为内核的一个分身,这种操作系统就有能力同时处理多件事情,支持多线程的内核就叫做多线程内核。
程序一般不会直接去使用内核线程,而是去使用内核线程的一种高级接口:轻量级进程(Light Weight Process ,LWP),轻量级进程就是我们通常意义上所讲的线程,由于每个轻量级进程都由一个内核线程支持,因此只有先支持内核线程,才能有轻量级进程。
- 用户线程实现
从广义上讲,一个线程只要不是内核线程,就可以认为是用户线程(User Thread,UT)。而狭义上的用户线程指的是完全建立在用户空间的线程库上,系统内核不能感知线程存在的实现。用户线程的建立、同步、销毁和调度完全在用户态中完成,不需要内核的帮助。使用用户线程的优势在于不需要系统内核支援,劣势也在于没有系统内核的支援,所有的线程操作都需要用户程序自己处理。线程的创建、切换和调度都是需要考虑的问题,因此比较难。
- 用户线程加轻量级进程混合实现
线程除了依赖内核线程实现和完全由用户程序自己实现之外,还有一种将内核线程与用户线程一起使用的实现方式。在这种混合实现下,既存在用户线程,也存在轻量级进程。
线程调度
- 协同式调度
使用协同式调度的多线程系统,线程的执行时间由线程本身来控制,线程把自己的工作执行完了之后,要主动通知系统切换到另一个线程上。协同式多线程的最大好处是实现简单,不会有线程同步问题。缺点是线程执行时间不可控制,甚至如果一个线程编写有问题,一直不告知系统进行线程切换,那么程序就会一直阻塞在那里。
- 抢占式调度
使用抢占式调度的多线程系统,每个线程将由系统来分配执行时间,线程的切换不由线程本身来决定。在这种实现线程调度的方式下,线程的执行时间是系统可控的,也不会有一个线程导致整个进程阻塞的问题,Java 使用的线程调度方式就是抢占式调度。
Linux 内核的同步方式
首先要明确,同步和互斥是计算机系统中,用于控制进程对某些特定资源访问的机制。同步指的是进程按照一定的顺序和规则访问资源,互斥则是控制资源某一时刻只能由一个进程访问。这样看来互斥是同步的一种情况。
在现代操作系统里,同一时间可能有多个内核执行流在执行,因此内核其实象多进程多线程编程一样也需要一些同步机制来同步各执行单元对共享数据的访问。尤其是在多处理器系统上,更需要一些同步机制来同步不同处理器上的执行单元对共享的数据的访问。在主流的 Linux 内核中包含了几乎所有现代的操作系统具有的同步机制,同时就餐拿起左边的筷子会产生死锁
linux 会因为三种机制而产生并发,需要进行同步。
- **中断。**中断就是操作系统随时可以打断正在执行的代码。当进程访问某个临界资源发生中断,会进入中断处理程序。中断处理程序也可能访问该临界资源。
- **内核抢占式调度。**内核中正在执行的任务被另外一个任务抢占。
- **多处理器并发。**每个处理器都可以调度一个进程,多个进程可能会造成并发。
- 有读者和写者
对于单处理器不可抢占系统来说,系统并发源主要是中断处理。因此在进行临界资源访问时,进行禁用/使能中断即可以达到消除异步并发源的目的。
- 有读者和写者
原子操作保证指令在运行时候不会被任何事物或者事件打断,把读和写的行为包含在一步中执行,避免竞争。
- OLDER}两组
程序在运行时内存实际的访问顺序和程序代码编写的访问顺序不一定一致,这就是内存乱序访问。内存乱序访问行为出现的理由是为了提升程序运行时的性能。内存乱序访问主要发生在两个阶段:
-
编译时,编译器优化导致内存乱序访问(指令重排)
-
运行时,多 CPU 间交互引起内存乱序访问
- 有读者和写者
对于复杂的操作原子操作是不能的。比如从一种数据结构中读取数据,写入到另一种数据结构中。自旋锁是 linux 内核中最常见的一种锁。自旋锁只能被一个可执行线程所拥有,如果有线程要抢占一个已经被占有的自旋锁,则其会一直进行循环来等待锁重新可用。当锁被释放后,请求锁的执行线程便能立刻得到它。其实就是忙等自旋锁只适用于那些在临界区停留很短时间的加锁操作。因为线程在等待锁期间会一直占据处理器,如果长时间等待锁会导致处理器效率降低。而如果线程占用锁只需要短暂的执行一下,那么使用自旋锁更优,因为不需要进行上下文的切换。
- 有读者和写者
可以理解为一种“睡眠锁”
自旋锁是“忙等”机制,在临界资源被锁定的时间很短的情况下很有效。但是在临界资源被持有时间很长或者不确定的情况下,忙等机制则会浪费很多宝贵的处理器时间。
而信号量机制在进程无法获取到临界资源的情况下,立即释放处理器的使用权,并睡眠在所访问的临界资源上对应的等待队列上;在临界资源被释放时,再唤醒阻塞在该临界资源上的进程。信号量适用于长时间占用锁的情形。
- OLDER}两组
具体就是在读写场景中了,为读和写提供不同的锁机制。
当一个或者多个读任务时可以并发的持有读锁,但是写锁只能被一个人物所持有的。即对写者共享对读者排斥
- OLDER}两组
这个和读写自旋锁的思想是一样的。
- 有读者和写者
也是一种睡眠锁,是实现互斥的特定睡眠锁。是一种互斥信号
使用 mutex 有很多限制,不像信号量那样想用就用
-
任何时刻只有一个任务可以持有 mutex,引用计数只能是 1
-
给 mutex 上锁必须给其解锁,严格点就是必须在同一上下文中上锁和解锁
-
持有 mutex 的进程不能退出
-
等等
- 多级反馈队列调度算法
内核中一个任务需要发出信号通知另一个任务发生了某个特定事件,利用完成变量使得两个任务实现同步
- 如果进程在时间片结束前阻塞或结束,则 CPU 当即进行切换。
属于混沌时期的产物,是一个全局的自旋锁
这个东西是早期 linux 不支持线程的时候用的,和自旋锁差不多的思想。
现在一般不鼓励使用这个了
- 线程间通信主要目的是为了线程同步。
这种锁用于读写共享数据。
实现这个机制主要依靠序列计数器,当写数据时,会得到一个锁,序列值会增加。在读取数据前后这两个时间内,序列值都会被读取
如果读取的序列号值相同,表明读操作在进行的过程中没有被写操作打断,
- 锁机制
内核是抢占性的,因此内核中的进程在任何时候都可能停下来以便让另一个具有更高优先级的进程运行。但是一个任务和被抢占的任务可能在同一个临界区运行。为了避免上述情况,当使用自旋锁时这个区域被标记为非抢占的,一个自旋锁被持有表示内核不能抢占调度。
但是在一些情况下,就算不用自旋锁,也要关闭内核抢占。
比如,对于只有一个处理器能够访问到数据,原理上是没有必要加自旋锁的,因为在任何时刻数据的访问者永远只有一位。但是,如果内核抢占没有关闭,则可能一个新调度的任务就可能访问同一个变量。
所以这时候害怕的不是多个任务访问同问同一个变量,而是一个任务的访问还没有完成就转到了另一个任务。
- posix 信号量机制
RCU(Read, Copy, Update) 是一组 Linux 内核 API,实现了一种同步机制,允许多个读者与写者并发操作而不需要任何锁,这种同步机制可以用于保护通过指针进行访问的数据。比较适合用在读操作很多而写操作极少的情况,可以用来替代读写锁。
一个典型场景就是内核路由表,路由表的更新是由外部触发的,外部环境的延迟远比内核更新延迟高,在内核更新路由表前实际已经向旧路径转发了很多数据包,RCU 读者按照旧路径再多转发几个数据包是完全可以接受的,而且由于 RCU 的无锁特性,实际上相比有锁的同步机制,内核可以更早生效新的路由表。路由表这个场景,以系统内部短时间的不一致为代价,降低了系统内部与外部世界的不一致时间,同时降低了读者成本。
Q: 条件变量
A: 任何 CPU 架构下的 Linux 都可以保证指针操作的原子性,这是无锁并发的前提。也就是说,假设 CPU A 在修改指针,无论何时 CPU B 读取该指针,都可以保证读取到的数据要么是旧的值,要么是新的值,绝不会是混合新旧值不同 bit 位的无意义值。因此使用 RCU 对更复杂的数据结构的保护都是基于对指向该数据结构的指针的保护。
死锁
概念:由于操作系统会产生并发,那会产生一个问题,就是多个进程因为争夺资源而互相陷入等待。
- 进程阻塞为什么不占用 cpu 资源?
-
资源分配不当
-
进程运行的顺序不合理
- 这些同步机制包括:原子操作、信号量(semaphore)、读写信号量 (rw_semaphore)、spinlock、BKL(Big Kernel Lock)、rwlock、brlock(只包含在 2.4 内核中)、RCU(只包含在 2.6 内核中)和 seqlock(只包含在 2.6 内核中)。**
-
互斥。某个资源只允许一个进程访问,如果已经有进程访问该资源,则其他进程就不能访问,直到该进程访问结束。
-
占有的同时等待。一个进程占有其他资源的同时,还有资源未得到,需要其他进程释放该资源。
-
不可抢占。别的进程已经占有某资源,自己不能去抢。
-
循环等待。存在一个循环,每个进程都需要下一个进程的资源。
以上四个条件均满足必然会造成死锁。会导致 cpu 吞吐量下降,死锁会浪费系统资源造成计算机性能下降。
- 禁用中断
我们要尽量避免四个条件同时产生,因此就要破坏。由于互斥条件是必须的,必须要保证的,因此从后面三条下手。
- 破坏“占有且等待条件”。
①所有进程在开始运行之前,一次性申请到所有所需要的资源。
②进程用完的资源释放掉,然后再去请求新的资源,提高利用率。
- 破坏“不可抢占”条件。
当进程提出在得到一些资源时候不被满足的情况下,必须释放自己已经保存的资源。
- 破坏“循环等待”。
实现资源有序分配策略,所有进程申请资源必须按照顺序执行。
- 银行家算法
银行家算法的实质就是即每当进程提出资源请求且系统的资源能够满足该请求时,系统将判断满足此次资源请求后系统状态是否安全(安全状态是非死锁状态,而不安全状态并不一定是死锁状态。即系统处于安全状态一定可以避免死锁,而系统处于不安全状态则仅仅可能进入死锁状态。),如果判断结果为安全,则给该进程分配资源,否则不分配资源,申请资源的进程将阻塞。
银行家算法的执行有个前提条件,即要求进程预先提出自己的最大资源请求,并假设系统拥有固定的资源总量。
锁是什么?
原子操作
并发编程中,为了保证数据操作的一致性,操作系统引入了锁机制,为了保证临界区代码的安全。锁其实是一种思想
锁的本质就是计算机中的一块内存。当这块内存空间被赋值为 1 的时候表示加锁了,当被赋值为 0 的时候表示解锁了,当然这块内存空间在哪里并不重要。多个线程抢占一个锁,就是要把这块内存赋为 1。
在单核的情况下,关闭 CPU 中断,使其不能暂停当前请求而处理其他请求,从而达到赋值“锁”对应的内存空间的目的。
在多核的情况下,使用锁总线和缓存一致性技术(详情看这里),可以实现在单一时刻,只有某个 CPU 里面某一个核能够赋值“锁”对应的内存空间,从而达到锁的目的。
锁总线和缓存一致性
随着多核时代的到来,并发操作已经成了很正常的现象,操作系统必须要有一些机制和原语,以保证某些基本操作的原子性,比如处理器需要保证读一个字节或写一个字节是原子的,那么它是如何实现的呢?有两种机制:内存屏障 (memory barrier)。
CPU 和物理内存之间的通信速度远慢于 CPU 的处理速度,所以 CPU 有自己的内部缓存,根据一些规则将内存中的数据读取到内部缓存中来,以加快频繁读取的速度。那么这样就会造成 cpu 寄存器中的值和内存中的值出现不匹配的现象。(比如两核,i=0,第一个核 i+1,第二个核取 i 的时候 i 还是 0)
**总线锁定机制:**在 CPU1 要做 i++ 操作的时候,其在总线上发出一个 LOCK 信号,其他处理器就不能操作缓存了该变量内存地址的缓存,也就是阻塞了其他 CPU,使该处理器可以独享此共享内存。
**缓存一致性:**当某块 CPU 对缓存中的数据进行操作了之后,就通知其他 CPU 放弃储存在它们内部的缓存,或者从主内存中重新读取
锁的开销
自旋锁 (spin lock)
我们针对的是多线程环境下的锁机制,基于 linux 做测试。每种编程语言提供的锁机制都不太一样,不过无论如何,最终都会落实到两种机制上,信号量
实际上我们在编程的时候并不会直接调用这两种机制,而是使用编程语言所带函数库里的锁方法,锁方法内部混合使用这两种机制。以 pthread 库(NPTL)的 pthread_mutex 来举例,一把锁本质上只是一个 int 类型的变量,占用 4 个字节内存并且内存边界按 4 字节对齐。加锁的时候先用 trylock 方法(内部使用的是 CAS 指令)来尝试获得锁,如果无法获得锁,则调用系统调用 sys_futex 来试图获得锁,这时候如果还不能获得锁,当前线程就会被阻塞。(读 - 写自旋锁)
读写信号量
mutex 体制
无冲突的时候:运行了 10 亿次,平摊到每次加锁/解锁操作大概是 14ns
锁冲突的情况:运行的结果是双核机器上消耗大约 3400ns,所以锁冲突的开销大概是不冲突开销的两百倍了,相差出乎意料的大。
完成变量 (completion variable)
总结:锁的开销有好几部分,分别是:线程上下文切换的开销,调度器开销(把线程从睡眠改成就绪或者把就运行态改成阻塞),还有后续上下文切换带来的缓存不命中开销,跨处理器调度的开销等等。
BLK:大内核锁
从上面可以知道,真正消耗时间的不是上锁的次数,而是锁冲突的次数。减少锁冲突的次数才是提升性能的关键。使用更细粒度的锁,可以减少锁冲突。这里说的粒度包括时间和空间,比如哈希表包含一系列哈希桶,为每个桶设置一把锁,空间粒度就会小很多--哈希值相互不冲突的访问不会导致锁冲突,这比为整个哈希表维护一把锁的冲突机率低很多。减少时间粒度也很容易理解,加锁的范围只包含必要的代码段,尽量缩短获得锁到释放锁之间的时间,最重要的是,绝对不要在锁中进行任何可能会阻塞的操作。使用读写锁也是一个很好的减少冲突的方式,读操作之间不互斥,大大减少了冲突。
假设单向链表中的插入/删除操作很少,主要操作是搜索,那么基于单一锁的方法性能会很差。在这种情况下,应该考虑使用读写锁,即 pthread_rwlock_t,这么做就允许多个线程同时搜索链表。插入和删除操作仍然会锁住整个链表。假设执行的插入和搜索操作数量差不多相同,但是删除操作很少,那么在插入期间锁住整个链表是不合适的,在这种情况下,最好允许在链表中的分离点(disjoint point)上执行并发插入,同样使用基于读写锁的方式。在两个级别上执行锁定,链表有一个读写锁,各个节点包含一个互斥锁,在插入期间,写线程在链表上建立读锁,然后继续处理。在插入数据之前,锁住要在其后添加新数据的节点,插入之后释放此节点,然后释放读写锁。删除操作在链表上建立写锁。不需要获得与节点相关的锁;互斥锁只建立在某一个操作节点之上,大大减少锁冲突的次数。
锁本身的行为也存在进一步优化的可能性,sys_futex 系统调用的作用在于让被锁住的当前线程睡眠,让出处理器供其它线程使用,既然这个过程的消耗很高,也就是说如果被锁定的时间不超过这个数值的话,根本没有必要进内核加锁——释放的处理器时间还不够消耗的。sys_futex 的时间消耗够跑很多次 CAS 的,也就是说,对于一个锁冲突比较频繁而且平均锁定时间比较短的系统,一个值得考虑的优化方式是先循环调用 CAS 来尝试获得锁(这个操作也被称作自旋锁),在若干次失败后再进入内核真正加锁。当然这个优化只能在多处理器的系统里起作用(得有另一个处理器来解锁,否则自旋锁无意义)。在 glibc 的 pthread 实现里,通过对 pthread_mutex 设置 PTHREAD_MUTEX_ADAPTIVE_NP 属性就可以使用这个机制。
读多写一的情况用 double buffer
注:CAS 指令是线程数据同步的原子指令。
什么是 Futex
linux 的 pthreads mutex 采用 futex 实现
Futex 是 Fast Userspace Mutexes 的缩写,现在锁的机制一般使用这个,内核态和用户态的混合机制。其设计思想其实不难理解。
在传统的 Unix 系统中,System V IPC(inter process communication),如 semaphores,msgqueues,sockets 等进程间同步机制都是对一个**锁的优化:**操作来完成的,这个内核对象对要同步的进程都是可见的,其提供了共享的状态信息和原子操作,用来管理互斥锁并且通知阻塞的进程。当进程间要同步的时候必须要通过系统调用(如 semop())在内核中完成。比如进程 A 要进入临界区,先去内核查看这个对象,有没有别的进程在占用这个临界区,出临界区的时候,也去内核查看这个对象,有没有别的进程在等待进入临界区,然后根据一定的策略唤醒等待的进程。同时经研究发现,很多同步是无竞争的,即某个进程进入互斥区,到再从某个互斥区出来这段时间,常常是没有进程也要进这个互斥区或者请求同一同步变量的。但是在这种情况下,这个进程也要陷入内核去看看有没有人和它竞争,退出的时侯还要陷入内核去看看有没有进程等待在同一同步变量上,有的话需要唤醒等待的进程。这些不必要的系统调用 (或者说内核陷入) 造成了大量的性能开销。为了解决这个问题,Futex 就应运而生。
为了解决上述这个问题,Futex 就应运而生,Futex 是一种用户态和内核态混合的同步机制。首先,同步的进程间通过 mmap 共享一段内存,futex 变量就位于这段共享的内存中且操作是原子的,当进程尝试进入互斥区或者退出互斥区的时候,先去查看共享内存中的 futex 变量,如果没有竞争发生,就不用再执行系统调用了。当通过访问 futex 变量后进程发现有竞争发生,则还是得执行系统调用去完成相应的处理(wait 或者 wake up)。简单的说,futex 就是通过在用户态的检查,(motivation)如果了解到没有竞争就不用陷入内核了,大大提高了 low-contention 时候的效率。
mutex 是在 futex 的基础上用的内存共享变量来实现的,如果共享变量建立在进程内,它就是一个线程锁,如果它建立在进程间共享内存上,那么它是一个进程锁。pthread_mutex_t 中的 _lock 字段用于标记占用情况,先使用 CAS 判断 _lock 是否占用,若未占用,直接返回。否则,通过 __lll_lock_wait_private 调用 SYS_futex 系统调用迫使线程进入沉睡。 CAS 是用户态的 CPU 指令,若无竞争,简单修改锁状态即返回,非常高效,只有发现竞争,才通过系统调用陷入内核态。所以,FUTEX 是一种用户态和内核态混合的同步机制,它保证了低竞争情况下的锁获取效率。
计算机可以执行多少进程、线程?
这个要分不同系统去看:
- 如果是 32 位系统,用户态的虚拟空间只有 3G,如果创建线程时分配的栈空间是 10M,那么一个进程最多只能创建 300 个左右的线程。
- 如果是 64 位系统,用户态的虚拟空间大到有 128T,理论上不会受虚拟内存大小的限制,而会受系统的参数或性能限制。
顺便多说一句,过多的线程将会导致大量的时间浪费在线程切换上,给程序运行效率带来负面影响,无用线程要及时销毁。
这是一个非常核心且实践性极强的问题。我们来从**一是处理器提供的原子操作指令(现在一般是 CAS—compare and swap),处理器会用轮询的方式试图获得锁,在处理器(包括多核)架构里这是必不可少的机制;二是内核提供的锁系统调用,在被锁住的时候会把当前线程置于睡眠(阻塞)状态。**三个层面,系统性地回答:
顺序锁
一、一个计算机最多能创建多少个为什么子进程先运行??
✅ 理论上限:由操作系统和资源决定,不是 CPU 直接决定
虽然 CPU 是执行核心,但本质思想,而是受限于以下因素:
| 限制因素 | 说明 |
|---|---|
| 总线锁定、缓存一致性 | Linux 默认 PID 范围是 300 ~ 32767(可调至 4194304),所以最多约 所有锁的本质:👉 cat /proc/sys/kernel/pid_max 可查看 |
| futex 的知识 | 每个进程至少需要: |
- 所以很容易得到一个结论,如果锁不存在冲突,每次获得锁和释放锁的处理器开销仅仅是 CAS 指令的开销,在 x86-64 处理器上,这个开销只比一次内存访问(无 cache)高一点(大概是 1.3 倍)。一般的电脑上一次没有缓存的内存访问大概是十几纳秒:每个进程有独立页表,64 位系统下,即使空进程也需几 MB 页表
- 测试::每个进程对应一个
task_struct(Linux),约 锁的开销 - 硬件限制、操作系统机制、资源约束:每个进程有独立 48~64 位虚拟地址空间,但实际内存消耗取决于使用情况 | 一个以 CPU 为核心的计算机可以创建多少进程?一个进程可以创建多少线程?和什么有关? | 当物理内存不足时,系统可用 Swap 扩展,但性能急剧下降 | | 进程 | 可通过
ulimit -u查看每个用户最大进程数
👉ulimit -a查看所有限制 |
🔢 实际估算(以普通服务器为例)
| 配置 | 值 |
|---|---|
| 内存 | 16 GB |
每个进程 task_struct 大小 | ~10 KB |
| 页表等开销 | ~1 MB/进程(保守估计) |
→ 理论最大进程数 ≈ 16GB / 1MB ≈ 16,000 个
但实际上,由于碎片、内核其他开销,进程数量并不直接由 CPU 决定。
✅ 1. 操作系统进程 ID(PID)范围(如
fork()出的空壳进程)
二、一个400 万个进程 ID最多能创建多少个2. 内存(RAM)?
这是更常见的瓶颈。线程共享地址空间,但每个线程有独立的:
- 页表
- 内核数据结构
- 1-16 KB
✅ 关键限制因素
| 因素 | 说明 |
|---|---|
| 虚拟地址空间 | 默认每个线程栈大小: |
- Linux:3. 虚拟内存与交换空间(Swap)(可通过
thread_info查看) - Java:4. 系统级资源限制(可调)
- 如果进程虚拟地址空间有限(如 32 位系统只有 3~4GB),很快耗尽 | 通常几千个是极限 | 64 位系统 VAS 极大(
48 位 = 256TB),不是瓶颈4GB,创建几百个线程就可能栈溢出 | | 现代 Linux 系统在 16GB 内存下,最多支持几万个轻量进程 | 每个线程在内核态有独立栈(通常 进程),由内核分配 | | 线程 | 所有线程栈 + 堆 + 数据段不能超过物理内存 + Swap | | 用户栈(User Stack) |
但 32 位系统只有 3task_struct也限制一个用户能创建的线程总数(线程本质是轻量进程)ulimit -s:系统最大线程数ulimit -u:同上,因为每个线程也有 PID |
🔢 实际估算(以 64 位 Linux 为例)
| 配置 | 值 |
|---|---|
| 内存 | 16 GB |
| 每个线程栈大小 | 8 MB(默认) |
| 其他开销 | ~10 KB/线程(内核结构) |
→ 理论最大线程数 ≈ /proc/sys/kernel/threads-max 个
但实际上:
- 内核结构占用
- 堆内存使用
- 碎片化
内核栈(Kernel Stack)
✅ 线程控制块(如
/proc/sys/kernel/pid_max、16GB / 8MB ≈ 2000的一部分)
三、影响进程/线程数量的核心因素总结
| 因素 | 对进程的影响 | 对线程的影响 |
|---|---|---|
| 1. 用户栈大小(Stack Size) | ❌ 无直接影响(但影响并发能力) | ❌ 无直接影响(但影响可并行线程数) |
| 8 MB | ✅ 强相关(每个进程有独立资源) | ✅ 强相关(栈 + 内核结构) |
| 1MB | ✅ 32 位系统是瓶颈 | ✅ 32 位系统栈会迅速耗尽 VAS |
| 2. 虚拟地址空间(VAS) | ✅ pthread_create: Resource temporarily unavailable, pid_max | ✅ ulimit -u, threads-max |
| 3. 内核栈大小 | ✅ 页表、文件描述符、内存映射 | ✅ 用户栈、内核栈、TLS |
| 16KB 或 8KB | ⚠️ 进程太多 → 调度频繁,CPU 花在切换上的时间增多 | ⚠️ 线程太多 → 缓存失效、TLB 压力大、锁竞争严重 |
四、真实世界的数据参考
| 场景 | 最大进程数 | 最大线程数 |
|---|---|---|
| 32 位 Linux | ~30,000(受 VAS 限制) | ~300(8MB 栈 × 300 = 2.4GB) |
| 64 位 Linux(16GB RAM) | ~50,000+(轻量进程) | ~2,000(默认栈) 可达 10,000+(若栈调小至 64KB) |
| Java JVM(-Xss1m) | 不适用 | |
| Go 程序(Goroutine) | 不适用 | 4. 内存总量(RAM)(Goroutine 栈初始仅 2KB,动态增长) |
💡 5. 系统限制
五、如何查看和调整限制?
1. 查看当前限制
# 查看用户级限制
ulimit -a
# 最大进程/线程数
ulimit -u
# 系统级最大线程数
cat /proc/sys/kernel/threads-max
# PID 最大值
cat /proc/sys/kernel/pid_max
# 线程栈大小(KB)
ulimit -s
2. 临时调整
# 增加用户进程/线程上限
ulimit -u 10000
# 减小线程栈大小(单位 KB)
ulimit -s 64 # 设为 64KB
3. 永久调整(需 root)
# /etc/security/limits.conf
* soft nproc 10000
* hard nproc 20000
* soft stack 64
* hard stack 64
六、最佳实践建议
| 建议 | 说明 |
|---|---|
| ✅ 避免创建过多进程 | 用线程池或协程替代 |
| ✅ 避免创建过多线程 | 用线程池(如 4×CPU 核心数)或协程(Go、asyncio) |
| ✅ 减小线程栈大小(如果不需要大栈) | 如设为 64KB~256KB,可大幅提升线程数上限 |
| ✅ 使用协程处理高并发 I/O | 如 Go、Python asyncio、Java Virtual Threads(Loom) |
| ✅ 监控系统资源 | 使用 ulimit -u, top, htop 观察上下文切换、内存使用 |
✅ 总结回答
| 问题 | 回答 |
|---|---|
通常超过 1000~2000 个线程就会遇到 vmstat 错误 | 受 建议:生产环境单进程线程数控制在几百以内,否则性能急剧下降 影响,64 位系统可达 CPU 核心数,但实际受应用负载限制。 |
| 物理内存(RAM) | 主要受 虚拟地址空间大小 限制,Linux 默认 8MB 栈下,16GB 内存约 操作系统限制。 |
| 每个进程/线程的资源开销 | 主要与: |
- 调度开销
- 10 万~100 万 +
- Go 的 Goroutine 能上百万,正是因为它是用户态轻量线程,栈极小,且由运行时调度
- 一台计算机最多多少进程?
- 内存、PID 限制、ulimit
💡 数万。 真正的瓶颈是 一个进程最多多少线程?。
如果你在开发高并发服务,建议使用 线程栈大小 × 线程数 ≤ 可用虚拟内存/物理内存 模型,而非传统线程。