LuaJIT源码分析(四)table

LuaJIT源码分析(四)table

lua的table是lua唯一的数据结构,可以用来表示所有的数据。它非常好用而且简洁,但是背后的实现却是十分的复杂。

1、数据结构

table类型定义在luajit的lj_obj.h中:

// lj_obj.h
typedef struct GCtab {
  GCHeader;
  uint8_t nomm;		/* Negative cache for fast metamethods. */
  int8_t colo;		/* Array colocation. */
  MRef array;		/* Array part. */
  GCRef gclist;
  GCRef metatable;	/* Must be at same offset in GCudata. */
  MRef node;		/* Hash part. */
  uint32_t asize;	/* Size of array part (keys [0, asize-1]). */
  uint32_t hmask;	/* Hash part mask (size of hash part - 1). */
#if LJ_GC64
  MRef freetop;		/* Top of free elements. */
#endif
} GCtab;

GCHeader这个字段不必多说,luajit凡是gc object都带有这个头;nomm这个字段是一个negative cache,它用来表示当前table没有提供哪些元方法,这里特别注意它表示的是没有提供,正如注释里写的那样。nomm字段是uint8_t类型的,意味着它最多只能记录8个元方法的状态。通过这个字段,可以快速查找到当前table是否包含这8个元方法,而不用每次都要走一遍table查找。支持快速查找的元方法定义在名为MMS的enum中:

// lj_obj.h
#define MMDEF(_) \
  _(index) _(newindex) _(gc) _(mode) _(eq) _(len) \
  /* Only the above (fast) metamethods are negative cached (max. 8). */ \
  _(lt) _(le) _(concat) _(call) \
  /* The following must be in ORDER ARITH. */ \
  _(add) _(sub) _(mul) _(div) _(mod) _(pow) _(unm) \
  /* The following are used in the standard libraries. */ \
  _(metatable) _(tostring) MMDEF_FFI(_) MMDEF_PAIRS(_)

typedef enum {
#define MMENUM(name)	MM_##name,
MMDEF(MMENUM)
#undef MMENUM
  MM__MAX,
  MM____ = MM__MAX,
  MM_FAST = MM_len
} MMS;

从定义中可知,目前只有index, newindex, gc, mode, eq, len这6个元方法支持快速查找。一个table刚被创建出来的时候,nomm字段赋值为(uint8_t)~0,表示没有提供任何元方法。当对table进行set操作时,是有可能新增一些元方法的,此时nomm字段会被设置为0,表示这个cache当前已经失效了。不过之后如果对table继续进行get操作,则会调用lj_meta_cache对查找结果重新进行缓存。

// lj_meta.c
/* Negative caching of a few fast metamethods. See the lj_meta_fast() macro. */
cTValue *lj_meta_cache(GCtab *mt, MMS mm, GCstr *name)
{
  cTValue *mo = lj_tab_getstr(mt, name);
  lj_assertX(mm <= MM_FAST, "bad metamethod %d", mm);
  if (!mo || tvisnil(mo)) {  /* No metamethod? */
    mt->nomm |= (uint8_t)(1u<<mm);  /* Set negative cache flag. */
    return NULL;
  }
  return mo;
}

从代码可知,如果要查找的元方法不存在,就标记到cache中,避免后续不必要的查找。这个cache标记会一直有效,直到table本身发生了变化。

colo字段表示colocation数组的长度,这个数组就是table的数组部分,如果它比较小,就会在table创建时,和table本身分配在一起,这也是考虑到了程序的局部性,colocation数组最大的长度为16。

// lj_def.h
#define LJ_MAX_COLOSIZE	16		/* Max. elems for colocated array. */

后面几个字段都比较好理解了,array指向table的数组部分,gclist用于垃圾回收的链表,metatable存放table的元表,node指向table的hash表部分,asize和hmask分别表示table的数组部分大小和hash表部分大小。最后的freetop字段,初始时指向table的hash表末尾之后的位置,它始终指向hash表中下一个可以分配的空闲位置之后,这意味着如果freetop和node重合,hash表已经满负荷了,需要重新rehash。

接下来看下表示hash表元素的数据结构。

// lj_obj.h
/* Hash node. */
typedef struct Node {
  TValue val;		/* Value object. Must be first field. */
  TValue key;		/* Key object. */
  MRef next;		/* Hash chain. */
#if !LJ_GC64
  MRef freetop;		/* Top of free elements (stored in t->node[0]). */
#endif
} Node;

Node结构包含一个键值对和一个指向下一个Node的指针,一般来说value的访问频率会高于key,所以为了性能考虑,把value放在了key的前面。在非LJ_GC64模式下,它还有一个freetop字段,这个字段的用途和之前table里定义的相同,它只在hash桶的第一个元素中是有意义的。那这里可能就有疑问了,既然只有第一个元素有意义,那为什么不直接放在table的定义里,而要放在Node里面呢?答案就在这个LJ_GC64的判断里。

首先,我们来研究在LJ_GC64模式下,不考虑freetop字段的GCtab和Node的内存分布:

在这里插入图片描述

在这里插入图片描述

再看非LJ_GC64模式下,同样不考虑freetop字段的GCtab和Node的内存分布:

在这里插入图片描述

在这里插入图片描述

通过对比,其实已经可以发现端倪了,在非LJ_GC64模式下,Node结构最后有个4字节的padding,而freetop字段恰好又是4字节大小,那么本着不用白不用的道理,将freetop字段放在Node结构下,并不会增加Node的大小,而放回GCtab结构,则会给GCtab增加4字节的大小。LuaJIT为了节约内存,着实煞费苦心。

综上所述,LuaJIT中table的数据存放于两类数据结构中,一是数组,一是hash表,在LJ_GC64模式下,table的内存分布如下图所示。

在这里插入图片描述

2、创建

LuaJIT中创建一个新的table最常见的API为lj_tab_new

// lj_tab.c
/* Create a new table.
**
** IMPORTANT NOTE: The API differs from lua_createtable()!
**
** The array size is non-inclusive. E.g. asize=128 creates array slots
** for 0..127, but not for 128. If you need slots 1..128, pass asize=129
** (slot 0 is wasted in this case).
**
** The hash size is given in hash bits. hbits=0 means no hash part.
** hbits=1 creates 2 hash slots, hbits=2 creates 4 hash slots and so on.
*/
GCtab *lj_tab_new(lua_State *L, uint32_t asize, uint32_t hbits)
{
  GCtab *t = newtab(L, asize, hbits);
  clearapart(t);
  if (t->hmask > 0) clearhpart(t);
  return t;
}

由注释可知,asize参数表示创建table时初始数组部分大小,它是开区间的,asize本身并不在数组的有效范围内。hbits参数表示初始hash表部分大小对应的二进制位数,LuaJIT的hash表大小永远都是2的整数次幂,因此可以用位数来表示实际大小,实际大小hsize = 1 << hbits。如果hbits为0,则hash表部分不存在。

负责具体创建逻辑的是newtab这个函数。

// lj_tab.c
/* Create a new table. Note: the slots are not initialized (yet). */
static GCtab *newtab(lua_State *L, uint32_t asize, uint32_t hbits)
{
  GCtab *t;
  /* First try to colocate the array part. */
  if (LJ_MAX_COLOSIZE != 0 && asize > 0 && asize <= LJ_MAX_COLOSIZE) {
    Node *nilnode;
    lj_assertL((sizeof(GCtab) & 7) == 0, "bad GCtab size");
    t = (GCtab *)lj_mem_newgco(L, sizetabcolo(asize));
    t->gct = ~LJ_TTAB;
    t->nomm = (uint8_t)~0;
    t->colo = (int8_t)asize;
    setmref(t->array, (TValue *)((char *)t + sizeof(GCtab)));
    setgcrefnull(t->metatable);
    t->asize = asize;
    t->hmask = 0;
    nilnode = &G(L)->nilnode;
    setmref(t->node, nilnode);
#if LJ_GC64
    setmref(t->freetop, nilnode);
#endif
  } else {  /* Otherwise separately allocate the array part. */
    Node *nilnode;
    t = lj_mem_newobj(L, GCtab);
    t->gct = ~LJ_TTAB;
    t->nomm = (uint8_t)~0;
    t->colo = 0;
    setmref(t->array, NULL);
    setgcrefnull(t->metatable);
    t->asize = 0;  /* In case the array allocation fails. */
    t->hmask = 0;
    nilnode = &G(L)->nilnode;
    setmref(t->node, nilnode);
#if LJ_GC64
    setmref(t->freetop, nilnode);
#endif
    if (asize > 0) {
      if (asize > LJ_MAX_ASIZE)
	lj_err_msg(L, LJ_ERR_TABOV);
      setmref(t->array, lj_mem_newvec(L, asize, TValue));
      t->asize = asize;
    }
  }
  if (hbits)
    newhpart(L, t, hbits);
  return t;
}

LuaJIT首先根据传入asize的值,判断是否要把数组部分和table本身创建在一起。目前定义的最大允许的值为LJ_MAX_COLOSIZE,如果满足条件,则预先分配一个sizetabcolo(asize)大小的空间,这个值就是数组部分+table本身的空间大小。

// lj_obj.h
#define sizetabcolo(n)	((n)*sizeof(TValue) + sizeof(GCtab))

这种情况下,数组的首地址自然就是分配空间的首地址加上GCtab大小的偏移了。而如果不满足条件,就需要单独分配数组部分的空间和table本身的空间,再单独保存数组部分的首地址了。

hash表的头指针默认指向全局的nilnode字段,该字段是在lua虚拟机启动时创建的:

// lj_state.c
#if LJ_64 && !LJ_GC64 && !(defined(LUAJIT_USE_VALGRIND) && defined(LUAJIT_USE_SYSMALLOC))
lua_State *lj_state_newstate(lua_Alloc allocf, void *allocd)
#else
LUA_API lua_State *lua_newstate(lua_Alloc allocf, void *allocd)
#endif
{
  ...
  setnilV(&g->nilnode.val);
  setnilV(&g->nilnode.key);
#if !LJ_GC64
  setmref(g->nilnode.freetop, &g->nilnode);
#endif
  ...
}

如果传入的hbits参数大于0,则会调用newhpart函数初始化hash表。

// lj_tab.c
/* Create new hash part for table. */
static LJ_AINLINE void newhpart(lua_State *L, GCtab *t, uint32_t hbits)
{
  uint32_t hsize;
  Node *node;
  lj_assertL(hbits != 0, "zero hash size");
  if (hbits > LJ_MAX_HBITS)
    lj_err_msg(L, LJ_ERR_TABOV);
  hsize = 1u << hbits;
  node = lj_mem_newvec(L, hsize, Node);
  setmref(t->node, node);
  setfreetop(t, node, &node[hsize]);
  t->hmask = hsize-1;
}

在分配好空间之后,node指针就会指向它,并且这里需要设置一下freetop,freetop的含义前面我们已经介绍过了,可以看到&node[hsize]就是最后一个空闲位置之后的地址。setfreetop是一个宏,前面也提到freetop字段可能存在于GCtab或者Node中,这个宏就是用来封装这一细节的。

// lj_obj.h
#if LJ_GC64
#define setfreetop(t, n, v)	(setmref((t)->freetop, (v)))
#else
#define setfreetop(t, n, v)	(setmref((n)->freetop, (v)))
#endif

table创建完之后,需要对数组部分和hash表部分分别进行clear操作,clear的逻辑很简单,这里就不赘述了。

// lj_tab.c
/* Clear hash part of table. */
static LJ_AINLINE void clearhpart(GCtab *t)
{
  uint32_t i, hmask = t->hmask;
  Node *node = noderef(t->node);
  lj_assertX(t->hmask != 0, "empty hash part");
  for (i = 0; i <= hmask; i++) {
    Node *n = &node[i];
    setmref(n->next, NULL);
    setnilV(&n->key);
    setnilV(&n->val);
  }
}

/* Clear array part of table. */
static LJ_AINLINE void clearapart(GCtab *t)
{
  uint32_t i, asize = t->asize;
  TValue *array = tvref(t->array);
  for (i = 0; i < asize; i++)
    setnilV(&array[i]);
}

3、查找

给定一个key,从table中找到对应的value,这个过程是在函数lj_tab_get中完成的。

// lj_tab.c
cTValue *lj_tab_get(lua_State *L, GCtab *t, cTValue *key)
{
  if (tvisstr(key)) {
    cTValue *tv = lj_tab_getstr(t, strV(key));
    if (tv)
      return tv;
  } else if (tvisint(key)) {
    cTValue *tv = lj_tab_getint(t, intV(key));
    if (tv)
      return tv;
  } else if (tvisnum(key)) {
    lua_Number nk = numV(key);
    int32_t k = lj_num2int(nk);
    if (nk == (lua_Number)k) {
      cTValue *tv = lj_tab_getint(t, k);
      if (tv)
	return tv;
    } else {
      goto genlookup;  /* Else use the generic lookup. */
    }
  } else if (!tvisnil(key)) {
    Node *n;
  genlookup:
    n = hashkey(t, key);
    do {
      if (lj_obj_equal(&n->key, key))
	return &n->val;
    } while ((n = nextnode(n)));
  }
  return niltv(L);
}

根据key的类型不同,函数会使用不同的get逻辑进行处理。如果key是一个字符串,则会调用lj_tab_getstr

// lj_tab.c
cTValue *lj_tab_getstr(GCtab *t, const GCstr *key)
{
  Node *n = hashstr(t, key);
  do {
    if (tvisstr(&n->key) && strV(&n->key) == key)
      return &n->val;
  } while ((n = nextnode(n)));
  return NULL;
}

第一步,字符串key是不可能位于table的数组部分的。这里hashstr会根据字符串的id值,计算出该字符串应该在hash表中所处的位置,由函数实现可知,这里只是简单地按位与了下mask。

// lj_tab.h
/* Hash values are masked with the table hash mask and used as an index. */
static LJ_AINLINE Node *hashmask(const GCtab *t, uint32_t hash)
{
  Node *n = noderef(t->node);
  return &n[hash & t->hmask];
}

/* String IDs are generated when a string is interned. */
#define hashstr(t, s)		hashmask(t, (s)->sid)

第二步,我们知道,hash表部分实际上是多个链表,由于相同hash值的存在,hash碰撞是不可避免的,LuaJIT使用链表的形式来解决冲突,即相同hash值的Node,会被链接在一起。那么,只要从hash值计算出的位置,所对应的Node(记作Main Node),作为链表头一路搜索即可。作为字符串,判断key相等的条件是,类型相同而且指向GCstr的指针,也要相同。这是因为LuaJIT的字符串都是interning的,同样的字符串内存中只存在一份拷贝。

在这里插入图片描述

如果key是一个int类型(该类型只在LJ_DUALNUM = 1时存在),那么先要考虑key在不在数组部分,如果不在数组部分再从hash表里查找。

// lj_tab.h
#define inarray(t, key)		((MSize)(key) < (MSize)(t)->asize)
#define arrayslot(t, i)		(&tvref((t)->array)[(i)])
#define lj_tab_getint(t, key) \
  (inarray((t), (key)) ? arrayslot((t), (key)) : lj_tab_getinth((t), (key)))

从hash表查找int的逻辑和字符串差不多,同样是计算出hash值,然后沿着链表查找。

// lj_tab.c
cTValue * LJ_FASTCALL lj_tab_getinth(GCtab *t, int32_t key)
{
  TValue k;
  Node *n;
  k.n = (lua_Number)key;
  n = hashnum(t, &k);
  do {
    if (tvisnum(&n->key) && n->key.n == k.n)
      return &n->val;
  } while ((n = nextnode(n)));
  return NULL;
}

int类型的key本质上就是一个值,所以也要考虑table中存放的key是number类型的可能性。只要它们的数值相等,那么key就是相等的。

同样,如果查找的key是number类型,那么先要看它是否其实是一个int数值,如果是,那就和int类型key的查找逻辑完全相同。换言之,可以无损转换为int的number,和int其实是等价的。下面这个例子可以加以验证:

local t = {}
t[1.0] = 5
t[2] = 6
print(t[1], t[2.0]) -- 5 6

最后,对于其他类型和不是int数值的number类型,会使用通用的hashkey函数计算出key的hash值,找到相应的位置;再使用通用的lj_obj_equal函数,来判断key是否相等。

4、插入

给定一个key,插入到table的过程,是在函数lj_tab_set中完成的。

// lj_tab.c
TValue *lj_tab_set(lua_State *L, GCtab *t, cTValue *key)
{
  Node *n;
  t->nomm = 0;  /* Invalidate negative metamethod cache. */
  if (tvisstr(key)) {
    return lj_tab_setstr(L, t, strV(key));
  } else if (tvisint(key)) {
    return lj_tab_setint(L, t, intV(key));
  } else if (tvisnum(key)) {
    lua_Number nk = numV(key);
    int32_t k = lj_num2int(nk);
    if (nk == (lua_Number)k)
      return lj_tab_setint(L, t, k);
    if (tvisnan(key))
      lj_err_msg(L, LJ_ERR_NANIDX);
    /* Else use the generic lookup. */
  } else if (tvisnil(key)) {
    lj_err_msg(L, LJ_ERR_NILIDX);
  }
  n = hashkey(t, key);
  do {
    if (lj_obj_equal(&n->key, key))
      return &n->val;
  } while ((n = nextnode(n)));
  return lj_tab_newkey(L, t, key);
}

插入的过程是有可能给table新增或删除元方法的,因此nomm字段直接设置为0,表示缓存失效。与get方法类似,set方法也会根据key的类型来调用不同的处理函数。所谓的不同逻辑,是指如何计算key的hash值,以及如何判断插入的key与已有的key两者是否相等。对于int类型或者可转换为int的number类型,优先查找table的数组部分,如果找不到再去查找hash表部分;对于其他情况,则只需查找hash表部分。最终,如果在table中找到了key,那么就可以直接取出对应Node的val字段,返回给上层修改即可。

如果key不存在,就需要在table中新增了,此时会调用lj_tab_newkey函数:

// lj_tab.c
/* Insert new key. Use Brent's variation to optimize the chain length. */
TValue *lj_tab_newkey(lua_State *L, GCtab *t, cTValue *key)
{
  Node *n = hashkey(t, key);
  if (!tvisnil(&n->val) || t->hmask == 0) {
    Node *nodebase = noderef(t->node);
    Node *collide, *freenode = getfreetop(t, nodebase);
    lj_assertL(freenode >= nodebase && freenode <= nodebase+t->hmask+1,
	       "bad freenode");
    do {
      if (freenode == nodebase) {  /* No free node found? */
	rehashtab(L, t, key);  /* Rehash table. */
	return lj_tab_set(L, t, key);  /* Retry key insertion. */
      }
    } while (!tvisnil(&(--freenode)->key));
    setfreetop(t, nodebase, freenode);
    lj_assertL(freenode != &G(L)->nilnode, "store to fallback hash");
    collide = hashkey(t, &n->key);
    if (collide != n) {  /* Colliding node not the main node? */
      while (noderef(collide->next) != n)  /* Find predecessor. */
	collide = nextnode(collide);
      setmref(collide->next, freenode);  /* Relink chain. */
      /* Copy colliding node into free node and free main node. */
      freenode->val = n->val;
      freenode->key = n->key;
      freenode->next = n->next;
      setmref(n->next, NULL);
      setnilV(&n->val);
      /* Rechain pseudo-resurrected string keys with colliding hashes. */
      while (nextnode(freenode)) {
	Node *nn = nextnode(freenode);
	if (!tvisnil(&nn->val) && hashkey(t, &nn->key) == n) {
	  freenode->next = nn->next;
	  nn->next = n->next;
	  setmref(n->next, nn);
	  /*
	  ** Rechaining a resurrected string key creates a new dilemma:
	  ** Another string key may have originally been resurrected via
	  ** _any_ of the previous nodes as a chain anchor. Including
	  ** a node that had to be moved, which makes them unreachable.
	  ** It's not feasible to check for all previous nodes, so rechain
	  ** any string key that's currently in a non-main positions.
	  */
	  while ((nn = nextnode(freenode))) {
	    if (!tvisnil(&nn->val)) {
	      Node *mn = hashkey(t, &nn->key);
	      if (mn != freenode && mn != nn) {
		freenode->next = nn->next;
		nn->next = mn->next;
		setmref(mn->next, nn);
	      } else {
		freenode = nn;
	      }
	    } else {
	      freenode = nn;
	    }
	  }
	  break;
	} else {
	  freenode = nn;
	}
      }
    } else {  /* Otherwise use free node. */
      setmrefr(freenode->next, n->next);  /* Insert into chain. */
      setmref(n->next, freenode);
      n = freenode;
    }
  }
  n->key.u64 = key->u64;
  if (LJ_UNLIKELY(tvismzero(&n->key)))
    n->key.u64 = 0;
  lj_gc_anybarriert(L, t);
  lj_assertL(tvisnil(&n->val), "new hash slot is not empty");
  return &n->val;
}

这个函数相当地复杂,需要我们逐步进行剖析。

首先,我们知道,LuaJIT的hash表部分本质上是一个Node类型数组,每个Node包含了key,value以及指向下一个Node的指针。而每个Node,实际上有三种不同的状态:

State Namekeyvalue
Freenilnil
Deadnot nilnil
Alivenot nilnot nil

在一开始,每个Node都是free的状态,当一个key插入进来的时候,Node就从free状态变成了alive状态。当一个key被删除时(通过对value赋值为nil),Node会从alive状态,转变为dead状态。注意,dead状态下,key的值没有赋值为nil,而是保持不变。关于这点在LuaJIT的lj_obj.h的开头注释里有说明:

Note: In contrast to Lua’s GC, LuaJIT’s GC does not specially mark
dead keys in tables. The reference is left in, but it’s guaranteed to
be never dereferenced as long as the value is nil. It’s ok if the key is
freed or if any object subsequently gets the same address.

换言之,这个key指向的对象有可能已经被GC掉了,也有可能指向一个使用了原地址的新对象。LuaJIT会保证这些情况下,key都不会进行解引用。另外,如果新插入的key与dead状态Node的key相同,那么这个Node就从dead状态变成了alive状态,也被称作为复活

然后,我们回忆一下在get时提到了Main Node概念,它其实就是一个key根据hash运算之后,应该所处的Node位置。只不过由于hash冲突的存在,这个Node可能已经被别的key先占掉了。抢占的key分为两种情况,一种它的hash值所对应的索引位置也是这个Node,就是名正言顺地占有了这个Node,那么两个key的Main Node是同一个,新来的key只能去别的地方将就一下,通过Main Node的next指针来访问它;还有一种,就是它其实是因为Hash冲突,迫不得已占有这个Node的,那么这时新来的key就要把原本属于它的位置给夺回来,原先抢占Node的key需要挪个地方。

以上是LuaJIT插入一个key的基本思路。从基本思路中可以总结出三条hash表不变式

  1. 如果表中存在一个key,那么从这个key的Main Node开始对链表进行搜索,搜索0次或者多次,最终一定能找到该key;
  2. 一个Node最多只有一个前驱,并且如果Node是alive状态的,那么它的key的Main Node一定是它的若干次前驱(0次或者多次);
  3. Main Node没有前驱。

现在让我们回到插入逻辑本身。当插入一个newkey时:

  1. 寻找newkeyMain Node,如果该Node的key与newkey相等,那么直接返回该Node(可能是复活);
  2. 沿着Main Node的next指针一路遍历下去,如果某个Node的key与newkey相等,那么返回它(可能是复活);
  3. 从这一步开始进入lj_tab_newkey的逻辑,如果Main Node的val为nil(free或dead状态),就先修改它的key为newkey,然后再拿它作为Node返回(这里不可能是复活,因为key发生了变化);
  4. 从hash表中最远的freetop处开始找起,寻找一个key为nil的Node,直到找到为止,然后处理Main Node被抢占的问题;
  5. 如果找不到空闲的Node,说明hash表已满,需要rehash扩容,然后再次从步骤1开始尝试。

终于,我们要开始处理Main Node被抢占的问题了。假设newkeyMain Noden,而n的key的Main Nodecollide。根据不变式1,那么有:

collide -> … -> n

假设第4步中找到的空闲Node为freenode,以及n的下一个Node为nn,那么有:

collide -> … -> n -> nn -> …

我们希望,插入newkey之后,前面定义的三条hash表不变式依旧满足。如果collide就是n的话,问题就简单很多,直接把newkey塞进freenode里,然后freenode插入到n之后即可。

collide -> … -> n -> freenode -> nn -> …

但是,如果collide不是n,意味着n的key其实是鸠占鹊巢了,如果还按上面的方式处理,那么不变式3显然无法满足。(nnewkeyMain Node,但collide又是n的前驱)

此时的处理方式如下:

  1. 找到n的前驱,修改它的next指针,指向freenode
  2. n的所有信息转移到freenode,然后把n清空;
  3. newkey塞到n中。

现在得到的就是两个链表了,newkey夺回了属于自己的Node,原来的key挪到了freenode,并重新链接回链表。

collide -> … -> freenode -> nn -> …

n

那么,三条hash表不变式似乎都满足,仿佛万事大吉了。然而,LuaJIT源码中还有一大段篇幅在处理Rechain pseudo-resurrected string keys with colliding hashes.的问题,这个又是什么?有没有一种可能,从nn -> ...这个链表中,存在某些Node的Main Node,是n

这就要说回LuaJIT的table设计了,前面提到,如果Node的value赋值为nil,key是不会被清除的,也就是说,key很可能持有一个失效的地址。但问题在于,如果这个失效的地址上新分配了一个key,而这个新key也要插入到同一个table里,并且是直接复用了原来的Node,此时就很微妙了。

来看这个比较复杂的例子:

在这里插入图片描述

如上图所示,Node3的key,hash值从0变成了2,但是它却混在hash值为0的队伍里了。而且Node3再也无法访问到了,此时不变式1,2都被打破了。

5、伪复活

LuaJIT称上述问题为伪复活。最早版本的修复代码如下:

while (nextnode(freenode)) {
	Node *nn = nextnode(freenode);
	if (tvisstr(&nn->key) && !tvisnil(&nn->val) &&
		hashstr(t, strV(&nn->key)) == n) {
	 	freenode->next = nn->next;
	  	nn->next = n->next;
	  	setmref(n->next, nn);
	} else {
	  	freenode = nn;
	}
}

修复逻辑就是把freenode之后的Node都遍历一遍,看看有没有alive状态的Node,它的key是字符串类型,且Main Noden,如果有(伪复活导致),就把它挪到n链表。对照着上图,可以修复最后一步导致的问题。

在这里插入图片描述

不过,这样做是不够的,因为伪复活后新的hash值可能千奇百怪,例如下面这个例子,最后Node7打破了不变式1和2:

在这里插入图片描述

所以就有了第二个版本的修复代码:

while (nextnode(freenode)) {
    Node *nn = nextnode(freenode);
	if (tvisstr(&nn->key) && !tvisnil(&nn->val) &&
	    hashstr(t, strV(&nn->key)) == n) {
        freenode->next = nn->next;
        nn->next = n->next;
        setmref(n->next, nn);
        /*
        ** Rechaining a resurrected string key creates a new dilemma:
        ** Another string key may have originally been resurrected via
        ** _any_ of the previous nodes as a chain anchor. Including
        ** a node that had to be moved, which makes them unreachable.
        ** It's not feasible to check for all previous nodes, so rechain
        ** any string key that's currently in a non-main positions.
        */
        while ((nn = nextnode(freenode))) {
            if (tvisstr(&nn->key) && !tvisnil(&nn->val)) {
                Node *mn = hashstr(t, strV(&nn->key));
                if (mn != freenode) {
                    freenode->next = nn->next;
                    nn->next = mn->next;
                    setmref(mn->next, nn);
                } else {
                    freenode = nn;
                }
            } else {
                freenode = nn;
            }
        }
        break;
	} else {
        freenode = nn;
	}
}

说白了,就是当发现Main Noden的Node时,还要把它后面因为伪复活导致链接在错误位置的Node,全部链接到正确的链表中去。这样上图所出现的问题就可以被修复。

在这里插入图片描述

这样做可以让链表都满足不变式1和2,但是未必能满足3,不过3其实是优化的可选项,倒是不会引起其他问题。然而,这个版本的修复代码依旧有点小问题。

其一是它限定了key为字符串类型,但实际上只要是GC Object,都有这样的可能,我们上面画的图里,也没有假定key为字符串类型。

其二是它认为链接到Main Noden的Node之后的所有Node,如果它们的Main Node不是freenode,那它们一定是链接到了错误的位置,但其实不一定,有可能它的Main Node是它自身,也就是它其实是处在了正确的位置,只是它存在前驱罢了。按照这个版本的修复逻辑,会导致链表成环而出现死循环,如下图所示。

在这里插入图片描述

最终版本的修复代码如下:

while (nextnode(freenode)) {
	Node *nn = nextnode(freenode);
	if (!tvisnil(&nn->val) && hashkey(t, &nn->key) == n) {
        freenode->next = nn->next;
        nn->next = n->next;
        setmref(n->next, nn);
        /*
        ** Rechaining a resurrected string key creates a new dilemma:
        ** Another string key may have originally been resurrected via
        ** _any_ of the previous nodes as a chain anchor. Including
        ** a node that had to be moved, which makes them unreachable.
        ** It's not feasible to check for all previous nodes, so rechain
        ** any string key that's currently in a non-main positions.
        */
        while ((nn = nextnode(freenode))) {
            if (!tvisnil(&nn->val)) {
                Node *mn = hashkey(t, &nn->key);
                if (mn != freenode && mn != nn) {
                    freenode->next = nn->next;
                    nn->next = mn->next;
                    setmref(mn->next, nn);
                } else {
                    freenode = nn;
                }
            } else {
                freenode = nn;
            }
        }
        break;
	} else {
        freenode = nn;
	}
}

可能这里会对修复的前提条件hashkey(t, &nn->key) == n有疑问,实际上伪复活发生之后,如果没有额外的插入,其实也是没有问题的,此时不变式1和2都是满足的,只有优化项3不满足,并不影响访问。而对需要调整的链表而言,在调整位置之后的Node,它们的Main Node如果不是n,压根不会在插入过程发生变化,当然也不会影响到它们的正常访问。

6、rehash

如果hash表的空间不够了,就会进入到rehash阶段。rehash过程会去调整table数组部分和hash表部分的大小,如果空间不够会分配更多的空间,如果空间利用率太少,就要缩减内存。

static void rehashtab(lua_State *L, GCtab *t, cTValue *ek)
{
  uint32_t bins[LJ_MAX_ABITS];
  uint32_t total, asize, na, i;
  for (i = 0; i < LJ_MAX_ABITS; i++) bins[i] = 0;
  asize = countarray(t, bins);
  total = 1 + asize;
  total += counthash(t, bins, &asize);
  asize += countint(ek, bins);
  na = bestasize(bins, &asize);
  total -= na;
  lj_tab_resize(L, t, asize, hsize2hbits(total));
}

rehash大致可分为以下若干步骤,这里我们假设有一个table,它的数组部分为{1, 2, 3, nil, nil, nil, nil, nil, nil, 10},hash表部分为{ [12] = 12 },要插入的key为7。

(1)根据原先数组部分的大小按2的次幂分为n个桶,然后挨个统计每个桶中有效key(即val不为nil)的数量,最后计算出整个数组部分有效key的总数;在例子中,数组部分大小为10,实际有效key的数量为4,可以分出4个桶,每个桶中的计数如下:

在这里插入图片描述

(2)找到hash表部分中,可以属于数组部分的有效key,并计算出它们所对应的桶的位置,相应地增加该桶所统计的数量,以及数组部分的大小;在例子中,12这个key可以对应到3号桶中,因此3号桶计数变为2,数组部分有效key数量变为5:

在这里插入图片描述

(3)对要插入的新key,重复上一步骤,此时已经得到了理想情况下,数组部分所有有效key的总数;在例子中即为6:

在这里插入图片描述

(4)计算数组部分的空间利用率,计算方法很简单,就是统计每个桶所对应的数组部分切片中,有效key数量的占比,如果超过50%,则认为空间利用率OK,这个桶可以作为数组部分。自然地,这里需要遍历所有桶,找到符合条件的最大值,作为table最终的数组部分大小;在例子中,0号桶利用率为 2 / 2 = 100%,1号桶为(2 + 1) / 4 = 75%,2号桶为(2 + 1 + 1) / 8 = 50%,3号桶为(2 + 1 + 1 + 2) / 16 = 37.5%。最大的只有1号桶满足空间率超过50%,所以最终数组部分的大小为4,进而剩余的有效key数量为2,对应的hash bit为1,因此hash表部分的大小为2的1次幂,也就是2。

(5)使用新的值,调整table的内存分配,数组部分直接realloc即可,但hash表部分需要重新malloc。如果数组部分收缩了,则需要把多出来的value插入到新的hash表部分;如果原先table中有hash表部分,则需要把里面所有的有效key,插入到新的hash表部分中,原先在旧的hash表部分所维持的链表信息,也将不复存在,重新建立。可以看到,这一部分的操作是比较耗的,其中既涉及到了内存分配,又涉及到了大量的key的重新插入。在例子中,最终table的数组部分为{1, 2, 3, nil},hash表部分为{ [7] = 7, [12] = 12}

7、遍历

遍历部分就比较简单了,我们重点关注一下LuaJIT是如何根据已有的key,找到下一个要遍历的key:

/* Table traversal indexes:
**
** Array key index: [0 .. t->asize-1]
** Hash key index:  [t->asize .. t->asize+t->hmask]
** Invalid key:     ~0
*/

/* Get the successor traversal index of a key. */
uint32_t LJ_FASTCALL lj_tab_keyindex(GCtab *t, cTValue *key)
{
  TValue tmp;
  if (tvisint(key)) {
    int32_t k = intV(key);
    if ((uint32_t)k < t->asize)
      return (uint32_t)k + 1;
    setnumV(&tmp, (lua_Number)k);
    key = &tmp;
  } else if (tvisnum(key)) {
    lua_Number nk = numV(key);
    int32_t k = lj_num2int(nk);
    if ((uint32_t)k < t->asize && nk == (lua_Number)k)
      return (uint32_t)k + 1;
  }
  if (!tvisnil(key)) {
    Node *n = hashkey(t, key);
    do {
      if (lj_obj_equal(&n->key, key))
	return t->asize + (uint32_t)((n+1) - noderef(t->node));
    } while ((n = nextnode(n)));
    if (key->u32.hi == LJ_KEYINDEX)  /* Despecialized ITERN while running. */
      return key->u32.lo;
    return ~0u;  /* Invalid key to next. */
  }
  return 0;  /* A nil key starts the traversal. */
}

可以看到,初始时如果key为nil,那么会先从index=0处开始遍历,index为0既可能是数组部分的第一个有效的key,也可能是hash表部分的第一个有效的key。函数会优先遍历数组部分,直到数组部分遍历完,再去遍历hash表部分。

8、取长度

LuaJIT取table的长度可以分为两步,首先如果table包含数组部分,且数组部分的最后一个元素为nil,那意味着table的长度绝对不会超过数组部分的大小,就可以安心地在数组部分内进行二分查找即可,这样就比较快:

MSize LJ_FASTCALL lj_tab_len(GCtab *t)
{
  size_t hi = (size_t)t->asize;
  if (hi) hi--;
  /* In a growing array the last array element is very likely nil. */
  if (hi > 0 && LJ_LIKELY(tvisnil(arrayslot(t, hi)))) {
    /* Binary search to find a non-nil to nil transition in the array. */
    size_t lo = 0;
    while (hi - lo > 1) {
      size_t mid = (lo+hi) >> 1;
      if (tvisnil(arrayslot(t, mid))) hi = mid; else lo = mid;
    }
    return (MSize)lo;
  }
  /* Without a hash part, there's an implicit nil after the last element. */
  return t->hmask ? tab_len_slow(t, hi) : (MSize)hi;
}

否则,考虑到数组部分之外的元素可能位于hash表中,也得对hash表进行搜索。首先要确定搜索的上下限,再根据上下限进行二分查找;如果找不到上下限,就得退化成线性搜索,效率更低。

LJ_NOINLINE static MSize tab_len_slow(GCtab *t, size_t hi)
{
  cTValue *tv;
  size_t lo = hi;
  hi++;
  /* Widening search for an upper bound. */
  while ((tv = lj_tab_getint(t, (int32_t)hi)) && !tvisnil(tv)) {
    lo = hi;
    hi += hi;
    if (hi > (size_t)(INT_MAX-2)) {  /* Punt and do a linear search. */
      lo = 1;
      while ((tv = lj_tab_getint(t, (int32_t)lo)) && !tvisnil(tv)) lo++;
      return (MSize)(lo - 1);
    }
  }
  /* Binary search to find a non-nil to nil transition. */
  while (hi - lo > 1) {
    size_t mid = (lo+hi) >> 1;
    cTValue *tvb = lj_tab_getint(t, (int32_t)mid);
    if (tvb && !tvisnil(tvb)) lo = mid; else hi = mid;
  }
  return (MSize)lo;
}

注意,最终计算出的长度,只对连续的table才合法有效。如果是带有空洞的table,比如{1, 2, 3, nil, nil, nil, nil, nil, nil, 10},返回的长度可能就是10,但毫无意义。

综上所述,通过一系列的分析,我们可以得出,为了提升效率,在使用table时,最好只使用到table的数组部分,当数组部分和hash表部分同时存在时,table的效率会打折。另外,最好通过预分配的形式,指定table的内存大小,避免后续频繁rehash,rehash的代价还是很高昂的。

Reference

[1] Infinite loop on table lookup

[2] Lua 源码分析之Table - Rehash过程

[3] 性能提升10倍的秘诀:必须用好 table

  • 12
    点赞
  • 13
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值