一个进程里开两个线程去改同一个数组,一个线程写进去的值,另一个线程立刻就能读出来;把同样的逻辑改成两个进程,就读不到了,得改用共享内存之类的东西才能互相看到。这个差别不是语言给的,是操作系统在这两个东西身上分配的资源不一样。
进程、线程、协程经常被摆在一起比,但它们其实站在三个层次上:进程是分配资源的单位 ,线程是 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。