1. 并发模型基础:进程、线程、协程的概念与区别,并发与并行的本质
各位同学好,我是你们这门课的老朋友。今天咱们聊聊并发模型的基础。说实话,我见过太多人在并发上栽跟头了——不是概念混淆,就是选错了模型。咱们先把地基打牢。
1.1 并发与并行:别再搞混了
先问个问题:你电脑上同时开着浏览器、IDE、,这叫并发还是并行?
很多人张口就来「并行」。其实不对。你想想看,单核 CPU 同一时刻只能干一件事。那为什么我们感觉「同时」在运行?因为操作系统在快速切换——给浏览器 10ms,给 IDE 10ms,再给 10ms。切换得够快,人就感觉是同时的。
并发(Concurrency),说白了就是「看起来同时做多件事」。它关注的是任务的交错执行。
并行(Parallelism),才是「真的同时做多件事」。它需要多核 CPU 或多台机器,每个核跑一个任务,谁也不等谁。
核心区别一句话:
- 并发是逻辑上的「同时」——任务交替执行,宏观并行,微观串行。
- 并行是物理上的「同时」——任务真正一起跑。
我记得有一次帮一个团队排查性能问题。他们的服务 QPS 上不去,加机器也没用。一看代码,所有请求处理都用了锁,导致多核 CPU 大部分时间在等待。这就是典型的「并发设计,但没实现并行」。加再多核也没用,因为任务根本没真正并行起来。
1.2 进程:最重的「隔离舱」
进程是什么?你可以把它想象成一个独立的「小世界」。每个进程有自己的内存空间、文件描述符、环境变量。进程之间互不干扰——一个进程崩了,其他进程照常运行。
但代价也大:
- 创建开销大:需要分配独立的内存空间、初始化各种数据结构。我做过测试,Linux 上 fork 一个进程大概要几十微秒到几百微秒。
- 切换开销大:上下文切换要保存和恢复寄存器、内存映射、页表等。一次进程切换大概在 1-10 微秒级别。
- 通信麻烦:进程间通信(IPC)得用管道、消息队列、共享内存、Socket 等。写起来费劲,性能也有损耗。
那什么时候用进程?
- 需要强隔离的场景,比如浏览器每个标签页一个进程,一个标签页崩溃不会影响其他标签页。
- 多机部署时,每个实例本质上就是一个进程。
- 需要不同用户权限隔离的场景。
1.3 线程:轻量级的「车间工人」
线程是进程内部的执行单元。同一个进程的线程共享内存空间、文件描述符等资源。你可以把进程想象成一个工厂,线程就是工厂里的工人——共享厂房和设备,但各自干各自的活。
线程的优势很明显:
- 创建快:创建线程只需要分配栈空间和少量控制结构,Linux 上 pthread_create 大概几微秒。
- 切换快:线程切换只保存寄存器、程序计数器等,不涉及内存映射切换。一次线程切换大概在 0.1-1 微秒。
- 通信方便:共享内存,直接读写全局变量就行。
但线程也有坑:
- 同步问题:共享内存意味着要加锁、用信号量、条件变量。一不小心就死锁、活锁、数据竞争。
- 资源限制:每个线程默认栈空间 8MB(Linux 上),1000 个线程就是 8GB 内存。线程多了,光是栈就吃死你。
- 上下文切换成本:线程数超过 CPU 核数后,切换成本会急剧上升。我见过一个项目开了 5000 个线程,CPU 大部分时间在切换,真正干活的时间不到 10%。
我的经验:线程数一般控制在 CPU 核数的 2-4 倍比较合理。太多反而性能下降。如果你发现 CPU 使用率不高但系统响应慢,先看看线程数是不是太多了。
1.4 协程:用户态的「轻骑兵」
协程,也叫纤程、绿色线程。它跟线程最大的区别是:协程的调度在用户态,不在内核态。
什么意思?线程的创建、切换、销毁都由操作系统内核管理。而协程的调度由程序自己控制——你可以在代码里显式地让出执行权(yield),或者等 I/O 操作完成时自动切换。
协程的优势:
- 极轻量:一个协程的栈空间可以小到几 KB(Go 的 goroutine 初始栈只有 2KB)。创建百万个协程都没问题。
- 切换极快:协程切换只是保存几个寄存器和栈指针,不涉及系统调用。一次协程切换大概在几十纳秒级别。
- 没有锁的烦恼:协程是协作式调度,同一时刻只有一个协程在运行,很多场景下不需要加锁。
但协程也不是万能的:
- 不能利用多核:单线程的协程只能跑在一个核上。要利用多核,得配合多线程使用(比如 Go 的 GMP 模型)。
- 阻塞操作会卡住所有协程:如果一个协程执行了阻塞的系统调用(比如 read 文件),整个线程都会被阻塞,其他协程也跑不了。
- 调试困难:协程的调用栈不像线程那么直观,出问题的时候排查起来比较头疼。
避坑指南:我曾经在一个项目里用 Python 的 gevent 做高并发服务。协程跑得挺欢,但一遇到 C 扩展库的阻塞调用,整个服务就卡死了。后来才发现,不是所有库都对协程友好。选协程框架前,一定要确认你的依赖库是否支持。
1.5 三者的对比
咱们用一张表来总结一下:
| 维度 | 进程 | 线程 | 协程 |
|---|---|---|---|
| 调度者 | 操作系统内核 | 操作系统内核 | 用户程序(运行时) |
| 创建开销 | 高(几十~几百微秒) | 中(几微秒) | 极低(几十纳秒) |
| 切换开销 | 高(1-10 微秒) | 中(0.1-1 微秒) | 极低(几十纳秒) |
| 内存占用 | 高(独立地址空间) | 中(共享地址空间,独立栈) | 低(共享栈,可动态增长) |
| 通信方式 | IPC(管道、消息队列等) | 共享内存 + 锁 | 共享内存 + 通道(Channel) |
| 隔离性 | 强(独立内存空间) | 弱(共享内存) | 弱(共享内存) |
| 适用场景 | 多机部署、强隔离需求 | CPU 密集型、多核利用 | I/O 密集型、高并发连接 |
1.6 一张图看懂并发模型
下面这张 SVG 图展示了进程、线程、协程在调度和资源隔离上的核心差异:
1.7 如何选择?我的建议
说了这么多,到底该用哪个?我个人的经验是这样的:
- I/O 密集型服务(比如 Web 服务器、API 网关、消息队列消费者):优先考虑协程。Go 的 goroutine、Java 的虚拟线程、Python 的 asyncio 都是好选择。协程能轻松处理数万并发连接,而线程到几千就开始吃力了。
- CPU 密集型计算(比如视频编码、科学计算、加密解密):用线程,数量等于 CPU 核数。协程在这里帮不上忙,因为计算不涉及 I/O 等待。
- 需要强隔离的场景(比如运行用户提交的代码、多租户服务):用进程。一个进程崩了不影响其他进程。Docker 容器本质上也是进程级别的隔离。
- 混合场景:很多现代框架是「多线程 + 协程」的组合。比如 Go 的 GMP 模型——用多线程利用多核,在每个线程上跑协程处理高并发 I/O。这是目前比较成熟的方案。
一个小技巧:如果你不确定选哪个,先问自己三个问题:
- 我的服务主要等什么?(等 CPU 还是等 I/O?)
- 我需要多强的隔离性?(一个请求崩了,能接受整个服务重启吗?)
- 我的团队熟悉哪种模型?(再好的技术,团队不会用也是白搭。)