Lua 并发模型:从协程到多线程的演进与选型

系统梳理 Lua 的并发模型:单线程与事件循环的本质、阻塞与非阻塞 IO、单线程 worker 池架构,以及 lua-lanes、Luaproc 等多线程方案与 C 线程协作的方法,最后给出不同场景的并发选型建议。

单线程的本质

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」更多文章

  1. Lua 剖析与调试工具链:从 luaprofiler 到火焰图
  2. OpenResty WAF 与安全防护实战:用 Lua 构建 Web 防火墙
  3. Lua 设计模式落地:用 table 与元表实现经典模式