进程、线程、协程有什么区别?从资源分配到调度,一篇理清

一个进程里开两个线程去改同一个数组,一个线程写进去的值,另一个线程立刻就能读出来;把同样的逻辑改成两个进程,就读不到了,得改用共享内存之类的东西才能互相看到。这个差别不是语言给的,是操作系统在这两个东西身上分配的资源不一样。

进程、线程、协程经常被摆在一起比,但它们其实站在三个层次上:进程是分配资源的单位 ,线程是 CPU 调度的单位 ,协程是用户态里自己调度的单位。下面按这三个层次来拆。


资源分配

先看一样东西,在 Linux 上跑 ps -eLf:

text 复制代码
UID   PID  PPID  LWP  C NLWP CMD
wuwu  100   99   100  0    3 ./demo
wuwu  100   99   101  0    3 ./demo
wuwu  100   99   102  0    3 ./demo

(中间省略了 STIME、TTY、TIME 等列)

同一个程序三行,PID 都是 100,LWP(轻量级进程,就是线程 ID) 分别是 100、101、102。也就是说 Linux 内核里面,进程和线程并不是两种不同的东西,区别只在于"共享不共享资源"。

每个进程真正独占的,是这几样:

资源 进程内各线程
虚拟地址空间(页表) 共享同一份
文件描述符表 共享
信号处理表 共享
当前工作目录、umask 共享
栈 每个线程一份
寄存器(PC、SP) 每个线程一份
线程本地存储 TLS 每个线程一份

"共享什么"并不是写死的。fork() 和 pthread_create() 底层是同一个系统调用 clone(),只是传的 flag 不同:

flag 效果
CLONE_VM 父子共享地址空间(页表)
CLONE_FILES 共享文件描述符表
CLONE_FS 共享 cwd、根目录、umask
CLONE_SIGHAND 共享信号处理表
CLONE_THREAD 放进同一个线程组,PID 相同

fork() 一个 flag 都不传,所以是完整的拷贝(地址空间靠写时复制 COW,先共享物理页,写的时候才复制);pthread_create() 会带上 CLONE_VM | CLONE_FS | CLONE_FILES | CLONE_SIGHAND | CLONE_THREAD 这几个(另外还有几个管理用的),于是新东西和调用者共享地址空间和一大堆表,只独立出栈和寄存器------这就是线程。

所以"线程是轻量级进程"这个说法不是比喻。内核里它们就是同一套 task_struct,区别全在 clone 的那几个 flag 上。


调度

地址空间是"分配给谁"的问题,调度是"CPU 现在跑谁"的问题。

Linux 的调度器 CFS 调度的单位是 task_struct,它不区分进程和线程,一律按线程调度 。一个进程哪怕只有一个线程,在调度器眼里也就是一个 task。/proc/<pid>/task/ 下面每个线程一个目录,也能看出内核是按线程记账的。

区别在这里:一次线程切换,CPU 换一下栈指针和一组寄存器就完了,因为大家用的是同一张页表;一次进程切换,除了换寄存器,还得换页表基址寄存器(CR3),这一步会牵连到 TLB。


切换开销

页表一换,TLB(Translation Lookaside Buffer,缓存"虚拟页→物理页"映射的那张快表)里的映射就全不对了,因为它们是按上一个进程的虚拟地址存的。于是切换后的一段时间里,访问内存几乎每次都 TLB miss,要重新走一遍页表(可能得访存好几次),程序莫名其妙就慢了。这叫 TLB 抖动。

缓解手段是 PCID(Process Context IDentifier):给 TLB 每一项打个进程标签,切进程时不整个清空,只是别人的项不再命中。但同一个物理页对应的 CPU 缓存(L1/L2)该污染的还是会污染。

切换类型 大致开销 主要成本
进程切换 几微秒 换 CR3 + TLB 失效 + 保存恢复寄存器/栈
线程切换 亚微秒 只换寄存器/栈,不换页表
协程切换 几十纳秒 用户态换少量寄存器,不陷入内核

这些数字是量级,具体和 CPU、缓存命中情况都有关系。差距的来源就是上面两行:要不要换页表,要不要进内核。


协程

前面两种切换都要陷入内核 (从用户态进内核态,下一篇会专门讲),而这一步本身就贵。协程的思路是:既然贵在内核掺和,那干脆不进内核,在用户态自己切。

协程的调度器在用户态(Go 的 runtime、Python 的 asyncio 事件循环、Lua 的 coroutine),它自己维护一堆待跑的协程,谁该跑了由它决定,切换就是保存/恢复几个寄存器、换一下用户栈,全程不碰内核。

两个直接结果:

  • 栈可以很小 。内核线程的栈受 ulimit -s 限制,Linux 默认 8MB 一个,开几千个就没了;Go 的 goroutine 初始栈只有几 KB,还能按需增长,所以一个进程里跑几十万个协程是常态。
  • 切换是"协作式"的 。协程不会在任意时刻被抢走,它得自己主动让出(执行到 await、yield,或者 Go 里运行时发现它要阻塞 IO 了),没有抢占也就没有随时可能到来的中断。

代价是协程只是用户态的抽象,干活的内核线程数没变。某一刻真正并行执行的协程,上限就是底层内核线程数(Go 里由 GOMAXPROCS 控制),协程再多也是它们轮流跑。


三者对比

进程 线程 协程
资源分配 独立的地址空间、fd 表、信号表 共享所属进程的这些 共享所属线程的一切
谁调度 内核 内核 用户态 runtime
切换开销 微秒级,换页表 + TLB 失效 亚微秒,不换页表 纳秒级,不进内核
栈 独立地址空间 独立栈,默认 MB 级 独立栈,初始 KB 级
数据交换 必须走 IPC 直接读写共享内存 直接读写
一个崩了 只影响自己 整个进程一起挂 整个进程一起挂
数量级 几百到几千 几千 几十万

所以实际怎么选:需要强隔离的(浏览器给每个标签页一个进程)用进程;需要大量共享数据、频繁通信的用线程;并发数要上万、切换开销成了瓶颈的,再考虑协程。但要注意,协程不解决并行------它省的是切换和内存,不是 CPU。

相关推荐
威哥爱编程1 小时前
【AI全栈12-09】Spring Boot 用流式输出做打字机对话:点下去不再干等
java·spring boot·后端
威哥爱编程1 小时前
【AI全栈12-08】Spring Boot 用 MCP 统一接入内部系统:让 AI 接一次,全公司复用
java·spring boot·后端
deviant-ART1 小时前
Java 并发编程:深入理解 synchronized 的使用与原理
java·开发语言·后端
tachibana21 小时前
Go语言里slice是怎么扩容的?
后端·golang·go·切片
西瓜很甜哟2 小时前
golang实现大文件分片上传
后端·golang·html
sp42a2 小时前
简单的 Spring 文件上传与下载组件
java·后端·spring
步行cgn2 小时前
为什么 Spring 建议使用构造器注入?
java·后端·spring
小蒜学长2 小时前
基于Vue.js + Spring Boot的酒店客房预订系统设计与实现(代码+数据库+LW)
java·数据库·spring boot·后端·酒店客房预订系统
打工仔折腾 AI2 小时前
鸿蒙平板远控电脑实测:ToDesk蓝牙键鼠、双模式鼠标与跨端剪贴板落地记录
人工智能·后端·python·性能优化·计算机外设·电脑·harmonyos