AIGC标识 lua-源码带读-08-协程与多任务

Lua 5.4 源码带读 第8篇:协程与多任务

本篇目标

深入理解 Lua 协程(coroutine)的实现:lua_State 作为协程的容器、yield/resume 机制、非抢占式调度、协作式多任务模式。

前置知识

  • 了解协程与线程的区别
  • 了解yield/resume概念
  • 阅读过第1-4篇

1. 协程基础

1.1 什么是协程

协程是协作式多任务的基本单位。与线程不同:

  • 协程可以主动让出执行权(yield)
  • 协程不能被其他协程强制抢占
  • 一个协程在任何时刻只在一个线程中运行

1.2 协程API

// lua.h
lua_State *lua_newthread(lua_State *L);  // 创建新协程
int lua_resume(lua_State *L, lua_State *from, int nargs, int *nresults);
int lua_yieldk(lua_State *L, int nresults, lua_KContext ctx, lua_KFunction k);

1.3 Lua标准库API

-- 协程库
local co = coroutine.create(function()
    coroutine.yield(100)
    return "done"
end)

coroutine.resume(co)       -- 恢复执行,返回 true, 100
coroutine.resume(co)       -- 恢复执行,返回 true, "done"
coroutine.status(co)       -- "dead"
coroutine.yield(...)       -- 让出执行权

2. 协程的内部表示

2.1 每个协程是一个lua_State

// lstate.c
lua_State *lua_newthread(lua_State *L) {
  lua_State *L1 = luaE_newthread(L);
  setthvalue(L, L->top, L1);
  api_incr_top(L);
  return L1;
}

每个协程都有独立的:

  • 值栈(value stack)
  • 调用信息链(CallInfo)
  • 错误恢复点(errorJmp)
  • 线程状态(status)

2.2 协程状态

// lua.h
#define LUA_OK           0  // 正常运行
#define LUA_YIELD        1  // 已挂起
#define LUA_ERRRUN       2  // 运行时错误
#define LUA_ERRSYNTAX    3  // 语法错误
#define LUA_ERRMEM       4  // 内存错误
#define LUA_ERRERR       5  // 错误处理函数错误

3. yield 机制

3.1 lua_yieldk

// ldo.c
int lua_yieldk(lua_State *L, int nresults, lua_KContext ctx, lua_KFunction k) {
  // 检查是否在C调用中
  if (L->ci->callstatus & CIST_YIELD)
    // 已经在挂起状态,不能再yield
    luaG_runerror(L, "attempt to yield across a C-call boundary");

  // 设置挂起状态
  L->status = LUA_YIELD;

  // 保存返回值到栈上
  StkId func = L->ci->func;
  // 移动返回值到正确位置

  // 如果有continuation函数,保存上下文
  if (k != NULL) {
    L->ci->u.c.k = k;
    L->ci->u.c.ctx = ctx;
    L->ci->callstatus = CIST_YIELD | CIST_YPCALL;
  }

  return LUA_YIELD;
}

3.2 resume 机制

// ldo.c
int lua_resume(lua_State *L, lua_State *from, int nargs, int *nresults) {
  // 检查状态
  if (L->status != LUA_YIELD && L->status != LUA_OK)
    return LUA_ERRRUN;

  // 恢复执行
  StkId firstResult = luaD_rawrunprotected(L, resume, &nargs);

  if (firstResult == NULL) {
    // yield,返回挂起状态
    *nresults = 0;
    return LUA_YIELD;
  } else {
    // 完成或出错
    // 设置返回值
    return LUA_OK;
  }
}

3.3 yield 的栈处理

coroutine.resume(co)
  → 从调用者栈复制参数到协程栈
  → 恢复执行
  → 协程执行 yield
    → 返回值留在协程栈顶
  → 将返回值复制回调用者栈
  → resume 返回 true, 返回值...

4. continuation 机制

4.1 什么是continuation

Lua 5.1 只能在C函数调用边界之前yield。Lua 5.4 通过 continuation 机制支持跨C调用边界的yield。

-- 5.1: 不能在C函数中yield
-- 5.4: 可以在C函数中yield
local co = coroutine.create(function()
    local ok, err = pcall(function()
        -- pcall是C函数,但可以在其中yield
        coroutine.yield(100)
    end)
end)

4.2 continuation函数

// lua.h
typedef lua_CFunction lua_KFunction;  // continuation函数

continuation函数在resume时被调用,处理yield后的恢复。


5. 协作式多任务模式

5.1 生产者-消费者

local producer = coroutine.create(function()
    while true do
        local item = produce_item()
        coroutine.yield(item)
    end
end)

local consumer = coroutine.create(function()
    while true do
        local ok, item = coroutine.resume(producer)
        if not ok then break end
        consume_item(item)
    end
end)

5.2 协程调度器

local function scheduler(threads)
    while #threads > 0 do
        for i, co in ipairs(threads) do
            if coroutine.status(co) ~= "dead" then
                local ok, val = coroutine.resume(co)
                if not ok then
                    table.remove(threads, i)
                end
            else
                table.remove(threads, i)
            end
        end
    end
end

6. 非抢占式 vs 抢占式

特性 协作式(Lua协程) 抢占式(OS线程)
切换时机 显式yield 由调度器强制切换
数据竞争 不可能(手动yield) 可能(需要锁)
上下文切换开销 低(只保存栈) 高(保存寄存器等)
编程复杂度 低(无锁) 高(需要同步原语)
适用场景 协作式多任务 真正的并行计算

7. 本篇小结

概念 要点
协程 = lua_State 每个协程有独立的栈和CallInfo
yield 主动让出执行权,保留栈状态
resume 恢复协程执行
continuation 跨C调用边界的yield(5.4新增)
非抢占式 协程之间不会发生数据竞争
状态管理 LUA_OK/LUA_YIELD/LUA_ERR*

思考题

  1. Lua 协程和操作系统线程在上下文切换开销上有什么区别?
  2. continuation机制解决了什么问题?
  3. 为什么Lua选择非抢占式而非抢占式协程?
  4. 如何用Lua协程实现一个简单的状态机?
  5. coroutine.wrapcoroutine.create 有什么区别?

思考题解答

1. 协程和OS线程的上下文切换开销区别

特性 Lua协程 OS线程
切换开销 极低(~100ns) 较高(~1-10μs)
保存内容 只保存lua_State(栈+CallInfo) 保存所有寄存器+TLB+浮点状态
内存开销 每个协程约1-8KB 每个线程约1-8MB(栈空间)
创建开销 内存分配+初始化 系统调用+内核数据结构
切换方式 显式yield 由调度器中断

开销对比

  • Lua协程切换:只需移动指针(保存/恢复lua_State),约100纳秒
  • OS线程切换:需要保存/恢复所有CPU寄存器、刷新TLB、可能的内核态切换,约1-10微秒
  • 差距约10-100倍

内存对比

  • Lua协程:每个协程一个lua_State,初始栈约1KB,最大可配置
  • OS线程:默认栈空间1-8MB,内核还要维护线程控制块
  • 可以创建数千个Lua协程,但OS线程通常限制在数百个

2. continuation机制解决了什么问题?

问题:Lua 5.1中,C函数不能在被调用时yield。如果C函数内部调用了Lua代码,而Lua代码yield了,会导致"跨C调用边界的yield"错误。

-- Lua 5.1中会报错
local co = coroutine.create(function()
    pcall(function()
        coroutine.yield(100)  -- pcall是C函数,不能yield
    end)
end)

解决方案:5.4引入continuation机制,允许C函数注册一个continuation函数。当C函数内部yield时,resume时不是恢复C函数,而是调用continuation函数继续执行。

// C函数返回continuation函数
static int my_cfunc(lua_State *L) {
    // 执行部分工作
    return lua_yieldk(L, 1, ctx, my_continuation);
}

// continuation函数:在resume时被调用
static int my_continuation(lua_State *L, int status, lua_KContext ctx) {
    // 继续未完成的工作
}

效果:C函数可以"透明地"支持yield,对Lua代码来说就像普通函数一样。

3. 为什么选择非抢占式协程?

  1. 简单性:不需要锁、原子操作、内存屏障等同步原语
  2. 性能:无锁设计,切换开销极低
  3. 确定性:协程只在显式yield点切换,行为可预测
  4. 无数据竞争:同一个协程在任何时刻只在一个线程中运行
  5. 与Lua的嵌入式定位一致:Lua主要用于单线程环境,不需要真正的并行

代价

  • 不能利用多核CPU(需要配合多进程或外部线程库)
  • 长时间运行的协程会阻塞其他协程
  • 需要程序员手动yield(协作点)

适用场景

  • 协作式多任务(游戏AI、网络服务器)
  • 迭代器和生成器
  • 状态机
  • 异步I/O回调

4. 如何用协程实现状态机?

local function create_states()
    -- 状态:每个状态是一个协程
    local idle = coroutine.create(function()
        while true do
            print("状态: IDLE")
            local event = coroutine.yield("waiting")
            if event == "start" then
                return "running", event  -- 切换到running状态
            end
        end
    end)

    local running = coroutine.create(function()
        while true do
            print("状态: RUNNING")
            local event = coroutine.yield("processing")
            if event == "stop" then
                return "idle", event  -- 切换到idle状态
            end
        end
    end)

    return { idle = idle, running = running }
end

-- 状态机调度器
local states = create_states()
local current = "idle"

local function dispatch(event)
    local co = states[current]
    local ok, next_state, data = coroutine.resume(co, event)
    if next_state then
        current = next_state
    end
end

dispatch("start")   -- IDLE -> RUNNING
dispatch("stop")    -- RUNNING -> IDLE

原理:每个状态是一个协程,状态转换通过 return 实现,事件通过 resume 传递。

5. coroutine.wrap 和 coroutine.create 的区别

特性 coroutine.create coroutine.wrap
返回值 协程对象(userdata) 迭代器函数
错误处理 返回 ok, err 直接抛出错误
使用方式 coroutine.resume(co, ...) co(...)
底层实现 创建lua_State 创建lua_State + 包装函数
-- create + resume
local co = coroutine.create(function() return 42 end)
local ok, val = coroutine.resume(co)
print(ok, val)  -- true  42

-- wrap
local co = coroutine.wrap(function() return 42 end)
local val = co()
print(val)  -- 42
-- 如果协程报错,wrap会直接抛出错误

选择建议

  • 需要错误处理 → create + resume
  • 简单迭代 → wrap(更简洁)
  • 需要检查协程状态 → create(可以使用 coroutine.status
posted @ 2026-09-04 17:26  IcarusLee  阅读(4)  评论(0)    收藏  举报