协程与线程的比较
协程常被称为"用户态线程",但它们与操作系统线程有本质区别。理解这些区别,才能明白什么场景该用协程、什么场景必须用线程。
1. 核心区别一览
| 对比维度 | Lua 协程 | 操作系统线程 |
|---|---|---|
| 调度方式 | 协作式:仅在 yield 处主动让出 | 抢占式:由操作系统随时打断切换 |
| 并行能力 | 无:同一时刻只有一个协程在运行 | 有:多线程可真正并行跑在多核上 |
| 切换开销 | 极小:用户态切换,只保存协程栈 | 较大:陷入内核,保存完整上下文 |
| 内存占用 | 每个协程只需很小的栈(KB 级) | 每个线程默认保留 MB 级的栈空间 |
| 数量级 | 轻松创建数万乃至数十万个 | 通常几百上千个就会遇到瓶颈 |
| 数据同步 | 无真正并行,一般不需要加锁 | 必须用锁、信号量等同步原语 |
| 阻塞影响 | yield 不影响其他协程,但阻塞调用(如 io.read)会卡住整个状态机 | 一个线程阻塞不影响其他线程 |
| 适用场景 | 迭代器、生产者-消费者、异步 I/O 封装、游戏帧逻辑 | CPU 密集型并行计算、真正的多任务 |
2. 协作式意味着什么
协程的切换点完全由代码决定:只有执行到 coroutine.yield,控制权才会交出去。因此两个协程的输出顺序是确定的、可复现的:
local function worker(name)
for i = 1, 2 do
print(name, "step", i)
coroutine.yield() -- 唯一可能被打断的位置
end
end
local coA = coroutine.create(worker)
local coB = coroutine.create(worker)
coroutine.resume(coA, "A") -- A step 1
coroutine.resume(coB, "B") -- B step 1
coroutine.resume(coA) -- A step 2
coroutine.resume(coB) -- B step 2
-- 输出
-- A step 1
-- B step 1
-- A step 2
-- B step 2线程则随时可能被操作系统打断,同样的代码若用两个线程执行,输出顺序是不确定的。确定性让协程代码更容易调试和推理,但也意味着协程不能利用多核。这是协作式换取的代价。
3. 阻塞调用是协程的天敌
协程的"并发"只存在于 Lua 世界内部。一旦协程里调用了真正阻塞的操作(读文件、网络请求、os.execute 等),整个进程都会被卡住,其他协程毫无办法:
local co = coroutine.create(function()
-- io.read() 这类阻塞调用会卡住整个程序,其他协程也无法运行
local line = "simulated blocking result"
coroutine.yield(line)
end)
print(coroutine.resume(co)) -- 输出 true simulated blocking result正因为如此,基于协程的异步框架(如 OpenResty/ngx_lua)都把网络 I/O 做成"非阻塞 + 协程挂起"的组合:请求发出后协程 yield 让出,数据到达后再由框架 resume 恢复协程,从协程的使用者视角看依然是顺序代码。
4. 不需要锁的"并发"
因为同一时刻最多只有一个协程在运行,两个协程之间的数据访问天然是串行的。只要不在 yield 调用之间穿插共享状态的修改,就不会出现竞态条件。线程则不同,对共享数据的每次访问都可能与其他线程交错,必须靠锁来保护。
当然,如果宿主程序把同一个 Lua 状态机交给多个线程同时访问,Lua 状态机本身不是线程安全的,仍然需要外部加锁或为每个线程分配独立的状态机(Lua 4.0 起支持的 multiple states 就是为这类场景设计的)。
5. 什么时候用协程,什么时候用线程
选择依据可以归纳为一句话:要并行用线程,要结构清晰用协程。
- 用协程:任务以等待 I/O 为主、需要把异步回调改写成顺序代码、需要生成器和惰性求值、单机游戏逻辑分帧处理。
- 用线程:任务以 CPU 计算为主且机器多核、需要真正的并发吞吐、调用的库本身会阻塞。
在嵌入式和游戏领域,最常见的组合是"少量系统线程 + 每个线程里跑一个 Lua 状态机 + 状态机内部用协程做逻辑调度",两者互为补充。
结语
协程与线程解决的是两个不同层面的问题:线程解决"如何同时执行",协程解决"如何把交替执行写成清晰的顺序代码"。Lua 把协程做成语言内建,为的是给宿主程序一个可控的、确定性的多任务模型,切换只在用户态发生。