单线程的本质
Lua 语言的并发能力非常克制:一个 lua_State 同时只能执行一份 Lua 代码,同一状态内的所有协程共享同一个线程,由 coroutine.yield 显式让出执行权。这不是缺陷,而是设计取舍——协作式并发避开了加锁、竞态、死锁等线程难题,让「并发编程」退化成「顺序代码 + 主动让出」。
但现实中总有多核利用、CPU 密集计算、跨进程协作的需求。本文回答三个问题:单线程模型下怎么做出高性能服务?想要真并行时有哪些多线程方案?它们各自的取舍是什么?协程本身的 API 与用法在 Lua 协程深入解析 中已有完整讲解,这里聚焦「并发模型」这一层架构视角。
-- 一个 Lua 状态:任意时刻只有一段代码在执行
local co = coroutine.create(function()
while true do
coroutine.yield() -- 主动让出,绝不抢占
end
end)
核心事实:Lua 的「并发」默认是并发(concurrency)而非并行(parallelism)。想要并行,必须引入多个 Lua 状态或多线程宿主。
协程与事件循环
单线程服务要想扛住高并发,靠的是「事件循环 + 协程」的组合。事件循环持有所有待处理事件,协程则让每个逻辑任务以同步代码的形式存在,二者配合可以同时获得高吞吐与可读性。
一个最小的事件循环骨架:
-- 协程任务队列 + 简单事件循环
local pending = {} -- {co = 协程, at = 唤醒时间}
local function spawn(fn)
local co = coroutine.create(fn)
table.insert(pending, {co = co, at = os.clock()})
end
local function sleep(seconds)
coroutine.yield(os.clock() + seconds) -- 让出,附带回唤醒时间
end
local function run_loop()
while #pending > 0 do
local now = os.clock()
for i = #pending, 1, -1 do
local p = pending[i]
if now >= p.at then
local ok, err = coroutine.resume(p.co)
if not ok then
print("协程错误: " .. tostring(err))
table.remove(pending, i)
elseif coroutine.status(p.co) == "dead" then
table.remove(pending, i)
else
p.at = err -- 协程让出时带回下次唤醒时间
end
end
end
end
end
spawn(function()
for i = 1, 3 do
print("任务A 第" .. i .. "步")
sleep(0.1)
end
end)
run_loop()
真实世界的实现(OpenResty、LÖVE、LuaSocket)远比这复杂,但骨架相同:一个线程里转圈分发事件,协程在 IO 未就绪时让出,就绪后被唤醒。OpenResty 就是这套模型最成功的工业实践,每个请求一个协程,Nginx 的 epoll 负责事件分发,详见 OpenResty 网关开发实战。
阻塞与非阻塞 IO
阻塞 IO 与协程模型水火不容。在事件循环里调用任何阻塞操作(io.read、同步 socket、os.execute)都会把整个线程卡住,其他协程全部排队。所以单线程服务必须使用非阻塞 IO。
Lua 生态的两种典型做法:
-- 方式一:显式非阻塞 + select 轮询(LuaSocket)
local socket = require("socket")
local client = socket.connect("127.0.0.1", 80)
client:settimeout(0) -- 非阻塞模式
while true do
local chunk, err = client:receive(1)
if chunk then
-- 读到数据
elseif err == "timeout" then
-- 未就绪,让出时间片
coroutine.yield()
else
break
end
end
-- 方式二:协程自动让出(OpenResty cosocket)
-- 开发者写同步风格代码,底层在 IO 未就绪时自动 yield
local http = require("resty.http")
local res = httpc:request_uri("http://upstream/api") -- 内部自动让出
两种方式的共同点是「阻塞点 = 让出点」。区别在于方式一需要手动检查超时与重试,方式二把让出封装进 API,代码看起来完全同步。理解这一点后,就明白为什么 OpenResty 手册反复强调:请求阶段禁用阻塞调用。
单线程 worker 池
单线程再快也有上限。当单核吃满而延迟仍不达标时,最常见的扩展方式是「多进程/多实例的 worker 池」:起 N 个各自独立的 Lua 状态,每个状态跑一套事件循环,进程间通过外部组件协作。
┌──────── 请求入口 ────────┐
▼ ▼
worker 0(Lua 状态) worker N(Lua 状态)
事件循环 + 协程池 事件循环 + 协程池
│ │
└────── 共享外部存储 ──────┘
Redis / 消息队列 / 共享内存
worker 池的优势是彻底避开线程同步:worker 之间零共享内存,状态通过 Redis、消息队列等外部设施传递。Redis 的脚本能力让「读改写」类操作可以在 Redis 内原子完成,具体写法见 Redis Lua 脚本实战。
-- worker 内的共享状态全部走 Redis,天然跨进程一致
local redis = require("resty.redis")
local red = redis:new()
red:connect("127.0.0.1", 6379)
-- 原子自增:防止两个 worker 同时写坏计数器
local n = red:incr("visits")
red:expire("visits", 3600)
worker 池需要处理两件事:一是负载均衡(把请求均匀分到各 worker),二是状态一致性(把需要共享的数据外置)。只要这两点成立,worker 池可以线性扩展吞吐,是「既要单线程简单、又要多核性能」的最实用折中。
负载均衡可以简单到用哈希取模:同一用户永远落在同一 worker,便于利用 worker 内的本地缓存:
-- 入口处按用户 ID 哈希选 worker
local workers = {"worker_a", "worker_b", "worker_c"}
local idx = (crc32(uid) % #workers) + 1
local target = workers[idx]
这里的关键是哈希函数要稳定,让路由结果不随 worker 数量变化而剧烈抖动。一致性哈希(如 ngx.crc32_short + 虚拟节点)可以在增删 worker 时只迁移少量映射,是生产环境的更优选择。
协程与线程的分工
混合场景(既有大量 IO、又有少量 CPU 密集任务)不需要二选一,可以让协程与线程各管一段:协程负责 IO 密集的请求编排,lua-lanes 的线程负责 CPU 密集的纯计算。分工的关键是让两边只通过值交换数据。
local lanes = require("lanes").configure()
-- 把 CPU 密集的哈希计算丢给线程池
local gen = lanes.gen("*", function(block)
return sha256_impl(block) -- 纯函数,无共享状态
end)
-- 协程侧照常处理请求
local function handler(payload)
local digest = gen(payload) -- 阻塞等待线程结果
local ok, err = persist(digest) -- IO 让出,不占线程
return ok
end
这种「IO 用协程、CPU 用线程」的搭配在游戏服务端很常见:网络收发、定时器、数据库访问全走事件循环;战斗结算、路径计算、序列化丢给线程池。要注意线程池内函数必须是纯函数(不访问全局可变量、不调用会 yield 的 API),否则跨线程竞态会悄悄出现。
lua-lanes 多线程方案
当任务必须在一个进程内真并行时,就该引入多线程库了。lua-lanes 是最成熟的选择:它把一个 Lua 状态拆成多个 lua_State,每个状态运行在自己的操作系统线程上,线程间用 linda(消息通道)传递数据。
local lanes = require("lanes").configure()
local linda = lanes.linda()
-- 在另一个线程里执行
local function worker(input)
local sum = 0
for i = 1, input do sum = sum + i end
return sum
end
local gen = lanes.gen("*", worker) -- * 表示任意线程池
local handle = gen(1000000)
local result = handle:join() -- 阻塞等待结果
print("结果:", result)
lua-lanes 的关键特性:
- 数据必须可序列化:跨线程传递的 table 会被拷贝,函数与 userdata 不能直接传,只能通过
linda传递值。 - 线程池管理:
lanes.gen("*", fn)从默认线程池取线程,也可指定固定线程数。 - linda 通道:支持
send/receive,可带超时,是生产者消费者模式的标准设施。
-- 用 linda 做任务队列:主线程派发,worker 线程消费
local linda = lanes.linda()
lanes.gen("*", function()
while true do
local job = linda:receive("job", 1) -- 1 秒超时
if job then
local ok, err = process(job)
linda:send("result", job.id, ok, err)
end
end
end)()
for _, job in ipairs(jobs) do
linda:send("job", job)
end
lua-lanes 适合「数据密集、需要真并行、共享状态少」的场景,比如批量数值计算、图像处理、独立任务队列。它不太适合频繁传递大表或高并发小消息的负载——每次跨线程拷贝都有成本。
Luaproc 多线程方案
Luaproc 走的是更纯粹的「进程 + 消息」路线:每个 Luaproc 是一个独立 Lua 状态加独立线程,只通过有界消息队列互相通信,连 linda 这种共享通道都省了。它更像 Actor 模型,与 Skynet 游戏服务器框架 的思路一致。
local luaproc = require("luaproc")
-- 启动一个 Luaproc 进程
luaproc.newproc([[
local luaproc = require("luaproc")
while true do
local msg = luaproc.receive() -- 阻塞接收
if msg == "ping" then
luaproc.send("pong")
elseif msg == "quit" then
break
end
end
]])
luaproc.send("ping")
print(luaproc.receive()) -- pong
luaproc.send("quit")
Luaproc 的消息是深拷贝的,天然规避了共享内存竞态。它的模型非常干净,但生态相对小众,且没有 lua-lanes 那样的线程池概念,每个进程都要手动管理生命周期。
与 C 线程协作
第三种路线是让 C 宿主来管线程,Lua 只负责脚本逻辑。典型做法:C 程序里创建多个线程,每个线程持有独立的 lua_State,线程之间用 C 的互斥锁与队列通信,Lua 层完全看不到线程。
/* C 宿主:每个工作线程持有一个独立 lua_State */
void *worker(void *arg) {
lua_State *L = luaL_newstate();
luaL_openlibs(L);
while (1) {
/* 从队列取任务,调用 Lua 函数处理 */
lua_getglobal(L, "handle_job");
lua_pushinteger(L, job_id);
if (lua_pcall(L, 1, 0, 0) != LUA_OK) {
fprintf(stderr, "%s\n", lua_tostring(L, -1));
lua_pop(L, 1);
}
}
}
这条路的优点是完全可控:线程数、任务分配、数据传递都由宿主决定,Lua 侧只需要暴露纯函数。代价是要写不少 C 胶水代码,且要自己处理 lua_State 之间的数据序列化。完整的 C 嵌入 API 见 Lua 与 C 语言的结合。
-- Lua 侧只需要暴露一个纯函数,交给 C 线程调度
function handle_job(job_id)
local data = load_from_cache(job_id)
if data then
return transform(data)
end
return nil, "not found"
end
这套模式适合「Lua 当脚本引擎、C 当调度器」的项目,比如游戏服务端把房间逻辑放在 Lua,但把 CPU 密集的物理、寻路放在 C 线程。
并发模型选型
把上面的方案放到一张表里对比:
| 模型 | 并行能力 | 数据共享 | 适用场景 | 代表 |
|---|---|---|---|---|
| 纯协程 | 无 | 直接共享(同状态) | IO 密集、高并发请求 | OpenResty、LÖVE |
| worker 池 | 多进程并行 | 外置存储 | 可水平扩展的服务 | 多 Nginx worker |
| lua-lanes | 多线程并行 | linda 通道拷贝 | 数据密集计算 | 批量数值处理 |
| Luaproc | 多线程并行 | 消息深拷贝 | Actor 风格服务 | 轻量游戏服务器 |
| C 线程协作 | 多线程并行 | 宿主自行控制 | 嵌入场景、混合负载 | 游戏服务端 |
选型判据就三条:是否要真并行、数据共享多不多、愿不愿写 C 胶水。IO 密集默认选协程;要扩吞吐优先考虑 worker 池;CPU 密集且在一个进程内,才轮到 lua-lanes/Luaproc;嵌入式系统直接走 C 线程。
注意事项
选择与实现并发模型时,需注意以下要点:
- 单个
lua_State的协程永远无法并行,别期待协程吃满多核。 - 事件循环里禁用阻塞 IO(
io.read、同步 socket),否则一个请求卡住全部请求。 - lua-lanes 与 Luaproc 跨线程传数据都是拷贝,函数、userdata、带元表闭包通常不能直接传。
- 多线程方案都要面对「Lua 状态不是线程安全」的事实:每个线程必须持有独立的
lua_State。 - 全局解释锁式的做法在 Lua 社区不存在,跨线程共享任何对象都要走序列化。
- worker 池的一致性依赖外部组件,Redis 崩了或网络抖动会让各 worker 状态分叉。
- C 线程协作时,Lua 栈的错误处理(
lua_pcall的返回值)必须在 C 层兜住,否则异常会越过线程边界。
常见问题(FAQ)
Lua 协程能利用多核 CPU 吗?
不能。单个 Lua 状态内的协程是协作式并发,任意时刻只有一段代码占用 CPU。协程让出后,另一个协程继续,但始终在同一个线程上执行。要利用多核,必须引入多 Lua 状态(lua-lanes、Luaproc)或多进程 worker 池。
lua-lanes 和 Luaproc 有什么区别?
lua-lanes 提供线程池与 linda 通道,适合「从线程池取一个线程干活、把结果取回」的任务模式;Luaproc 是纯粹的进程 + 消息队列模型,每个进程独立、只通过有界队列通信,更接近 Actor 风格。前者灵活、生态成熟,后者模型干净、限制更严。
什么时候才需要引入多线程?
当 IO 密集的协程模型已经吃满单核、且请求响应时间仍超标时,先考虑 worker 池横向扩展;只有当「必须在一个进程内并行计算、且共享状态很少」时,才值得引入 lua-lanes 或 Luaproc。多线程带来的序列化与生命周期管理复杂度,往往被低估。
多个 Lua 状态之间能共享变量吗?
不能直接共享。每个 lua_State 是独立的内存世界,跨状态传值只能靠序列化(字符串、可拷贝的 table)。lua-lanes 的 linda、Luaproc 的消息队列做的都是这件事的封装。需要跨线程一致的状态,请外置到 Redis 或数据库。
OpenResty 用的是哪种并发模型?
OpenResty 是「多 worker 进程 + 单进程事件循环 + 每请求协程」的三层组合:Nginx 起多个 worker 进程并行,每个 worker 内一个事件循环,每个请求一个 Lua 协程,IO 通过 cosocket 自动让出。它没有用 lua-lanes 那种线程内并行,因为网关负载以 IO 密集为主,worker 进程并行已经足够。
协程里做重计算会卡住其他协程吗?
会。协程只是让出执行权,并不减少 CPU 消耗。一个协程里跑 1 秒的纯计算循环,事件循环这 1 秒内其他所有协程都在排队。所以 CPU 密集任务要么拆小、要么搬到线程池(lua-lanes / Luaproc),单靠协程无法解决「一个任务拖垮全部」的问题。
跨线程传大表为什么慢?
因为每次传递都是深拷贝。lua-lanes 的 linda 和 Luaproc 的消息队列都会序列化整个 table,数据量越大拷贝越慢,且拷贝期间的 GC 压力也不容小觑。如果必须在线程间高频传递大数据,考虑改成「只传引用 + 宿主层共享内存」,或干脆用 worker 池走外部存储。
相关阅读
- Lua 协程深入解析:协作式多任务的实现
- OpenResty 网关开发实战:用 Lua 构建高性能 API 网关
- Redis Lua 脚本实战:原子操作与服务端逻辑
- Lua 与 C 语言的结合:C API 嵌入与扩展
- LuaJIT 深入解析:JIT 编译器架构与性能工程实践
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。