【Lua杂谈】一文上手coroutine协程

前言

提到lua,就不得不提协程coroutine。coroutine是lua的一种内在机制,为lua提供了原生的异步支持。从用户层面来看,用户不需关心coroutine的内在实现,而只需要用coroutine调度function即可,因此非常方便。

对于一个function而言,coroutine可以将function的代码分片,使得一个function可以分阶段运行。在实现上,function的状态管理会与CPU的机制相似。如果把一个function当做一个任务来看待的话,在coroutine的封装下,这个任务会被分解成多个阶段的子任务。这样,我们就可以把多个任务的子任务相互协调调度,实现更加灵活的功能交互。

因此,本期Lua杂谈,就来小试一抔coroutine的使用吧。

coroutine的基本用法

官方5.3.5版本的coroutine库,提供了如下的接口:

1
2
3
4
5
6
7
8
9
10
11
// lcorolib.c
static const luaL_Reg co_funcs[] = {
{"create", luaB_cocreate},
{"resume", luaB_coresume},
{"running", luaB_corunning},
{"status", luaB_costatus},
{"wrap", luaB_cowrap},
{"yield", luaB_yield},
{"isyieldable", luaB_yieldable},
{NULL, NULL}
};

用户可以通过coroutine.wrapcoroutine.create两种方式封装一个function(任务)。通过wrap封装任务会返回一个纯粹的lua函数(type为function),而用create封装则返回的是一个封装好的线程。

在Lua中,线程thread与协程coroutine的概念内涵有较多相似之处,但我们可以认为,线程是更加宏观广泛的概念,协程则是一种特殊的线程。线程强调的不同的routine之间运行是否独立;而协程强调的则是不同routine之间具有相互co的协作功能。

基于这两种方式调度任务的代码写法大同小异。以下以create封装任务为例,我们一起看看会是怎样的进行——

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
local function output(co, ...)
print("Info:")
print("\tSTATUS:", coroutine.status(co))
print("\tRUNNING:", coroutine.running())
print("\tYIELDABLE:", coroutine.isyieldable())
if ... then
print("Output: ")
for _, v in ipairs({...}) do
print("", tostring(v))
end
end
print("----------------------------------------\n")
end

local co

co = coroutine.create(function(a, b)
local ab = a + b
output(co)
local c, d = coroutine.yield(ab)
coroutine.resume(coroutine.create(function (co_thread) output(co_thread) end), co)
local cd = c + d
output(co)
local e, f = coroutine.yield(cd)
output(co)
return tostring(e) .. tostring(f)
end)

local ok1, ret1 = coroutine.resume(co, 1, 2)
output(co, tostring(ok1) .. ": " .. tostring(ret1))

local ok2, ret2 = coroutine.resume(co, 11, 22)
output(co, tostring(ok2) .. ": " .. tostring(ret2))

local ok3, ret3 = coroutine.resume(co, 111, 222)
output(co, tostring(ok3) .. ": " .. tostring(ret3))

我们首先定义了函数output函数输出当前各线程的状态(普通主线程、协程均可)。在coroutine中封装了以下的功能查看线程信息:

  • coroutine.status:指定一个线程,返回该线程的状态,可以是suspended(没运行,或被切出)running(正在运行)dead(任务完成,或遇到错误)以及normal(正调着另外一个协程)四种之一。
  • coroutine.running:返回当前线程以及是否为主线程的boolean。
  • coroutine.isyieldable:返回当前线程是否具有yield切出功能,如果是普通主线程,或者是不支持yield的C编写的线程,就不能切出。

之后定义了协程co,采用create封装一个任务。封装完之后,就试着跑一下。

我们先看看最后的输出结果:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
Info:
STATUS: running
RUNNING: thread: 00000000006ee6b8 false
YIELDABLE: true
----------------------------------------

Info:
STATUS: suspended
RUNNING: thread: 00000000006e5ef8 true
YIELDABLE: false
Output:
true: 3
----------------------------------------

Info:
STATUS: normal
RUNNING: thread: 00000000006f2e28 false
YIELDABLE: true
----------------------------------------

Info:
STATUS: running
RUNNING: thread: 00000000006ee6b8 false
YIELDABLE: true
----------------------------------------

Info:
STATUS: suspended
RUNNING: thread: 00000000006e5ef8 true
YIELDABLE: false
Output:
true: 33
----------------------------------------

Info:
STATUS: running
RUNNING: thread: 00000000006ee6b8 false
YIELDABLE: true
----------------------------------------

Info:
STATUS: dead
RUNNING: thread: 00000000006e5ef8 true
YIELDABLE: false
Output:
true: 111222
----------------------------------------

然后再分解运行过程:

  1. 主线程调用coroutine.resume(co, 1, 2)开始这个协程,其中1, 2为输入参数,对应任务function里的初始参数a, b
  2. 在协程中,把1, 2相加得到3ab变量,而后output线程状态:status(co)返回了running表示协程co正在运行,而runningisyieldable则只关心哪个线程运行了它们。在协程里,running会返回协程地址以及false,代表不是主线程;而在主线程里,running会返回主线程地址以及true。同样,在协程里,isyieldable会返回true表示该线程可被yield,而在主线程则不行,为false。后面的结果也都同样。
  3. 调用coroutine.yield(ab)切出协程,切回主线程,这一阶段返回的结果为ab
  4. 主线程视角下,coroutine.resume的返回结果为当前协程这一阶段是否没有异常(ok)以及协程yield出来的返回值。因此ok1ret1则为trueab。主线程调用output函数查看各个线程状况,可以看到status(co)suspended,协程co正在暂停状态,等待下一次resume;而由于在主线程,runningisyieldable分别为truefalse
  5. 主线程调用coroutine.resume(co, 11, 22)继续这个协程。在协程co的视角下,local c, d = coroutine.yield(ab)中的c, d,即为resume它的线程传进来的两个参数,在这里也就是11, 22了。
  6. 协程co又调起另一个协程,在另一个协程调用output来看原来协程costatus。嘛,这一步只是为了加一个status == "normal"的例子。
  7. 11 + 22的结果33cd,然后output线程状态,结果与步骤2相似。而后,再把cdyield切出去。
  8. 主线程收到返回值ok2 = true以及ret2 = 33,而后再output,结果也与步骤4相似。
  9. 主线程再次resume协程co,输入参数111, 222给协程中的e, f,协程内部output状态后,最终返回了字符串111222,协程任务结束。此时主线程中调用output(co),可以看到co的状态已经为dead。由于协程co没有发生异常,那么dead就表示协程所有的子任务都结束啦~

至此,整一个coroutine.create的例子已经完成。而对于coroutine.wrap,由于返回的是一个lua函数而非线程,因此需要通过pcall等手段捕获错误,从而不至于断掉主线程运行。有兴趣的同学,可以一探究竟~

coroutine与后端洋葱圈模型

coroutine的重点在于co,在官网上,也有排列组合生产者——消费者等表现协同任务的例子。不过本文则要搬出我们的老朋友——后端的洋葱圈模型,示意图如下:

洋葱圈模型

后端对于数据规范、安全性等是相当有要求的。比如一个获取数据的请求到达后端,首先都要经过重重关卡检查请求的合法性,而后后端控制器才调用服务取出数据,最后返回时,还得再一个个关卡严查,才能把该带的数据带出去。洋葱圈模型的数据处理流水线,便是如此。

我们以一个例子来试试吧~

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
local function middleware_header(ctx)
ctx.header["user-agent"] = "ShangQi"
ctx.header["referer"] = "America"
coroutine.yield()
end

local function middleware_body(ctx)
ctx.body.data = "Chinese Hero"
coroutine.yield()
if ctx.body.data ~= "Chinese Hero" then
print(ctx.header["user-agent"] .. " is not a Chinese Hero!")
end
end

local function controller(ctx)
if ctx.header["referer"] ~= "China" then
ctx.body.data = "You are not Chinese!"
end
end

local context = { header = {}, body = {} }
handle(context, middleware_header, middleware_body, controller)

在这个场景里,我们要处理的数据叫contextcontext先经过两个middleware中间件headerbody,通过两个关卡,才进入到正式的掌权端controller

header中间件中,context为自己的header加上了两个头衔:user-agent表示用户代理身份,这里叫做ShangQireferer表示从哪里来,这里叫做America

body中间件中,context为自己的body加上了Chinese Hero的数据,然后切出不管,并且打赌,如果切回来不是Chinese Hero的话,就告诉全世界,这个user-agent身份不能代表Chinese Hero

但在真正掌握控制大权的controller中,则不会为标榜Chinese Hero的非中国人买单。

1
ShangQi is not a Chinese Hero!

那么,怎样实现这一过程呢?我们看一下整一个代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
local function handle(ctx, ...)
local handlers = {}
local args = table.pack(...)
-- 检查长度
if #args == 0 then
return false, "Error! Expected at least one handler (controller) for context!"
end
-- 把middleware跟controller用coroutine.create封装,插入到handlers中
for i = 1, #args do
if not type(args[i]) == "function" then
return false, "Error! Handler " .. tostring(i) .. " is not a function!"
end
table.insert(handlers, coroutine.create(args[i]))
end
-- 客户端 --> middleware(上半部分) --> controller
for i = 1, #handlers do
local ok, err = coroutine.resume(handlers[i], ctx)
if not ok then
local tag = "controller"
if i ~= #handlers then
tag = "middleware #" .. tostring(i)
end
return false, "Error at " .. tag .. "! " .. err
end
end
-- 移除controller
table.remove(handlers)
-- 客户端 <-- middleware(下半部分) <-- controller
while #handlers > 0 do
local co = table.remove(handlers)
if coroutine.status(co) == "suspended" then
local ok, err = coroutine.resume(co, ctx)
if not ok then
return false, "Error at middleware #" .. tostring(#handlers + 1) .. "! " .. err
end
else
-- strict mode, call coroutine.yield() forcibly
return false, "Error at middleware #" .. tostring(#handlers + 1) .. "! Please cast coroutine.yield() in strict mode!"
end
end
return true, nil

我们可以把所有的中间件与控制器处理任务都封装成协程,然后入栈(table.insert)。最后一个处理者(handler),也就是控制器处理完毕后,开始将一个个协程pop(table.remove)出来,再resume,这样,就能够模拟洋葱圈模型的操作了。

总结

lua的coroutine非常灵活。配合各种底层架构,可以玩出很多种不同的花样。多上手试试吧~

版权声明
本文为博客HiKariのTechLab原创文章,转载请标明出处,谢谢~~~