lua协程实现及field访问的一个细节
intro
由于C不支持协程,C++只有在最近的C++标准(C++20)中才支持协程,如果希望在C++中支持协程通常需要使用第三方库。针对这种问题,可能有些实现在C++中嵌入lua脚本,利用lua的原生协程机制来达到协程效果;或者干脆使用go这种内置包含了协程的语言。
协程
问题
- 私有堆栈
协程的一个关键特性就是当“异步”,当执行某个流程的时候,在某个位置切出去,之后再切回来。由于一个执行流执行的时候,很多信息是在堆栈中保存的(包括调用关系),切换前后需要有相同的堆栈,所以协程必须有自己的单独堆栈。
- 切换
当每个协程有了自己的私有堆栈之后,剩余的问题就是yield的时候,控制流转到哪里?这个问题其实相对比较简单,以内核的进程切换为例:当切换的时候其实就是跳转到schedule函数。go的运行时应该也是类似,yield就是执行流切换到调度器来选择下一个可以运行的协程。
实现
- 私有堆栈
为了让实现能够感知到需要创建一个协程,必须通过某种方法声明一个协程。这个在lua中通过coroutine.create;在go中通过go关键字;C++20使用的貌似是无栈协程,这种实现由于没有堆栈,不太容易实现复杂的逻辑,使用也不太友好。
这里关键的意思是:lua/go使用协程的时候都要先声明,这个声明让语言可以为这个协程准备私有堆栈。
static int luaB_cocreate (lua_State *L) {
lua_State *NL;
luaL_checktype(L, 1, LUA_TFUNCTION);
NL = lua_newthread(L);
lua_pushvalue(L, 1); /* move function to top */
lua_xmove(L, NL, 1); /* move function from L to NL */
return 1;
}
- 协程入口
和私有堆栈对应的,有一个单独协程入口函数的概念:用来决定从哪个函数开始,函数的堆栈放在这些私有堆栈上。
- 切换
lua线程创建之后并不会立即运行,而是需要通过resume开始运行,当执行resume函数时,运行时会通过setjmp设置一个跳转点,coroutine执行到yield的时候直接longjmp到resume即可恢复resume的执行。
也就是说,lua的yield并不是返回到某个调度器,而是返回到了resume位置,因为协程创建/yield之后必须通过resume恢复执行。
对应的跳转结构为
/* ISO C handling with long jumps */
#define LUAI_THROW(L,c) longjmp((c)->b, 1)
#define LUAI_TRY(L,c,a) if (setjmp((c)->b) == 0) { a }
#define luai_jmpbuf jmp_buf
#endif /* } */
当resume时,通过LUAI_TRY保存当前位置。
int luaD_rawrunprotected (lua_State *L, Pfunc f, void *ud) {
l_uint32 oldnCcalls = L->nCcalls;
struct lua_longjmp lj;
lj.status = LUA_OK;
lj.previous = L->errorJmp; /* chain new error handler */
L->errorJmp = &lj;
LUAI_TRY(L, &lj,
(*f)(L, ud);
);
L->errorJmp = lj.previous; /* restore old error handler */
L->nCcalls = oldnCcalls;
return lj.status;
}
当yield时,会通过longjmp跳转到resume(LUAI_THROW)设置的位置。也就是说,协程主要是被resume驱动的,执行流执行resume来调用协程逻辑,协程yield之后返回到resume。
l_noret luaD_throw (lua_State *L, int errcode) {
if (L->errorJmp) { /* thread has an error handler? */
L->errorJmp->status = errcode; /* set status */
LUAI_THROW(L, L->errorJmp); /* jump to it */
}
else { /* thread has no error handler */
global_State *g = G(L);
errcode = luaE_resetthread(L, errcode); /* close all upvalues */
if (g->mainthread->errorJmp) { /* main thread has a handler? */
setobjs2s(L, g->mainthread->top.p++, L->top.p - 1); /* copy error obj. */
luaD_throw(g->mainthread, errcode); /* re-throw in main thread */
}
else { /* no handler at all; abort */
if (g->panic) { /* panic function? */
lua_unlock(L);
g->panic(L); /* call panic function (last chance to jump out) */
}
abort();
}
}
}
栗子
在lua中,创建一个协程并不会立即执行,需要使用resume启动第一次运行。
lua
tsecer@harry: cat no_resume.lua
co = coroutine.create(function ()
print("hello world")
end)
tsecer@harry: lua no_resume.lua
tsecer@harry:
go
golang中当通过go启动一个协程的时候,协程立即开始执行。
tsecer@harry: cat gofunc.go
package main
import (
"fmt"
"sync"
)
func hi(wg* sync.WaitGroup) {
defer wg.Done()
fmt.Println("hello world")
}
func main() {
var wg sync.WaitGroup
wg.Add(1)
go hi(&wg)
wg.Wait()
}
tsecer@harry: go run gofunc.go
hello world
tsecer@harry:
python
python的coroutine也明确说明了创建一些协程之后并不会立即执行:
Note that simply calling a coroutine will not schedule it to be executed:
>>> main() <coroutine object main at 0x1053bb7c8>
To actually run a coroutine, asyncio provides the following mechanisms:
The asyncio.run() function to run the top-level entry point “main()” function (see the above example.)
Awaiting on a coroutine. The following snippet of code will print “hello” after waiting for 1 second, and then print “world” after waiting for another 2 seconds:
在python的源代码中,大致可以看到,await的实现思路和lua大致相同:也是转换为了设置跳转点,然后执行RESUME的模式
static int
compiler_add_yield_from(struct compiler *c, location loc, int await)
{
NEW_JUMP_TARGET_LABEL(c, send);
NEW_JUMP_TARGET_LABEL(c, fail);
NEW_JUMP_TARGET_LABEL(c, exit);
USE_LABEL(c, send);
ADDOP_JUMP(c, loc, SEND, exit);
// Set up a virtual try/except to handle when StopIteration is raised during
// a close or throw call. The only way YIELD_VALUE raises if they do!
ADDOP_JUMP(c, loc, SETUP_FINALLY, fail);
ADDOP_I(c, loc, YIELD_VALUE, 1);
ADDOP(c, NO_LOCATION, POP_BLOCK);
ADDOP_I(c, loc, RESUME, await ? RESUME_AFTER_AWAIT : RESUME_AFTER_YIELD_FROM);
ADDOP_JUMP(c, loc, JUMP_NO_INTERRUPT, send);
USE_LABEL(c, fail);
ADDOP(c, loc, CLEANUP_THROW);
USE_LABEL(c, exit);
ADDOP(c, loc, END_SEND);
return SUCCESS;
}
反编译验证
tsecer@harry: cat await_disas.py
import time
import asyncio
async def foo():
await asyncio.sleep(1234)
tsecer@harry: ../python -m dis await_disas.py
0 RESUME 0
1 LOAD_CONST 0 (0)
LOAD_CONST 1 (None)
IMPORT_NAME 0 (time)
STORE_NAME 0 (time)
2 LOAD_CONST 0 (0)
LOAD_CONST 1 (None)
IMPORT_NAME 1 (asyncio)
STORE_NAME 1 (asyncio)
3 LOAD_CONST 2 (<code object foo at 0x7f2563275a60, file "await_disas.py", line 3>)
MAKE_FUNCTION
STORE_NAME 2 (foo)
RETURN_CONST 1 (None)
Disassembly of <code object foo at 0x7f2563275a60, file "await_disas.py", line 3>:
3 RETURN_GENERATOR
POP_TOP
L1: RESUME 0
4 LOAD_GLOBAL 0 (asyncio)
LOAD_ATTR 2 (sleep)
PUSH_NULL
LOAD_CONST 1 (1234)
CALL 1
GET_AWAITABLE 0
LOAD_CONST 0 (None)
L2: SEND 3 (to L5)
L3: YIELD_VALUE 1
L4: RESUME 3
JUMP_BACKWARD_NO_INTERRUPT 5 (to L2)
L5: END_SEND
POP_TOP
RETURN_CONST 0 (None)
L6: CLEANUP_THROW
L7: JUMP_BACKWARD_NO_INTERRUPT 5 (to L5)
-- L8: CALL_INTRINSIC_1 3 (INTRINSIC_STOPITERATION_ERROR)
RERAISE 1
ExceptionTable:
L1 to L3 -> L8 [0] lasti
L3 to L4 -> L6 [2]
L4 to L7 -> L8 [0] lasti
tsecer@harry:
unix
如果把操作系统的fork看做协程所模拟的原始语义,那么fork也是类似于go的予以,在创建协程/进程的同时启动执行;而反观lua/go两种脚本语言,它们协程的实现都是先创建但是不执行,而是需要使用专门的指令(Resume)等来启动协程运行。
这或许是go和Linux的作者都是Ken Thompson,所以go继承了unix操作系统的实现思想?或者有其它更深层的原因?
field访问
在lua中,obj[xxx]和obj.xxx具有相同的效果,都是访问obj的xxx字段,但是在使用的时候有一个微妙的差别:
通过dot访问的时候field名字是编译时确定的,而[]则会运行时求值。如果一个field是在运行时计算出来的,必须使用[]这种方式。
在解析时,如果是dot访问,只是作为一个字符串来处理,如果是[]格式,则会作为表达式解析。
static void suffixedexp (LexState *ls, expdesc *v) {
/* suffixedexp ->
primaryexp { '.' NAME | '[' exp ']' | ':' NAME funcargs | funcargs } */
FuncState *fs = ls->fs;
primaryexp(ls, v);
for (;;) {
switch (ls->t.token) {
case '.': { /* fieldsel */
fieldsel(ls, v);
break;
}
case '[': { /* '[' exp ']' */
expdesc key;
luaK_exp2anyregup(fs, v);
yindex(ls, &key);
luaK_indexed(fs, v, &key);
break;
}
case ':': { /* ':' NAME funcargs */
expdesc key;
luaX_next(ls);
codename(ls, &key);
luaK_self(fs, v, &key);
funcargs(ls, v);
break;
}
case '(': case TK_STRING: case '{': { /* funcargs */
luaK_exp2nextreg(fs, v);
funcargs(ls, v);
break;
}
default: return;
}
}
}
两种方式的区别在于是否运行时求值
static void fieldsel (LexState *ls, expdesc *v) {
/* fieldsel -> ['.' | ':'] NAME */
FuncState *fs = ls->fs;
expdesc key;
luaK_exp2anyregup(fs, v);
luaX_next(ls); /* skip the dot or colon */
codename(ls, &key);
luaK_indexed(fs, v, &key);
}
static void yindex (LexState *ls, expdesc *v) {
/* index -> '[' expr ']' */
luaX_next(ls); /* skip the '[' */
expr(ls, v);
luaK_exp2val(ls->fs, v);
checknext(ls, ']');
}
outro
lua虽小,五脏俱全,体现了很多大型语言的核心实现方法。