

深入Lua:线程和栈
source link: https://zhuanlan.zhihu.com/p/98134347
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.

深入Lua:线程和栈
前面讲了一些Lua对象的实现细节,这一节要从总体上看Lua虚拟机是怎么创建出来的。
一个Lua虚拟机所涉及的各种状态和数据,主要是由两个结构来管理的,一个是global_State
,另一个是lua_State
。global_State负责全局的状态,比如GC相关的,注册表,内存统计等等信息。而lua_State对应于一个Lua线程,当创建一个Lua虚拟机时会自动创建一个“主线程”,默认Lua代码就在这个主线程中执行。而通过协程库可以创建多个“线程”,并使Lua代码执行在不同的“线程”中,这一篇先忽略协程的东西,也就是只关注全局状态和主线程。
因为与虚拟机相关的状态都放在global_State或lua_State中,所以虚拟机的API是可重入的,可以多个系统线程中并行执行多个虚拟机,只要确保每个虚拟机一个时刻只在一个系统线程执行即可。
global_State的声明如下所示,这里我去掉与GC相关的东西(占了主要部分),等到以后说到GC时才列出来。
/*
** 'global state', shared by all threads of this state
全局状态,所有线程共享这个状态
*/
typedef struct global_State {
// 内存分配函数,以及关联的用户数据
lua_Alloc frealloc; /* function to reallocate memory */
void *ud; /* auxiliary data to 'frealloc' */
// 短字符串哈希表
stringtable strt; /* hash table for strings */
// 全局注册表
TValue l_registry;
// 随机函数种子
unsigned int seed; /* randomized seed for hashes */
// 终止函数
lua_CFunction panic; /* to be called in unprotected errors */
// 主线程
struct lua_State *mainthread;
// 版本号
const lua_Number *version; /* pointer to version number */
// 错误消息
TString *memerrmsg; /* memory-error message */
// 元方法名,初始化在luaT_init
TString *tmname[TM_N]; /* array with tag-method names */
// 基本类型的元方法,表和userdata之外的元表放这儿
struct Table *mt[LUA_NUMTAGS]; /* metatables for basic types */
// 零结尾的字符串缓存
TString *strcache[STRCACHE_N][STRCACHE_M]; /* cache for strings in API */
} global_State;
frealloc是设置给Lua的内存分配函数,从这可看出Lua是高度可定制的,你可以在调用lua_newstate
时转入自己的分配函数,也可以调用luaL_newstate
使用Lua提供的默认分配函数,代码如下:
// 默认的分配器:
// nsize == 0 : 行为和free一样
// nsize != 0: 行为和realloc一样,当ptr==NULL时,realloc和malloc一样;否则重分配内存,注意返回的地址和ptr可能不一样。
static void *l_alloc (void *ud, void *ptr, size_t osize, size_t nsize) {
(void)ud; (void)osize; /* not used */
if (nsize == 0) {
free(ptr);
return NULL;
}
else
return realloc(ptr, nsize);
}
后面Lua将只调用frealloc,而不会使用如malloc这样的C函数。lmem.h|c
基于frealloc提供了更上层的分配函数,Lua代码通常只用lmem来分配对象或其他数据结构。
strt 为短字符串缓存,这在前面已经有描述。
l_registry 注册表,是一个Table对象,用于保存全局的Lua值,比如主线程对象,全局环境等等。
seed 是用于计算哈希的随机种子
panic 为终止函数,当代码出现错误且未被保护时,会调用panic函数并终止宿主程,通过lua_atpanic
可设置终止函数。panic在luaD_throw
中被调用,随后会调用abort结束程序。你有最后一个机会不让结束程序,就是在panic函数里调用longjmp,使panic永远不会返回,不过这种做法应该也很少用到。
mainthread 主线程。
在调用lua_newstate
时创建global_State和一个lua_State,此即为线程状态。lua_State是一个Lua对象,代表一个线程,它里面最重要的的数据就是一个用于存放Lua值的栈,和函数的调用信息的链表(CallInfo),去掉和GC相关的以及调试相关的字段后,lua_State结构如下:
// 线程对象
struct lua_State {
// CallInfo数量,一个CallInfo代表一层函数调用
unsigned short nci; /* number of items in 'ci' list */
// 线程状态:LUA_OK...
lu_byte status;
// 栈顶地址
StkId top; /* first free slot in the stack */
// 全局状态
global_State *l_G;
// 当前函数的调用信息
CallInfo *ci; /* call info for current function */
// [stack, stack_last]这个范围的栈槽位可用
StkId stack_last; /* last free slot in the stack */
// 栈的起始地址
StkId stack; /* stack base */
// 错误处理链表:当前的恢复点,看luaD_rawrunprotected
struct lua_longjmp *errorJmp; /* current error recover point */
// 初始调用信息
CallInfo base_ci; /* CallInfo for first level (C calling Lua) */
// 栈大小
int stacksize;
};
StkId的类型为TValue*,实际上stack就是一个TValue数组。
statck, top, stack_last, stacksize
这几个字段的含义用下图说明:
一开始线程创建一个大小为BASIC_STACK_SIZE
的TValue数组,statck指向这个数组的首地址,statcksize为这个栈的大小,top为当前栈顶,向栈压值后top会往下移(增长)。stack_last为最后可用的位置,即正常的栈操作可以在[stack, stack_last]之间。剩下的EXTRA_STACK个槽位预留,用于元表调用或错误处理的栈操作,也就是这些扩展槽位可以让某些操作不用考虑栈空间是否足够,而导致要重分配栈空间的行为。
对栈的原始操作并不会自动增长栈空间,那样每次都要检查空间,对性能比较有影响。对于每个C函数的调用,Lua确保一开始有LUA_MINSTACK(20)
个空闲槽位可以用,一般情况是非常足够了,对于需要在循环里不断压入元素的操作,应该调用lua_checkstack
:
// 检查当前线程的栈空间是否足够,如果不够会扩大整个栈,
// 同时如果当前函数的栈范围不够,也会扩大
LUA_API int lua_checkstack (lua_State *L, int n) {
int res;
// 当前的函数调用信息
CallInfo *ci = L->ci;
lua_lock(L);
api_check(L, n >= 0, "negative 'n'");
if (L->stack_last - L->top > n) /* stack large enough? */
res = 1; /* yes; check is OK */
else { /* no; need to grow stack */
// 计算出正在使用的大小,EXTRA_STACK也认为是使用的部分
int inuse = cast_int(L->top - L->stack) + EXTRA_STACK;
if (inuse > LUAI_MAXSTACK - n) /* can grow without overflow? */
res = 0; /* no */
else /* try to grow stack */
// 在保护模式下调用growstack
res = (luaD_rawrunprotected(L, &growstack, &n) == LUA_OK);
}
// 调整当前CI的栈顶
if (res && ci->top < L->top + n)
ci->top = L->top + n; /* adjust frame top */
lua_unlock(L);
return res;
}
这个函数有几个重要的信息:
- 栈有一个最大的尺寸
LUAI_MAXSTACK
,超过这个最大尺寸则不能增长栈,这个值很大,在int为32位以上的机器上,它是100万个。 - 实际增长空间的函数是
growstack
,它是在保护模式下调用的,关于保护模式的实现这里先略过。 - 增长完毕后,还要调整当前CallInfo的栈使用范围,这个下面会说。
growstack
调用的是luaD_growstack
,它会尝试以2倍的大小扩充栈,最终扩充栈的是luaD_reallocstack
函数:
// 重新分配栈空间
void luaD_reallocstack (lua_State *L, int newsize) {
TValue *oldstack = L->stack;
int lim = L->stacksize;
lua_assert(newsize <= LUAI_MAXSTACK || newsize == ERRORSTACKSIZE);
lua_assert(L->stack_last - L->stack == L->stacksize - EXTRA_STACK);
luaM_reallocvector(L, L->stack, L->stacksize, newsize, TValue);
// 对多出来的槽位填充为nil
for (; lim < newsize; lim++)
setnilvalue(L->stack + lim); /* erase new segment */
// 调整大小字段
L->stacksize = newsize;
L->stack_last = L->stack + newsize - EXTRA_STACK;
// 分配完之后,可能L->stack和oldstack为不同的地址,所以要矫正依赖于栈地址的其他数据
correctstack(L, oldstack);
}
前面的代码都好理解,主要是correctstack这个,它的作用是矫正依赖于栈地址的其他数据,因为当调用luaM_reallocvector
之后可能会重新分配内存地址,所以必须对那些依赖的地方作调整:
// 重新分配栈之后,可能L->stack和oldstack为不同的地址,所以要矫正依赖于栈地址的其他数据
static void correctstack (lua_State *L, TValue *oldstack) {
CallInfo *ci;
UpVal *up;
// 矫正栈顶
L->top = (L->top - oldstack) + L->stack;
// open upvalue
for (up = L->openupval; up != NULL; up = up->u.open.next)
up->v = (up->v - oldstack) + L->stack;
// 调用栈帧
for (ci = L->ci; ci != NULL; ci = ci->previous) {
ci->top = (ci->top - oldstack) + L->stack;
ci->func = (ci->func - oldstack) + L->stack;
if (isLua(ci))
ci->u.l.base = (ci->u.l.base - oldstack) + L->stack;
}
}
这里主要调整3个地方,一个是线程的栈顶,打开的upvalue,和CallInfo链表。
这里说一点个人见解,对于依赖于栈的数据,能否保存偏移,而不是直接保存地址?比如上面的L->top,或ci中的top, func,如果它们都是基于L->stack的偏移值,那么当栈扩充后,这些变量就完全不需要调整。取栈元素时变成这样:StkId e = L->stack + L->top
,这里看起来虽然是多了一个相对寻址,但我认为性能应该不会影响多少,相反代码上肯定会简洁得多。
创建虚拟机
Lua创建一个虚拟机很简单,只需要下面的代码:
// 创建一个虚拟机,L为虚拟机的主线程
lua_State *L = luaL_newstate();
// 打开标准库,如果不需要标准库,下面这一行都可以不要。
luaL_openlibs(L);
luaL_newstate是一个上层封装,主要是调用lua_newstate,并指定默认的内存分析函数,和panic函数:
// 创建虚拟机,并设置panic回调
LUALIB_API lua_State *luaL_newstate (void) {
lua_State *L = lua_newstate(l_alloc, NULL);
if (L) lua_atpanic(L, &panic);
return L;
}
lua_newstate才是真正创建虚拟机的地方,在里面创建glocal_state和主线程lua_State并对它们初始化,不过它的创建手法有点奇妙,它调用分配函数创建这个结构:
typedef struct LG {
LX l;
global_State g;
} LG;
typedef struct LX {
lu_byte extra_[LUA_EXTRASPACE];
lua_State l;
} LX;
也就是一性次把lua_State和global_State一起创建出来了,这样释放主线程,全局状态也跟着回收掉,可见Lua对空间的利用是多么紧凑。释放的相关代码是:
#define fromstate(L) (cast(LX *, cast(lu_byte *, (L)) - offsetof(LX, l)))
LX *l = fromstate(L1);
luaM_free(L, l);
lua_State的前面还有一点附加空间,可以容纳一个void*指针,这个附加空间Lua并没有使用,外部可以用它来保存和虚拟机相关的数据。
创建好虚拟机后,在保护模式下初始化一些其他的数据,在f_luaopen
函数中:
static void f_luaopen (lua_State *L, void *ud) {
global_State *g = G(L);
UNUSED(ud);
// 初始化主线程的栈
stack_init(L, L); /* init stack */
// 注册表
init_registry(L, g);
// 字符串
luaS_init(L);
// 元表
luaT_init(L);
// 标识符
luaX_init(L);
// 开启GC
g->gcrunning = 1; /* allow gc */
// 版本号
g->version = lua_version(NULL);
luai_userstateopen(L);
}
初始化注册表的代码可以看一下:
static void init_registry (lua_State *L, global_State *g) {
TValue temp;
/* create registry */
// 创建注册表
Table *registry = luaH_new(L);
sethvalue(L, &g->l_registry, registry);
luaH_resize(L, registry, LUA_RIDX_LAST, 0);
/* registry[LUA_RIDX_MAINTHREAD] = L */
// 保存主线程
setthvalue(L, &temp, L); /* temp = L */
luaH_setint(L, registry, LUA_RIDX_MAINTHREAD, &temp);
/* registry[LUA_RIDX_GLOBALS] = table of globals */
// 创建全局环境
sethvalue(L, &temp, luaH_new(L)); /* temp = new table (global table) */
luaH_setint(L, registry, LUA_RIDX_GLOBALS, &temp);
}
在创建注册表的时候,会把主线程保存在LUA_RIDX_MAINTHREAD字段,同时创建一个全局环境,后面的标准库模块都保存在这个全局环境中。
Recommend
-
111
单个 Lua 虚拟机只能工作在一个线程下,如果你需要在同一个进程中让 Lua 并行处理一些事务,必须为每个线程部署独立的 Lua 虚拟机。 ps. 在少量多线程应用环境,加锁也是可行的。你可以在编译时自定义 lua_lock...
-
16
作者:nicochen,腾讯 IEG 游戏开发...
-
18
深入Lua:垃圾回收3作为垃圾回收的最后一篇,要来描述一下内存如何统计,什么时候触发GC,以后回收的灵敏度等问题。看了这一章,你应该能够知道如何通过pause和step multiplier这两个参数来控制GC的速度。glo...
-
18
深入Lua:垃圾回收2一份参考文档关于Lua的GC的演进,有一份非常好的幻灯片,是之前看云风文章的时候发现的。文中讲了5.0以前基于标记...
-
24
深入Lua:垃圾回收标记和清扫Lua是一门自动内存管理的语言,它使用的是经典的标记和清扫算法。这个算法的原理其实非常简单,在我们的编程实践中或多或少都有类似的做法,一个理想的实现是这样的:明确对象模型和关系:...
-
4
深入Lua:调用相关的指令这一节我们来深入解析与调用相关的指令,这些指令是:OP_CALL 调用OP_TAILCALL 尾调用OP_VARARG 可变参数OP_RETURN 返回解析这些指令的过程中,最重要的是时刻跟踪栈的变...
-
20
深入Lua:调试设施Lua语言内部提供了完善的Debug库支持,使用它来实现一个Lua调试器并不是特别难。这一篇打算将其核心框架介绍一下。再看函数原型的数据调试的首要条件是要有调试信息,Lua的函数原型就带有丰富的调试信息,在预编译文...
-
7
Ameba , 一个简单的 lua 多线程实现 浏览:3084次 出处信息 几个月以前,在我在 blog 上曾谈及 Lua 5.2 的...
-
2
云风的 BLOG 思绪来得快去得也快,偶尔会在这里停留 ...
-
5
多线程串行运行 Lua 虚拟机 ltask 是一个功能类似 skynet 的库。我主要将...
About Joyk
Aggregate valuable and interesting links.
Joyk means Joy of geeK