协程与线程对比
小李去面试字节后端岗,面试官问了一个看似简单的问题:
"你用过协程吗?为什么协程比线程高效?"
小李说:"协程是用户态线程,不需要切换到内核态..."
面试官追问:"那用户态切换具体指什么?为什么这样就高效?"
小王开始支支吾吾。
协程这个问题,说起来简单,但很多人只知其然不知其所以然。今天,我们从根子上把这个概念讲透。
一、从一个问题开始
先想象一个场景:你经营一家餐厅,只有一个服务员(线程)。
这个服务员的工作流程:
- 顾客点餐 → 服务员等待厨房做菜(阻塞)
- 厨房做好了 → 服务员端菜上桌
- 顾客吃饭 → 服务员闲着
如果只有这一个服务员,餐厅的效率会非常低。因为服务员在等待的时候什么都干不了。
现在,换一个思路:让服务员在等待的时候去招待其他客人。
这就是协程的核心思想:在等待期间切换执行权,而不是傻等。
【直观类比】
线程 = 餐厅服务员
传统多线程模式,就像餐厅里每个客人都有一个专属服务员:
场景:10个客人同时点餐
多线程模式:
- 需要10个服务员
- 每个服务员等一道菜
- 菜品完成后服务员端菜
问题:
- 服务员太多(线程开销大)
- 服务员等待时闲着(阻塞)
- 菜做好了没人端(线程调度延迟)
协程 = 高效服务员
协程模式,就像一个超级服务员:
场景:10个客人同时点餐
协程模式:
- 只需要1个服务员
- 服务员记住每个客人的位置(保存状态)
- 服务员巡回检查:谁的菜好了就端谁的
核心:
- 服务员没有傻等(异步非阻塞)
- 记住状态,切换成本极低(用户态)
关键区别
二、核心原理
线程切换的本质
线程切换(上下文切换)需要操作系统介入:
graph TD
A[线程A执行中] --> B[触发调度/阻塞]
B --> C[保存线程A上下文<br/>寄存器/栈/程序计数器]
C --> D[操作系统调度]
D --> E[加载线程B上下文]
E --> F[线程B执行中]
style C fill:#ff6b6b
style E fill:#ff6b6b
每次上下文切换需要:
- 保存当前线程的寄存器状态
- 调用内核调度器
- 加载新线程的状态
- 切换内存地址空间(TLB刷新)
这个过程通常需要 数百到数千个CPU周期。
协程的本质
协程是完全在用户态实现的协作式多任务:
graph TD
A[协程A执行中] --> B[主动让出(yield)]
B --> C[保存协程A状态<br/>栈指针/寄存器]
C --> D[切换到协程B]
D --> E[协程B执行中]
E --> F[主动让出(yield)]
F --> G[恢复协程A]
style B fill:#51cf66
style F fill:#51cf66
关键点:
- 协作式:协程自己决定什么时候让出
- 用户态:不需要进入内核态
- 轻量级:状态保存在用户空间
对比代码
线程实现:
import threading
def download(url):
print(f"开始下载: {url}")
# 线程会在这里阻塞等待
response = requests.get(url)
return response
# 每个下载任务都需要一个线程
threads = []
for url in urls:
t = threading.Thread(target=download, args=(url,))
threads.append(t)
t.start()
协程实现(asyncio):
import asyncio
async def download(url):
print(f"开始下载: {url}")
# 协程在这里让出控制权
response = await aiohttp.get(url)
return response
# 单线程可以处理海量并发
async def main():
tasks = [download(url) for url in urls]
await asyncio.gather(*tasks)
asyncio.run(main())
goroutine vs 线程
Go语言的goroutine是协程的典型实现:
func main() {
// 启动1万个goroutine
for i := 0; i < 10000; i++ {
go func() {
// 每个goroutine只需要2KB栈空间
fmt.Println("Hello")
}()
}
}
为什么Go能轻松启动上万个goroutine?
三、边界与特例
1. 协程的调度器
协程需要调度器来分配执行时间:
Go调度器(GPM模型):
graph TD
G[goroutine] --> M[调度队列]
M --> P[Processor]
P --> M2[Machine/线程]
subgraph GPM
G1[G1]
G2[G2]
G3[G3]
end
subgraph Processors
P1[P1]
P2[P2]
end
- G(goroutine):待调度的任务
- P(Processor):逻辑处理器,管理G的队列
- M(Machine):实际的系统线程
2. 协程的局限性
无法利用多核CPU:
# asyncio默认单线程
async def main():
# 这里的所有代码都在同一个线程执行
await asyncio.sleep(1)
print("在主线程")
解决方案:配合多进程实现多核利用
3. 协程 vs async/await
async/await是协程的语法糖:
# 这两段代码是等价的
# 协程函数
async def foo():
return "result"
# 等价于
def foo():
return coroutine_object() # 返回协程对象
await的本质是:
- 检查协程是否完成
- 没完成则挂起当前协程
- 等待其他协程完成后再恢复
4. 常见语言对比
四、常见误区
❌ 误区一:协程可以并行执行
协程是并发(Concurrency),不是并行(Parallelism)。
并发(Concurrency):
一个人交替处理多个任务
时间片轮转,看起来像同时执行
并行(Parallelism):
多个人同时处理多个任务
真正的同时执行
协程通过协作式调度实现并发,但物理上仍是单线程执行。
❌ 误区二:协程完全不需要同步
虽然协程避免了线程切换的锁,但共享资源仍需要同步:
async def increment(counter):
# 这段代码在await之前和之后可能被打断
temp = counter.value # 读取
temp += 1 # 修改
await asyncio.sleep(0) # 让出控制权!
counter.value = temp # 写回
多个协程同时操作同一个变量,仍会出现竞态条件。
❌ 误区三:协程可以无限创建
goroutine虽然轻量,但也有上限:
// Go源码中的限制
const (
MaxGomaxprocs = 1 << 16 // 65536
)
超过限制会导致:
- 内存耗尽(goroutine栈会动态增长)
- 调度开销增大
- 垃圾回收压力增大
❌ 误区四:协程一定比线程快
在IO密集型场景下,协程确实更快。
但在CPU密集型场景下,协程没有优势,甚至更慢:
# CPU密集型:用协程反而更慢
async def cpu_task():
result = 0
for i in range(1000000):
result += i
return result
原因:协程是协作式调度,如果某个协程执行CPU密集型任务不主动让出,其他协程会被饿死。
五、记忆技巧
一句话总结
线程靠系统调度,协程靠自觉让位
对比速记表
口诀
"线程调度靠内核,开销大但省心"
"协程调度靠自觉,开销小但要配合"
"IO密集选协程,CPU密集用线程"
六、实战检验
自检题目
题目1:为什么Redis 6.0引入多线程IO但命令执行仍是单线程?
点击查看答案
Redis的命令执行是CPU密集型操作(如HGET、SETNX),单线程足以处理。
引入多线程IO是为了解决网络IO的瓶颈:主线程负责执行命令,多个IO线程负责读取请求和写入响应。
这样既保持了单线程执行的简单性,又提升了网络IO的吞吐能力。
题目2:goroutine的栈为什么是动态伸缩的?
点击查看答案
goroutine创建时只分配2KB栈空间,远小于线程的1-8MB。
当栈空间不足时(通常通过栈顶指针检测),会自动扩容;栈空间利用率低时,会自动缩容。
这是Go在内存效率上的优化,使得可以轻松创建数十万个goroutine。
题目3:协程的调度器需要考虑什么?
点击查看答案
- 公平性:避免某个协程长时间占用CPU
- 优先级:高优先级任务应该先执行
- 亲和性:尽量让任务在同一核心上执行
- 饥饿问题:确保低优先级任务最终也能执行
面试追问预测
七、生产实战案例
案例:Go在微服务中的高并发优势
为什么越来越多的公司选择Go作为微服务开发语言?
对比:10K并发连接
Java(线程模型):
- 需要10000个线程
- 每个线程1MB栈 → 10GB内存
- 上下文切换开销巨大
Go(goroutine模型):
- 需要10000个goroutine
- 每个goroutine 2KB栈 → 20MB内存
- 上下文切换开销极小
案例:Python爬虫的协程优化
# 同步爬虫:每秒10个请求
import requests
def crawl(urls):
for url in urls:
resp = requests.get(url)
process(resp)
# 异步爬虫:每秒1000个请求
import aiohttp
import asyncio
async def crawl(urls):
async with aiohttp.ClientSession() as session:
tasks = [fetch(session, url) for url in urls]
await asyncio.gather(*tasks)
性能提升:10x - 100x
💡
协程的核心价值在于:用极低的资源开销实现极高的并发能力。在IO密集型场景下,这是革命性的提升。