进程、线程和协程有什么区别?
一句话回答
进程是资源分配的单位,有独立的地址空间、文件描述符表等资源;线程是 CPU 调度的单位,同一进程的线程共享地址空间和打开的文件,各自只有栈、寄存器和线程局部存储。协程是用户态调度的执行单元,切换不进内核,由语言运行时或程序自己决定什么时候让出。切换开销是进程 > 线程 > 协程;隔离性正好反过来,一个线程崩溃会带走整个进程,进程之间互不影响。
详细解析
资源怎么分
| 资源 | 同一进程的线程之间 |
|---|---|
| 虚拟地址空间(代码段、全局变量、堆) | 共享 |
| 文件描述符表、当前工作目录 | 共享 |
| 信号处理函数、进程 ID | 共享 |
| 栈、寄存器(含 PC、SP) | 每个线程独立 |
线程 ID、信号屏蔽字、errno、线程局部存储 |
每个线程独立 |
进程之间默认什么都不共享,要交换数据就得借助 进程间通信。线程之间直接读写同一块内存,通信成本低,代价是要用锁保护共享数据,否则会出现竞态,锁用不好又会 死锁。
在 Linux 内核里,进程和线程都用 task_struct 表示,调度器统一调度。区别在于创建时 clone() 传的标志:线程带上 CLONE_VM、CLONE_FILES、CLONE_THREAD 等,表示和父任务共享地址空间、文件表,并归入同一个线程组。所以 top -H 能看到每个线程,它们在内核眼里都是可调度的任务。
切换开销
| 进程切换 | 线程切换(同进程) | 协程切换 | |
|---|---|---|---|
| 是否进内核 | 是 | 是 | 否,在用户态完成 |
| 保存的内容 | 寄存器、内核栈等 | 寄存器、内核栈等 | 少量寄存器(PC、SP 等) |
| 切换页表 | 要,TLB 中的地址映射大多失效 | 不用,地址空间相同 | 不用 |
| 创建成本 | 高:复制页表等(写时复制) | 中:要分配栈和内核结构 | 低:一小块内存 |
用 glibc 的 pthread 创建线程时,默认栈大小取进程启动时的 ulimit -s(常见是 8MB;设成 unlimited 时改用架构默认值,x86-64 上是 2MB),Java、Go 这类运行时会自己设置线程栈大小。这是预留的虚拟内存,实际按需占用物理内存。即使如此,开几万个线程也会给内存和调度带来明显压力,详见 上下文切换。
协程
协程把"什么时候切换"交给程序:遇到 I/O 等待时主动让出,运行时再挑一个就绪的协程接着执行。一个线程上可以跑大量协程,所以适合高并发的 I/O 场景。
- 有栈协程:每个协程有自己的栈,可以在任意深度的函数调用里挂起,例如 Go 的 goroutine。Go 运行时用 GMP 模型把大量 goroutine 调度到少量线程上,还能利用多核,见 GMP 调度模型
- 无栈协程:编译器把函数改写成状态机,只能在
await这类标记处挂起,例如 JS、Python、Rust 的 async/await
协程本身不能让 CPU 密集的计算变快。一个协程长时间占着线程不让出,同一线程上的其他协程都得等(Go 后来加入了基于信号的异步抢占来缓解这个问题)。
多进程还是多线程
| 多进程 | 多线程 | |
|---|---|---|
| 隔离性 | 好,一个进程崩溃不影响其他进程 | 差,一个线程段错误,整个进程退出 |
| 数据共享 | 要用 IPC,相对麻烦 | 直接共享内存,要加锁 |
| 资源占用 | 每个进程一套地址空间 | 共享,较省 |
| 典型例子 | Nginx 的 master + worker,PostgreSQL 每个连接一个进程,浏览器的多进程架构 | Java、MySQL 的线程池 |
两个后端常见的例子:
- Node.js:一个进程里只有一个线程执行 JS,靠事件循环处理并发,要用满多核就开多个进程(cluster、PM2)或 worker_threads,见 Node.js 是单线程的吗
- Go:一个进程,少量系统线程,上面跑成千上万个 goroutine,写同步风格的代码就能获得高并发
代码示例
在 Linux 上观察一个进程有多少线程:
# NLWP 列是线程数
ps -o pid,nlwp,cmd -p 1234
# 每个线程在 /proc 里对应 task 下的一个目录
ls /proc/1234/task
# 按线程查看 CPU 占用,PID 列显示的是线程 ID
top -H -p 1234
面试官可能追问
fork 出来的子进程和父进程共享什么?
子进程得到父进程地址空间的一份"副本",但不是立即复制:父子共享物理页,页表都标成只读,谁先写就复制哪一页,这就是写时复制,见 虚拟内存。文件描述符会被复制一份,父子的同号 fd 指向同一个打开文件,共享读写偏移量。多线程程序 fork 时,子进程里只有调用 fork 的那个线程。
线程崩溃为什么会导致整个进程退出?
线程共享地址空间,一个线程非法访问内存,操作系统无法判断其他线程的数据有没有被破坏。段错误产生的 SIGSEGV 默认处理方式是终止整个进程。语言层面的异常要分开看:Go 里任何一个 goroutine 的 panic 没有被 recover,整个程序都会退出;Java 线程里未捕获的异常(如 NullPointerException)只会结束这个线程,JVM 照常运行,但这个线程正在做的事就悄悄中断了。
协程一定比线程快吗?
不一定。协程省的是创建和切换的开销,适合大量 I/O 等待的场景。纯计算任务的瓶颈在 CPU 核数,协程再多也只能用满这些核。另外协程里调用了会阻塞线程的操作(比如没有被运行时接管的阻塞系统调用),会把底层线程卡住,Go 运行时为此会创建新的线程来顶替。
易错点
- "线程共享进程的所有资源"不准确:栈、寄存器、线程局部存储是线程私有的。栈内存虽然在同一个地址空间、技术上能被别的线程访问,但按设计属于各自的线程
- 线程切换并不是没有开销,只是比进程切换少了页表切换;同样要进出内核、保存恢复寄存器
- 协程是"用户态调度",不等于"不需要锁":Go 的多个 goroutine 会在多个线程上并行执行,共享数据照样要用锁或 channel
AI 模拟面试官
用自己的话回答,AI 对照参考答案打分、指出遗漏,再追问,最多 3 轮
这道题你掌握了吗?
选一个最接近的状态,没掌握的题会出现在"我的进度 · 待复习"里。
学习记录暂存在本机浏览器。登录后自动同步到账号,换设备也能看到。