skynet sharetable遇到的一些问题
看着自己好久没写东西了,最近遇到了不少事情,比如公司被卖,比如最近工作也不太顺利。
关于sharetable
skynet 的 sharetable一般在游戏业务是用来存配置表的,sharetable提供一种机制让这份表被所有服务都可以读取,节省了其他服务在持有一份的内存,更新时候也比较方便,只要更新sharetable服务的那个表就可以在其他服务重新query的时候读取到新值。
在sharetable之前,还有一个非常值得说的是sharedata,他在定位上和sharetable是一样的,不过他有一个能力就是,sharedata在update之后,不需要重新query就可以让local变量拿到新值。
代码上看的话大概就是:
local tb = res.b -- tb = { hp = 5, mp = 10 } -- sharedata update hp -> 1 print(tb.hp) --> 1 --sharetable update hp -> 1 print(tb.hp) --> 5 -- sharetable.query print(tb.hp) --> 1
这里对于我们自己的游戏业务来说,从sharedata迁移到sharetable是有负担的,因为我们不知道那些表可能需要我重新query一次,同时注入服务去扫lvm又会导致更新非常慢。
STW方案
在保留sharedata这种更新引用的能力又可以像原生table一样使用的考虑情况下,聊了一个方案:STW,做完后给skynet提了个pr,当时是想着希望云风大佬看看帮忙给点意见
方案的核心思路是:找到一个所有worker线程都不读sharetable的情况下,直接替换掉对应table的storage。
于是就考虑STW,在所有消息处理完毕的时候,让worker停下来。 缺点是,如果有一个消息需要处理非常久,那么就可能一直卡住其他worker。这里的处理方法是,设置2s的超时,如果2s内所有worker没有达到检查点,那么就取消本次更新。这样也可以保证数据的一致性。
方案核心思路很简单,不过实现起来有相当多的细节需要处理。最后实现的性能还是非常令人满意的,对24个worker,1000个service,100w integer值变化,只需要139.531ms就更新完毕了,读取性能还和原生table一致。
不过最后这个方案我们业务上也没采用。
RCU方案
后面又提了一个方案是RCU方案,意思是把table的更新搞成版本链的形式,每次有新更新就在链表头插入,然后读取时候从链表头一直往下找。
这个方案利用了sharetable对象不能有原表,将table结构体中的metatable指针复用一下,保证了table类型sizeof值不变。
不过这个方案需要加很多判断,比如判断这个table是不是ishared等等,总之最后测试结果来看,性能下降了3%,直接放弃了……
其他
在丢弃掉STW方案之后,去修补了旧更新方案的不足(指我们业务自己的sharetable更新方案),遇到一个非常非常神奇的竞态问题。
场景是:
lua的TValue有value和tag两个值来决定他的值和类型,然后更新的时候,会先更新value的值,然后再更新tag的值。
我们有一个地方的业务代码大概是:
-- 如果这个table的某个值不是nil,那么就先把这个值清空了再赋新值 if rawget(tb, old_key) ~= nil { set_x(tb, old_key, nil) }
这里出现了神奇的地方,因为这个table是sharetable,所以可能会出现更新的时候有其他的服务读它。在更新的途中可能会出现:
-- 1: { x, integer } 旧值x -- 2: { z, integer } nil TValue 中残留的无关 payload z,被旧 tag 当成整数 -- 3: { z, nil } nil 生效,z不再有语义 -- 4: { y, nil } 写入真正的新 payload y -- 5: { y, integer } 新值y正式生效
如果有人在第2步读取到了值z,并且z不是integer,对于C来说是ub;不幸的是,如果这个类型是string,那么很可能会因为读取野指针导致crash。不过触发概率是非常非常低的。
修复的方案是:
local old_val = rawget(tb, old_key) if old_val ~= nil { old_val = nil set_x(tb, old_key, old_val) }
这样就把上面的行为变成了:
-- 1: { x, integer } 旧值x -- 2: { x, integer } 幂等写入旧payload -- 3: { x, nil } 切换到nil -- 4: { y, nil } 写入新payload,此时不会被当成integer -- 5: { y, integer } 新值y正式生效
虽然不能保证更新途中读取到错误的值,但是很巧妙的避免了crash的问题。