7

深入Lua:线程和栈

 3 years ago
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.
neoserver,ios ssh client

深入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字段,同时创建一个全局环境,后面的标准库模块都保存在这个全局环境中。


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK