进程、线程、协程到底有什么区别?一文彻底搞懂三者关系

发布时间:2026/9/15 7:59:53
进程、线程、协程到底有什么区别?一文彻底搞懂三者关系 摘要进程、线程、协程是后端开发、操作系统和并发编程面试中的必考题。三者名字相近却处在完全不同的抽象层级。本文从为什么需要它们出发用生活化比喻 精确概念 6 张示意图 对比表格把三者的概念、差异、典型开销、以及 Go 的 GMP 实战讲透并附 9 道高频与进阶面试题帮你一次建立完整认知框架。关键词进程、线程、协程、Goroutine、GMP、用户态、内核态、上下文切换、并发、并行目录一、先抛一个问题为什么要分这么细二、进程Process资源隔离的房子三、线程ThreadCPU 调度的干活的工人四、协程Coroutine / Goroutine工人手里的便签任务五、用户态与内核态权限的两层楼六、上下文与上下文切换保存现场再恢复七、三者的核心关系与对比八、为什么协程切换更轻量九、Go 实战GMP 调度模型十、实际应用场景该怎么选十一、高频与进阶面试题速答十二、总结一、先抛一个问题为什么要分这么细你可能会想一个程序跑起来不就行了吗为什么要分进程、线程、协程三件套答案很朴素——计算机要同时干很多事但 CPU 核心数有限内存又要防着程序互相破坏。于是操作系统和编程语言各承担了一部分职责把资源隔离好 → 进程的职责在隔离好的资源里高效地轮流干活 → 线程的职责在同一份干活的能力上更便宜地管理海量任务 → 协程的职责。一句话总览先记住后面反复印证进程解决资源隔离与分配问题线程解决CPU 执行与调度问题协程解决如何低成本管理大量并发任务问题。关系是进程 ⊃ 线程 ⊃ 协程进程包含线程线程承载协程。层级核心问题调度者单位成本数量级进程资源隔离与分配内核MB 级内存 页表数十~数百线程CPU 执行与调度内核MB 级栈数百~数千协程海量任务的低成本管理用户态 RuntimeKB 级栈万~百万下面一层一层拆开看。二、进程Process资源隔离的房子进程是操作系统进行资源分配和资源隔离的基本单位。当你双击一个程序、在命令行敲下./app操作系统会为它创建进程。在 Linux 上这件事最终由clone()系列系统调用完成fork()本质是clone(SIGCHLD)内核为新进程建立独立的task_struct和一套全新的页表。每个进程都拥有自己独立的家当独立的虚拟地址空间代码段、数据段、堆、栈打开的文件描述符表文件、Socket、管道等环境变量、信号处理方式、进程组/会话等元信息。因为地址空间互相隔离进程 A 不能直接读进程 B 的内存。这既是保护也是约束——好处是一个程序崩了一般不会拖垮另一个坏处是进程之间想交换数据得走后门也就是进程间通信IPCIPC 方式典型用途特点管道Pipe / FIFO父子进程、Shell 管道流式通信简单、单向、内核缓冲区消息队列MQ结构化消息传递解耦、可缓冲、有类型边界共享内存SHM高频大块数据最快零拷贝但需自己同步信号Signal事件通知不传数据只传发生了Socket跨机器 / 跨进程最通用网络也用它可以用ls /proc/pid/直观感受一套家当fd/是文件描述符maps是地址空间映射task/下则是这个进程的所有线程。记忆点进程像一套带锁的房子房子里的水电家具资源都是这家的别家进不来要串门只能走大门IPC。三、线程ThreadCPU 调度的干活的工人线程是操作系统进行CPU 调度的基本单位。一个进程可以雇多个工人一起干活这些工人就是线程。同一个进程里的线程共享进程的大部分资源代码段、堆、全局变量、文件描述符但每个人都有自己独有的执行状态自己的栈局部变量、函数调用链程序计数器PC/RIP当前执行到哪一行寄存器现场通用寄存器、RSP/RBP、标志位。因为资源共享线程之间通信非常方便直接读同一个全局变量就行但方便也带来麻烦——多个线程同时改同一份数据会产生数据竞争Race Condition。解决办法是同步原语// Go 示例用 Mutex 保护共享计数器 var ( count int mu sync.Mutex ) func worker() { for i : 0; i 1000; i { mu.Lock() count // 临界区同一时刻只允许一个线程进入 mu.Unlock() } }常见同步机制机制作用Mutex / RWMutex互斥锁 / 读写锁保护临界区Atomic原子操作无锁地更新单个变量ChannelGo用通信代替共享内存来传递数据Condition / Semaphore条件等待、并发限流一个容易被忽略的细节线程有用户态线程和内核态线程之分映射方式有三种——1:1Linux NPTL / pthread 默认一个用户线程对应一个内核线程调度由内核负责能利用多核N:1多个用户线程复用一个内核线程切换极快但无法多核并行一个阻塞全阻塞M:N用户线程与内核线程多对多映射兼顾轻量与并行但实现复杂Go Runtime 就是 M:N 的典型。记忆点线程是房子里干活的工人大家共用客厅厨房共享资源但每人有自己的笔记本记进度私有栈/寄存器。工人之间说话很容易但抢同一个微波炉共享数据要排队加锁。四、协程Coroutine / Goroutine工人手里的便签任务协程是运行在线程之上的轻量级执行单元通常由用户态的运行时Runtime负责调度。协程不是操作系统内核的概念而是语言/框架在用户态实现的并发抽象。Go 里叫 GoroutinePython 的async/await、Lua 的 coroutine、Java 的虚拟线程Project Loom都属于同一类思想。按实现方式还可以细分有栈协程Go每个协程有独立栈可动态扩容能在任意函数调用深度挂起/恢复写起来像同步代码代价是栈内存和切换时的寄存器保存无栈协程JS / Rust async、Python asyncio靠编译器把函数改写成状态机没有独立栈内存更省代价是有函数着色async 传染问题且只能在await点挂起。关键事实一个 OS 线程可以承载成千上万个协程。以 Go 为例Goroutine 初始栈只有 2KB 左右创建和切换成本远低于线程所以能轻松开几万甚至上百万个。func main() { for i : 0; i 100000; i { go func(id int) { // 随手开 10 万个 Goroutine fmt.Println(hello, id) }(i) } time.Sleep(time.Second) // 真实项目请用 sync.WaitGroup }记忆点协程是工人手里的一张张便签任务。一个工人线程能同时拿着一大叠便签按 Runtime 的安排来回切换执行便签很轻撕一张、换一张几乎不费劲。五、用户态与内核态权限的两层楼要理解为什么协程更轻必须先理解用户态 / 内核态和上下文切换。用户态普通应用程序运行的状态权限受限不能直接操作硬件、不能碰别家内存。想读文件、收发网络、创建进程得通过系统调用请求内核帮忙。内核态操作系统内核运行的状态权限最高管理 CPU、内存、进程、硬件。应用发起系统调用时会从用户态**陷入trap**内核态内核干完活再返回用户态。以自己的视角跟着一次read()走一遍用户态read(fd, buf, 4096) └─ glibc 包装函数rax 0read 的系统调用号执行 syscall 指令 ─────────── 特权级切换trap─────────── 内核态entry_SYSCALL_64 → ksys_read → vfs_read → 具体文件系统 / 驱动 └─ 保存用户上下文、执行内核代码、恢复上下文 ─────────── 特权级切换sysret─────────── 用户态返回读取到的字节数继续执行一个高频误区模式切换 ≠ 上下文切换。模式切换mode switch同一个线程从用户态进内核态再回来不换线程只换 CPU 特权级和栈开销约几十到几百纳秒上下文切换context switch换的是执行主体要保存/恢复一整套寄存器现场开销在微秒级。也就是说一次read()只发生模式切换没有线程切换——这正是 IO 密集场景下协程优势的关键它把等待的成本压到了最低。为什么要有这两层为了保护操作系统和其他程序。如果任何程序都拥有内核级权限一个 Bug 就能改写别家内存、搞坏系统。把普通程序关在用户态核心资源交给内核统一管理就实现了权限隔离与系统保护。六、上下文与上下文切换保存现场再恢复上下文就是一个任务当前执行到什么状态。在 x86-64 上一次线程切换至少涉及指令指针RIP、栈指针RSP、帧指针RBP通用寄存器 RAX/RBX/RCX/RDX/RSI/RDI/R8–R15标志寄存器 RFLAGS浮点/SIMD 状态XMM 等通常用xsave批量保存。上下文切换就是保存当前任务的执行状态 → 恢复另一个任务的执行状态。比如线程 A 跑到一半要切去跑线程 B就得先把 A 的现场存好等下次轮到 A 再从存好的位置接着跑。切换本身的成本来自几处保存/恢复寄存器、切换内核栈、可能刷 TLB、弄脏 CPU 缓存以及调度器决策开销。粗略的数量级参考会随 CPU/OS/负载浮动切换类型典型开销额外代价模式切换同一线程进出内核数十 ~ 数百 ns无线程切换同进程内约 1 ~ 5 μs可能丢失缓存局部性进程切换约 3 ~ 20 μs换页表、TLB 失效PCID 可缓解Goroutine 切换同线程内约几十 ns用户态保存少量寄存器七、三者的核心关系与对比把上面的内容收拢成一张总图 最核心的对比表核心对比表面试必背维度进程 Process线程 Thread协程 Coroutine谁来调度操作系统内核操作系统内核用户态 Runtime如 Go Runtime基本定位资源分配/隔离单位CPU 调度单位轻量级执行单元地址空间相互独立、隔离同进程内共享同线程内共享通信方式IPC管道/共享内存/Socket共享内存 锁/Channel共享内存 Channel/锁创建/切换开销最大中等最小典型栈大小MB 级MB 级如 1~8MBKB 级Go 约 2KB 起崩溃影响一般不波及其他进程可能拖垮整个进程可被 Runtime 捕获恢复能否多核并行能多进程能多线程能映射到多线程再到多核八、为什么协程切换更轻量两个常见误区先纠正误区 1线程切换一定进内核协程切换一定不进。准确说法线程由内核调度操作系统级线程切换通常需要内核参与协程的协程间调度由 Runtime 在用户态完成所以很多时候不进内核。但协程真要读写文件、做网络 IO照样会陷入内核态。误区 2协程不能利用多核。错。Go Runtime 会把 Goroutine 调度到多个 OS 线程不同线程可以跑在不同 CPU 核上因此 Goroutine 既能高并发也能真并行。开销排序典型情况非绝对协程切换开销 线程切换开销 进程切换开销协程Runtime 在用户态保存/恢复少量寄存器几乎不碰内核线程内核保存/恢复线程上下文涉及内核调度进程除上下文外还要换页表、地址空间、TLB 等内存管理状态最重。注意这不是铁律实际还受 CPU、OS、调度器、缓存命中率、任务类型影响。但在绝大多数业务场景里协程 线程 进程成立。九、Go 实战GMP 调度模型为什么 Go 能轻松开百万协程核心是 GMP 调度模型GGoroutine一个协程任务包含栈、状态、待执行函数等MMachine一个操作系统内核线程真正执行代码的载体PProcessor逻辑处理器持有可运行 G 的本地队列数量由GOMAXPROCS决定默认等于 CPU 核数。调度逻辑简化版每个 P 维护一个本地运行队列runq长度 256M 必须绑定一个 P 才能执行 Ghand-off 机制当某个 G 阻塞在系统调用上时M 会与 P 解绑P 转而寻找/新建一个 M 继续跑其他 G保证并行度不下降work-stealing当某个 P 的本地队列为空它会先去全局队列取一批全局也空时就随机从别的 P 那里偷一半过来执行——这样既避免了全局锁竞争又能自动做负载均衡队列溢出本地队列满了256 个会把一半128 个迁移到全局队列。G 数量远大于 MM 数量远小于 G于是实现了少量线程承载海量协程。两个值得深挖的机制① 栈的动态扩容连续栈Goroutine 初始栈仅 2KB。运行时会在函数入口做栈边界检查一旦发现栈空间不足就分配一块更大的栈通常是原来的 2 倍把旧栈内容整体拷贝过去并修正栈上所有指向旧栈的指针。这就是连续栈方案——它比早期 Go 的分段栈segmented stack性能更稳定避免了频繁的热分裂问题。② 抢占式调度的演进Go 1.14 之前Goroutine 是协作式抢占的Runtime 只在函数序言处插入抢占检查点。这意味着一个死循环且不含函数调用的 G 无法被抢占会导致整个 P 被占住GC 的 STW 时间被拖长。Go 1.14 引入了基于信号SIGURG的异步抢占让运行时能在更多时机安全地中断一个 G显著改善了调度公平性和 GC 延迟。多核并行示意Goroutine 1 → Thread 1 → CPU Core 1 Goroutine 2 → Thread 2 → CPU Core 2 Goroutine 3 → Thread 3 → CPU Core 3所以Goroutine 既轻量用户态调度又能并行映射到多 M 再多核。十、实际应用场景该怎么选场景推荐方案理由CPU 密集计算图像处理、加密多进程 / 多线程 绑核吃满多核GIL 语言下多进程更稳高并发网络服务API、网关、IM协程Go Goroutine / 异步 IOIO 等待多协程切换便宜单机扛百万连接需要强隔离多租户、插件沙箱多进程进程间隔离一个崩了不影响其他流水线 / 定时任务编排协程 Channel用通信代替共享内存逻辑清晰浏览器 / 前端脚本协程JS 事件循环 async/await单线程模型异步不阻塞 UI经验法则要隔离 → 上进程要并行算 → 上线程 / 多进程要海量并发、大量 IO 等待 → 上协程。十一、高频与进阶面试题速答Q1. 进程和线程的根本区别进程是资源分配/隔离单位拥有独立地址空间线程是 CPU 调度单位同进程内共享地址空间。Q2. 为什么进程切换比线程慢进程切换要换页表、地址空间、TLB 等内存管理状态线程同属一个进程共享地址空间只换执行上下文。Q3. 协程和线程的区别协程为什么轻协程由用户态 Runtime 调度初始栈 KB 级切换多在用户态完成、只保存少量寄存器线程由内核调度栈 MB 级切换需内核参与。Q4. 协程能利用多核吗能。Runtime 把协程调度到多个 OS 线程线程再分布到不同 CPU 核。Q5. 协程完全不需要内核吗不是。协程间调度不进内核但涉及文件/网络 IO 等系统调用时仍会陷入内核态。Q6. Go 为什么能开很多 GoroutineGoroutine ≠ OS 线程Goroutine 被 Runtime 复用到少量 OS 线程上GMP 模型初始栈仅 2KB 且可动态扩容所以少量线程即可承载海量协程。Q7. Goroutine 的栈是怎么扩容的连续栈方案初始 2KB栈满时分配一个约 2 倍大小的新栈把旧栈整体拷贝过去并重定位栈上指针。相比分段栈避免了热分裂问题。Q8. Go 1.14 之前的抢占有什么问题怎么解决的之前是协作式抢占只在函数序言插入检查点纯计算的死循环 G 无法被抢占会拖长 GC 的 STW。Go 1.14 引入基于 SIGURG 信号的异步抢占让运行时能更及时地中断 G。Q9. 什么情况下 Goroutine 会泄漏常见三种① 向无消费者的 channel 发送/接收而永久阻塞② 开启了协程但没有任何退出/取消机制如忘记用context.Context③ 无限循环且缺少退出条件。它们不会被 GC 回收会一直占着栈内存。十二、总结回到开头那句话三者的定位一气呵成进程 房子资源隔离线程 房子里的工人CPU 调度协程 工人手里的便签任务轻量并发进程 ⊃ 线程 ⊃ 协程协程切换 线程切换 进程切换典型开销。再给一张选型决策路径帮你在实战里快速判断需要故障隔离 / 多租户 → 进程 需要吃满多核算 CPU → 线程 / 多进程 绑核 高并发 大量 IO 等待 → 协程Goroutine / async 只是任务编排、数据流转 → 协程 Channel理解它们的关键不在于死记定义而在于想清楚每一层在解决什么问题内核用进程保护资源用线程提高 CPU 利用率语言用协程把海量并发任务的管理成本压到最低。把这个层级关系刻进脑子无论是写代码还是答面试都稳了。如果觉得有帮助欢迎点赞、收藏、关注。下一篇可以聊聊「Channel 与锁Go 并发同步到底选哪个」有问题评论区见。参考资料Linux man-pagesclone/fork/sched、Go 源码runtime/proc.go与runtime/stack.go、Go 官方博客Go Scheduler与Go 1.14 Release Notes、用户所附面试笔记《线程协程进程》。