进程、线程和协程有什么区别?

基础高频原理对比约 6 分钟读完

一句话回答

进程是资源分配的单位,有独立的地址空间、文件描述符表等资源;线程是 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 上观察一个进程有多少线程:

Shell
# 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 轮

登录后就可以和 AI 面试官对练,面试记录也会保存下来。登录

这道题你掌握了吗?

选一个最接近的状态,没掌握的题会出现在"我的进度 · 待复习"里。

学习记录暂存在本机浏览器。登录后自动同步到账号,换设备也能看到。