一直都对分布式协议比较感兴趣,选择了 Gossip
和 Raft
作为起点,之所以这么选择有两个原因。
- 它们足够简单。
- 一个基于
AP
,一个基于CP
,分别是可用性优先和一致性优先的代表。
Gossip
Gossip
协议主要通过谣言传播的形式,传播给其他节点。
我这里称 Gossip
为 协议 而不是算法是因为这只是个思想,基于这个思想有很多的变种。
Gossip 能够正常运作需要以下三种实现组合。
- 广播
- 反熵(Anti-entropy)
- 谣言传播
近期在压测服务器的过程中发现内存随着用户数增加而暴涨,用户数减少内存却没有释放回内核,一开始怀疑是内存泄漏,后面上了工具排查,最终定位到是 glibc
的内存管理并没有将内存释放给OS,为了解决这个问题,对 ptmalloc2
进行了剖析。
本篇中,不谈论 brk
和 mmap
系统调用的使用方法,默认环境为 Linux-x86-64
,讨论的 ptmalloc2
的版本为 glibc 2.17
的版本。
ptmalloc2
分配给用户的内存都以 chunk 来表示,可以理解为 chunk 为分配释放内存的载体。
1 | #ifndef INTERNAL_SIZE_T |
chunk 由以上几部分组成, INTERNAL_SIZE_T
为 size_t
为了屏蔽平台之间的差异,这里只谈论64位平台,为8字节。
prev_size
代表着上一个 chunk 的大小,是否有效取决于 size
的属性位 P
。size
代表当前 chunk 的大小和属性,其中低3位为属性位 [A|M|P]
。fd, bk
将其加入链表中管理。fd_nextsize bk_nextsize
只用在 large bin
中,表示 上/下一个大小的指针,加快链表遍历。最近接手的一个游戏项目是重 Lua
的结构(网络模块在 C++,其余逻辑全在 Lua)。和许多用 Lua 的游戏项目一样,遇到了 Lua 的垃圾回收的性能问题,经常跑着跑着就会掉帧,因此花了一周的时间,给 Lua 虚拟机写了个模块,把 Lua 垃圾回收的速度提高了一个量级。
这个思路其实在之前的一篇博客中也有提到,想要 垃圾回收快,无非就那么几种思路。
第一种思路,我觉得不合理,因为现代的内存分配器早就有内存池的设计了,手写一个内存池的收益并不大。
第二种思路,是比较合理的。因为我在项目的代码中发现很多处地方有动态生成 Closure
的情况。
1 | function test() |
上面那个例子,每次调用到 test
函数的时候,都会动态根据 fn
的 函数原型,生成一个 Closure
可能有人会问,Proto 不是有一个 cache 指向 Closure
吗?按道理这里 没有 UpValue
(即代表UpValue 完全相同),应该会复用啊,但是很可惜,执行完这个函数以后,因为没有对象指向 Closure
用完再不久的将来又会被回收。
因此,少写这种代码就可以减少对象的生成。
第三种思路,我的想法是,让垃圾回收所要遍历的对象大幅减少,就可以为垃圾回收提速了,由于我们是重 Lua 的框架,因此我们的所有配置都存在于 Lua 的 table中,而这一部分肯定是不需要被回收的,但是每次垃圾回收的时候,又会不停的扫描递归遍历,不合理。同时代码中的很多全局函数,也是根本不需要被回收的,也会被扫描到,于是就想到一个想法,给这些对象打上标记,让他们不被遍历不被清理,就可以大幅度的提速了。
原理简单,但是做起来确实挺难受的,要注意要手动关闭 UpValue
将其保留下来。
目前已经开源,LuaJIT-5.3.6源码。
游戏服务端之所以用 Lua,大多数时候是因为 Lua 方便做热更新,一般来说对 Lua 做热更新基本上都会使用以下两句语句。
1 | package.loaded[name] = nil |
这种方式的热更好处就是简单,不过有的代码写起来就要特别小心,当你在代码中看到以下类似的片段,很有可能是为了热更新做的一种妥协。
1 | Activity.c2sFun = Activity.c2sFun or {}; |
同时,如果 Lua 代码中存有大量的 upvalue
时,还要记得保存原有的状态信息,否则会丢失原值,对于开发人员来说,这种热更方式费心费力。
因此, Lua HotFix
就是为了摆脱以上的限制,或者说减少需要关心的事情,让开发人员能够更为简单的做热更新。之所以要自己写这么一套东西,主要是因为网络上开源的热更方案不适合项目,要么支持的Lua版本过旧,要么就约束的过多,项目已经进行到了中后期,这个时候再来规范已经来不及了,其次有很多的错误,这点我会在本文中的第二部分进行讨论。
本文主要分为两个部分,第一部分为 HotFix 实现,第二部分为热更新的错误案例。
虽然本系列主要讲的是 Lua 5.3 中的实现,不过在本篇中,想先聊聊 Lua 垃圾回收的历史。只有了解其历史,才知道为什么这么设计。
在 Lua 5.0 之前,Lua 因为没有 userdata
,垃圾回收的工作就很简单了,因为没有 userdata
也就没有了 __gc
元方法,也就不需要针对有特殊析构操作的对象进行特殊处理。
Lua 从早期到现在 2020年
推出的 最新版 Lua 5.4 都是采用的标记扫描算法,垃圾回收算法一般分为两类。
引用计数的话,每个对象都要占用多一块内存,同时需要频繁的增减引用计数值,特别指的是在栈上的时候,Lua 解释器做的又非常简单,如果采用引用计数,还要对指令进行优化。
而早期 标记扫描 也是比较简单,首先它每次扫描且回收垃圾都是需要一次执行完的,其次它只有两种标记,用到或没用到,而且每次创建新对象都会跑一次GC。
显然,这种垃圾回收注定了没人敢用。。。我每创建一个对象,你都跑一次GC,这谁顶得住?
Lua的协程和 Golang的协程不同,它是在同一个主线程上跑的协程,个人感觉用途不是很大,毕竟没有发挥多核的优势,不过还是有不少人认为这是 Lua的一个亮点,可以用来实现异步代码改写为同步代码,减轻人脑负担,然而很多人用的时候,并不了解当 Lua协程调用到C函数而C函数又调用到Lua函数后又执行 yield
的解决方案。本篇主要是来探讨Lua协程的设计。
试想一下,如果你来设计一个在同一个主线程上跑,且没有调度的协程,你会怎么做?
可能你会说这还不简单,我们都已经知道了 CallInfo
这样的结构,只需要创建一个新的Lua栈,将新的 函数设置进其 CallInfo
,当执行到 resume
时,则将 Lua栈 推入,去执行新的指令不就行了?
如果Lua只在自己的世界里面玩,从来不调用 C函数,那就还好。但问题是Lua会与其宿主语言也就是C语言进行打交道,会调用C的函数,如果这个C函数又调用了Lua Function,而其又调用了 yield
,等到它又被 resume
的时候,它就没办法继续执行那尚未执行完成的C函数。
大致执行流程如下
1 | // 因为 lua 的 resume,其实是在C中导出的 |
一种可行的思路是,将Lua的协程与每一个系统线程绑定,消耗高(不过我觉得这样才能发挥出多线程的优势嘛)。
Lua采用的方案则是,通过保存C函数和其状态,并标记状态,当 resume
时根据已有信息,回到原来未执行完C函数的位置。
以下的 lua_pcallk
为使用例子,倒数第二个参数为上下文,倒数第一个参数则是该C函数如果被中断后,应该继续执行的事情。
1 | static int luaB_pcall (lua_State *L) { |
Closure
其实对于 C/C++
程序员可以简单理解为 函数。不过由于有了 Upvalues
的概念,会让人理解起来不那么容易,但是 Lua 中的所有函数 其实都是 闭包,包括我们第一篇 Lua 5.3 设计实现(一) Lua是怎么跑起来的?) 文章中提到的运行流程的第一个主函数,其实也是一个闭包。
本文中 函数与闭包的名字会混用,请根据其是否含有 Upvalue 进行区分。
闭包是由 函数原型(Proto)+ (UpValue)组合而成的。
而 Proto
其实就是拥有所有执行所需要的信息,因为这一块在第一篇已经讲过,故大幅度跳过。
1 | typedef struct Proto { |
我们更关注的是 Upvalues
。
上一篇主要是讲了 Table
和 MetaMethod
的一些设计实现,谈论到了 Lua 会对 元方法的字符串名字作缓存,同时提到了 Lua 字符串分为长短字符串。这一篇主要是谈论一下 Lua 的长短字符串是怎么设计的?为什么要分长短这两种类型?
可以看到字符串内部会记录哈希值,每个字符串被创建出来就不能被改写,因此为了节约内存,Lua会复用相同的字符串,但是逐字节比较太慢了,因此会预处理将字符串hash,存入字符串的 hash
字段中。
字符串的实际内容会追加到 TString
的后面。
1 | typedef struct TString { |
短字符串全局只有一份,Lua解释器会将其存到 stringtable
这个结构中。字符串 hash
会根据 global_State
的 seed
进行哈希。
上一篇主要讲了 Lua代码 的运作过程,这一篇主讲 Lua Table 和 基于 MetaTable
实现的 MetaMethod
。
其实我觉得,Lua之所以能大放异彩,其一是它非常精小,其二是其开源,其三则是因为它的MetaMethod
的设计。
虽然本篇主要讲 table,不过在那之前,最好先来认识一下 Lua 其他类型在 Lua解释器中的实现。
UserData 暂且不谈,NUMBER细分为浮点数和整数,字符串则分长短字符串,函数又分Lua函数和C函数还有轻量的C函数,这一部分会分别留到字符串和闭包的时候再谈论。
1 | #define LUA_TNIL 0 |
其实在此之前已经写了一个 Lua 5.3 源码剖析系列,还有好几篇存档没有发。为什么突然又不发了呢?(甚至还删了),是因为我感觉之前那样学习的方式过于难受,折磨心智(人这一生最不该做的就是折磨自己),没有抓清主次,同时和网络上的博文同质化严重。因此就决定,再读一次 Lua 的源码,这次读的是 Lua 5.3.6 是 Lua 5.3 系列的最后一个版本。
本系列,不会谈论 Lua 语法,也默认读者已经有 Lua使用经验,我们将绕过 Lua 的编译器(大部分都是词法语法分析),直接进入到 Lua解释器中,来学习我们写好的 Lua 源码是怎么跑起来的。为了理解的方便,代码会有大量删减,只抽取其核心。
虽然,我们在一开始就说好,不谈论 Lua 编译器,但是还是要先理解 Lua 的运行机制。这里简单提一下,你写好的 xxx.lua
文件 会经过 luac 工具将 Lua源代码编译成 二进制文件,Lua 作者在代码中称其为 Chunk,接着 Lua解释器会加载它并执行,所以 Lua执行起来,看起来是边执行边编译,但实际上是先编译成 Chunk,再加载 Chunk去执行。
假设我们现在有一段 lua代码,且已经过了 luac工具 编译出了 Chunk,那么 Lua解释器是怎么将其加载的呢?
我们可以大胆猜测,Lua会有个load函数,去load我们的 Chunk。
1 | LUA_API int lua_load (lua_State *L, lua_Reader reader, void *data, |