Lua 开发者通常听说或使用过 LuaJIT,但是可能因为种种原因未能理解其工作原理,在这里分享一篇 Jakob Erlandsson 和 Simon Kärrman 的硕士毕业论文,TigerShrimp: An Understandable Tracing JIT Compiler,该论文讲述了如何为 JVM 开发一个 Tracing JIT,并附带了源码以及可视化工具。下文将简要剖析一些其实现原理。
TigerShrimp 基于 JVM Bytecode,使用 Javac
将 Java 代码文件编译为 .class
文件,后直接进行 decode .class
文件,通过这种方式绕过 Parser
阶段,得到 bytecode
。
TigerShrimp 内部有个简单的 Interpreter
,用以直接执行 bytecode
,执行每一条 Instruction
时,会记录当前的 pc
(二元组,记录函数索引和指令索引,不然指令索引可能重复),是否为热路径,若为热路径,则会执行 record
流程,记录每一条执行的指令。(通常记录循环,循环有回边,记录执行次数,执行次数大于一阈值,则认为是热路径)。
若已经有 native code
,即已经是热路径并完成了生成机器码的工作,则直接执行 native code
。
常规操作,记录每一条 Instruction
,只有在分支语句时需要特殊处理。因为这里是 record
的过程中,是顺序执行的,所以一定不会有分支,相当于这些 Instruction
组成了一个 BasicBlock
,但是原始的指令是有分支的,需要将分支进行翻转处理。具体例子如下:
1 | 1: if (a < b): |
若 a > b
则会执行到 y()
即 pc = 4
的位置,若原样记录 a < b
这条指令,逻辑就错了,因此需要翻转指令为 a >= b
。
指令记录到 return
时或回到循环开始的位置,则该条热路径记录完成。
热路径记录完成后,需要进行编译为机器代码,TigerShrimp 选择了 asmjit
库来帮助生成机器代码。具体的字节码翻译过程此处略过,只分析函数进入的准备工作,以及分支判断失败时的处理(如何正确的回退到解释器)。
1 | initCode.push_back( |
ExitInformation
用于描述当前执行的堆栈信息,使用数组来模拟堆栈,以便在执行 native code
过程中,因为分支判断失败跳回 Interpreter
时恢复当前的堆栈信息,继续解释执行。
traces
用于存储所有跳出点的 native code
地址,用于实现 Trace Stitching,简单的说就是当分支判断失败后,不要直接回到解释器,而是先看看这个退出点是否存在另一条热路径,若有则直接转移控制权。
若分支判断失败,将会直接跳转到 bailoutcode
的位置,此时 RSI
寄存器已经存储了当前的 pc
值,便于之后恢复到寄存器执行。
1 | void Compiler::compileBailoutFor(Op label) { |
由于执行执行过程中不会使用到物理栈,都是通过 ExitInfomation->variables
数组来模拟,所以此时的 RAX
为 handleTraceExit
, RDI
为 ExitInformation
,跳入 _handleTraceExit
1 | bailoutCode.push_back({x86::LABEL, exitLabel}); |
1 | asm("_handleTraceExit:;" |
查找当前退出 pc
是否有一条热路径,若有则直接跳入继续执行,没有就将退出 pc
返回回去。
使用 JMP
尾调用,避免多次函数调用的性能损耗。
TigerShrimp 为了实现简单,选择直接从 bytecode
解释执行,跳过繁杂的 Parser
生成 AST
阶段,其次为了实现栈上替换(OSR),直接不使用物理栈,使用数组模拟,方便回退到解释器,易于理解。
定时器的实现通常使用有序数据结构来实现,一般通过红黑树、跳表、最小堆、时间轮来实现。
其中又以最小堆最容易实现,红黑树最难实现。
Skynet 选择时间轮的原因估计是多线程,时间轮的插入平均复杂度比其他几个都要低,非常适用于多线程场景。
本篇就简单剖析一下 Skynet 实现的 TimingWheel。以下代码为方便阅读有删减。
首先实现上是采用数组 + 链表的形式进行实现。
先定义了一个链表,存放了过期时间,从 *tail
可以看出,此结构为尾插法,毕竟后插入的定时器后执行,很合理。
1 | struct timer_node { |
时间轮数据结构中含有一把自旋锁,时间轮在框架中会被多线程访问,又由于插入的时候冲突的粒度比较小,所以用自旋锁而不是互斥锁。
1 |
|
从中可以看出,Skynet 的时间轮有5个层级,其中会执行的那层 为 near
数组,其他的4层均不会被执行到。
之所以要分为5层,是为了节约内存,不然你完全可以定义一个巨大的数组,每个槽位表示每秒要执行的任务。
其中第一层大小为 1 << 12
即 4096
,2-5层为 1<<5
即 32
。
可以看出定时器最大值为 12 + (4 * 5) = 32 位。
每当遍历完整个 near
数组后,则从下面几层中取出一个槽位,将其填充到 near
数组继续模拟计时。
大致了解数据结构之后,再来看初始化逻辑。
1 | void |
均为简单的初始化链表。
1 | static struct timer * |
其中 gettime
使用了 clock_gettime
而且还是单调时间,避免系统时间被修改。 clock_gettime
的时间精度为纳秒,此函数进行换算后,最后精度为毫秒,而且还是10毫秒,此时我们可以猜测时间轮的精度为10ms。
1 | static uint64_t |
定时器更新由定时器线程去执行,每隔 100 微秒(也就是 0.1毫秒) 触发一次定时器更新,之所以外面调用是 0.1 毫秒,而时间轮精度为10 毫秒,是为了留足时间给定时器回调函数执行,否则某些函数执行时间过长,可能会导致定时器越来越晚触发。
1 | static void * |
skynet_updatetime
还考虑到时间倒流的问题,虽然我认为是不会触发,因为 clock_gettime
取的是 CLOCK_MONOTONIC
的时间,即系统启动后至今的时间,不会倒流。
之所以此处 要判断 (cp != TI->current_point)
是因为 update
的间隔为 0.1 毫秒,而时间轮精度为10 毫秒,可能 update
执行的时候 还没到定时的最小精度,最终触发 timer_update
。
1 | int |
先加自旋锁,然后进行执行 timeout 为 0 的回调,否则后面进行 转移 2-4 层的时间轮,将 near
层时间轮覆盖后,就再也执行不到了。
1 | static int |
执行逻辑很简单,用当前 time(为tick) % 4096 找到需要执行的槽位的链表,在代码中为了提升性能用了位运算 & 实现。这里还能注意到小细节,执行回调函数链表时不需要加锁。
1 | static inline int |
该函数由于用了大量位运算,所以看起来会比较难看,可以先从 while 的条件开始看,先是用当前 tick 也就是 ct % 4096,如果为 0,则说明 near
层的槽位已经全部走完 此时走完了 4096 * 10 毫秒,大约是40.96秒。接下来就是要找到正确的层次,然后从层次中找到正确的槽位,将其填充到 near
数组,即 ct / 4096 % 32,若为 0,则说明不在当前层次,还能再 除以一个 32。总之将位运算代码 & 翻译为 取余,>> 翻译为 除法,此函数的逻辑便不言自明。
同时 T→time 会一直递增,最后溢出回到0,uint32 的最大值为 4294967295
溢出为 0,则说明正确的值为 4294967296
那么 用该值 / 4096 / 32 / 32 / 32 / 32 发现 为 1,这就说明我们此时需要将 第四层的第0个槽位挪到 near
数组。 非常简单。
1 | static void |
timer_add
告诉了我们定时器传进来的参数 time
并不是要计时的值,而是多少个 tick
,比如 time
传入 10 时,则以为这 10个 tick
也就是 10 * 10 毫秒后到时。 expire
表示的就是多少个 tick
后到时。
1 | static void |
又是一大坨位运算,就是判断 expire
应该插入到哪一层,如果将其改写,则需要大量的 if 判断。
1 | static void |
Lua 项目中,通常需要工具进行内存监控,目前开源的工具中有 lua-snapshot,但这个工具的缺陷是开销比较大,在调用接口之后,会扫描整个GC链表,找出所有的GC对象,并进行统计,最后会创建大量的 Lua 对象,将结果存在里面,这就会导致本身内存已经够高了,再用这个工具的话,很可能会触发 OOM 或者是 STW,业务无法正常提供服务。
作为补充,期望有个工具能够监控所有的对象开辟的位置和大小信息,进行精确定位代码问题。
最终实现的效果如下图所示:
Lua 支持替换 frealloc
,这就使得我们监控内存分配成为了可能,接下来就是如何减轻性能损耗的同时将信息记录下来。我们需要的信息有 文件名 和 行号。
Lua 中所有的内存分配都是基于 realloc
,可以简单的在分配时,遍历 CallInfo
调用栈,获取最顶层的 Lua 函数的文件名和行号,将其记录下来即可。
由于对象释放时,是找不到正确的 Lua 调用栈的(就算找到了,也是取到触发垃圾回收那个时刻的文件名和行号),因此需要再分配时,就给这个内存对象记录一下,为了快速方便的取得该内存地址的开辟位置,在内存对象上增加一个 Cookie
。
1 | struct mem_cookie { |
内存扩缩容,大部分情况下都是 table 下的 array 或者 hash 部分进行扩缩容,若直接在扩缩容处获取调用栈信息,会导致获取的文件名和行号对不上该 table 创建的位置,在 global_State
记录 table pointer,通过读 Cookie,避免遍历调用栈以及更精确。
我们采用 proto_id作为文件名,这主要是出于以下考虑:
因此通过给 proto 一个 ID,进行编号,映射一张 proto_id -> source 的表,即可。具体可以改动 loadfile
的实现完成。
Lua 5.4 中行号是相对行号,内存分配又是个高频操作,将 Lua 5.3 的绝对行号移植过来,直接查表省去行号计算。
]]>CPython
的自动垃圾回收机制,并尝试提出改进的思路。相信有过计算机基础的人,哪怕对垃圾回收不那么熟悉,也肯定知道引用计数这个玩意。引用计数诞生于上个世纪,其主要思想是通过给每个对象增加计数,当计数为0时,则肯定没人使用该对象,可以放心将其删除。
虽然这个方法看起来有点糙,但在实际项目中,它的优点在于可以更实时的释放内存,释放内存的时机更精确,这也是为什么有的项目会尝试给 Lua
增添一个引用计数的垃圾回收,避免内存上涨过快。
凡事都有利弊,它的缺点也很明显,无法处理循环引用。
以下用 Python
举一个非常普遍的例子。
1 | class A: |
在上面中,我们手动删除了 a
和 b
,理应进行释放,但由于 a
和 b
互相构成了循环引用,导致其引用计数总是不为0,进而造成内存泄漏,而 CPython
对其解决方法也极其简单,就是将所有可能造成循环引用的对象,构成一个双向链表进行扫描,从 root object
出发进行扫描 - 清除,无法到达的对象就是可释放的对象,普通的对象直接采用引用计数去释放,简单快捷。
怎么去验证以上结论呢?我们可以用反证法,当 del a
和 del b
后,再调用 gc.collect()
查看其是否能被回收到,如果能回收到,说明在此时引用计数已经失效。
1 | # 设置 debug 标签,使得垃圾回收后的对象 存放至 gc.garbage 列表中 |
可以看出引用计数确实失效了,因为通过 扫描-清除
回收能回收到这两个对象。
1 | [<__main__.A object at 0x10adefc10>, <__main__.B object at 0x10adeff70>, {'b': <__main__.B object at 0x10adeff70>}, {'a': <__main__.A object at 0x10adefc10>}] |
接下来,我们来到 CPython
源码中查看如何用引用计数管理一个对象。我们将以整数为例,先看看整数对象的对象模型。
1 | // 对象的基类,拥有双向链表和引用计数 |
可以看出每个对象都有一条双向链表,但是这里需要说明的是,此处的双向链表并非后面扫描 - 标记所使用的双向链表,此处的双向链表会将所有的对象都链接到 refchain
中,目前从代码中只能看出是拿来做调试用途的。
1 | void |
以上关于 CPython
中的引用计数部分,就讲解完了,整体非常简单。接下来就是看容器类对象(会造成循环引用的对象)如何进行垃圾回收了。
垃圾回收领域一直有几大门派,最为突出的门派分别为 扫描-清除
和 标记-整理
,先讲什么是 标记-整理
。
假设我们将语言的内存池分为两块,其中一块不用,另一块一直拿来创建对象,当垃圾回收开启时,我们将所有可达对象(即可用对象)进行标记,然后将标记的对象重新在另一块内存池中进行创建,最后直接将原本那块内存池进行释放,这就将垃圾整理完成。
标记-整理
这种垃圾回收办法依赖于一个假设,那就是垃圾对象比正常的对象要多得多,这样整理起来由于是整个内存池一起销毁的,所以会快得多。
CPython
选择的是 扫描-清除
,我们就不在其他地方进行展开了,着重来介绍 扫描-清除
。
假设我们从 root object
出发,如果可以扫描到的对象,即成为可达对象,可达对象则代表正在被使用不可清理。最终我们将得到一个不可达对象的列表,将其清理即可。
而 扫描-清除
由于扫描和清除是一次性完成的,会导致 Stop The World
时间特别长,因此产生了所谓的分代垃圾回收,这也就是 CPython
目前所使用的垃圾回收。
分代垃圾回收基于一个假设,大部分对象存活的时间比较短,少部分对象存活的时间比较长,那么就可以优先对新生代进行垃圾回收,而对老年代的垃圾回收次数放缓,这就解决了 扫描-清除
的时间过长的问题。
接下来我们就来简单看看 分代垃圾回收的实现。我们以一个容器对象作为例子,就拿 list
好了。
以下为 list
的对象模型,由于本篇主题为垃圾回收,所以不关注其他成员。
1 | typedef struct { |
结构也是非常简单,同样有引用计数与双向链表(在 PyVarObject
结构中),那么就会有疑惑了,这里的双向链表用于链接所有对象到 refchain
,那么我们的分代垃圾回收的扫描链表去哪了?
1 | /* GC information is stored BEFORE the object structure. */ |
可以看出,在创建这类需要扫描的对象时,会提前算好头部还需要加多少内存,在头部再加一个 PyGC_Head
作为分代回收的链表,然后调用 _PyObject_GC_Link
触发垃圾回收,可以看出当创建一个对象达到该代的阈值时,将会触发垃圾回收,最后才调用 _PyObject_GC_TRACK
将其链入 第0代 GC链表
中。
1 | // Lowest bit of _gc_next is used for flags only in GC. |
从宏中可以看出,CPython
用了地址的最后两位去做一些事情,之所以可以这么做是因为内部实现了个小的内存分配器,里面的地址按4字节对齐,这意味着后两位一定为0,这也是一个常用技巧了,没什么好说的。
现在让我们关注最重要的 垃圾回收过程。
1 | static Py_ssize_t |
从最老一代开始进行收集,目前 CPython
默认有3代,分别为 0,1,2代。为了避免多次进行 full gc
,这里设置了个条件,当清理最老一代的时候,必须要 非最老一代存活的对象(long_lived_pending) / 当前最老一代存活的对象(long_lived_total) 超过 25%
才进行全量回收,其实这主要是因为 扫描-清理
过程是一次完成的,所以要尽量避免 full gc
。
接着就正式进入垃圾回收主函数。
1 | static Py_ssize_t |
在阅读之前,还要补充一个知识点,分代垃圾回收里面的三代回收是有阈值的,其中只有第0代也就是最年轻的一代的阈值指的是对象个数,剩下两代都是执行年轻代的次数。默认值为 (700, 10, 10)
这意味着想触发第0代垃圾回收需要创建出700个对象,而想触发第1代垃圾回收,需要第0代垃圾回收执行过10次,想要触发第2代垃圾回收则需要第1代垃圾回收执行过10次(同时还要满足上面的一个条件,这里就不重复了)。
1 | static Py_ssize_t |
整体来看,除了代码量略大,其他的还是很简单的,接下来我们将解决上面几个遗留问题。
root object
?untrack_tuples
是个啥?untrack_dicts
为什么只在 full gc
时调用?先解释第二点,为了加快垃圾回收的迭代,当 tuple
容器没有内嵌容器时,会将其从垃圾回收跟踪中删除,只使用最基础的引用计数。证明这一点很简单。
1 | a = (1, 2) |
可以看出,对 tuple
取消追踪,是个惰性过程。接下来我们引申到 dict
。
1 | a = {"a": 1} |
可以得出,当 dict
没有复杂的对象时,则不会对其追踪,那么我们是否可以将同样的思路引用于 list
呢?
接下来我们回到问题1,如何找到 root object
?如果读者对 Lua
了解的话就知道,Lua
的对象都可以从 registry
这个全局表中追踪到,但在 Python
的世界中却是不可行的,之所以会产生这样的问题,主要还是因为 Python
扩展模块(extension modules) 工作方式导致用于无法确定根集,这就使得复杂度一下就上来了。
CPython
的解决方法也很简单,结合引用计数和扫描清除两种办法去解决。拷贝一份引用计数(如果在原本的引用计数上操作太危险了,不小心变成0,就触发了引用计数回收了),然后在其基础上进行遍历,每次将引用计数 -1,这样就得到了相对引用计数,相对引用计数为0,则有可能是不可达对象,先猜想它是,后续再遍历可达对象,如果从可达对象可以找到相对引用计数为0的对象,那么它就是可达对象,需要将其恢复。
这块虽然有点绕,但仔细品味一下还是非常简单的。
接下来我们来讨论第三个问题,为什么 untrack_dicts
只在 第三代垃圾回收时触发?
这主要是因为 dict
插入一个对象时,会判断这个对象是不是容器,是容器就会将其追踪,但是每次都会在 untrack_dicts
去遍历检查是否可以取消追踪,这就很蠢了,有兴趣的可以阅读 Issue #14775。
其实还有些内容想讲,随便来个话题,在 Python 2
时代,当两个对象循环引用又同时有 __del__
时,垃圾回收会不回收这两个对象这类问题,但我不想在这里继续展开了,太累了,有兴趣可以阅读 PEP-442 进行学习。
CPython
的GC是 Stop The World
的,哪怕它已经很尽力用分代的方式去减少GC的损耗。是否可以将其改进为渐进的方式?我目前的想法是在容器操作时,进行 Barrier
操作,维护一个中间态,使得前面的扫描过程是可渐进的,最后处理垃圾的时候再停下来一次性处理完,减少停止的时间。但这个思路貌似不行,原因是根集是不确定的。
是否可以对其它常用容器也做 untrack
操作,当容器没有嵌套容器时,取消 track
操作,减少GC遍历损耗?这个思路需要小心避免犯上面 untrack_dicts
的错误。
甚至我们扩展 gcmodule
的接口,使得对一些常驻内存的对象进行标记,使其不要被跟踪?
这点就不说啥了,Lua
里也最好别用 __gc
。
最后的最后,感谢 CPython
这份非常漂亮的代码设计,让我在这个五一假期,受益良多,下一步可能会回到 Lua 5.2
中,阅读它 “失败” 的分代GC作品,我认为学习失败的经验比成功的经验要重要得多。
Python3
源码剖析中,剖析 float
的实现主要是阅读的 Python 3.10
的源码,但是在我看到 PEP-659 这篇关于指令特化(Specializing Adaptive Interpreter)的提案时,我就被它吸引了,因为这就是我之前想给 Lua
提速加的功能之一,冲着对它的热情,我决定将阅读的 CPython
版本提升到 3.11
,这一篇就来剖析一下指令特化的实现,我们将通过两个对象做加法进行分析。首先通过 Python
自带的 dis
工具进行分析,分析两个对象相加的流程。
1 | from dis import * |
可以看到两个对象相乘的指令码为 BINARY_OP
,我们跟踪到 CPython
中,可以确定会调用到 PyNumber_Add
函数中。
1 | static const binaryfunc binary_ops[] = { |
PyNumber_Add
实现也很简单,先看看这两个对象支不支持该二元运算符,不支持,则看看支不支持 concat
操作。
1 | PyObject * |
binary_op1
则是分别对左右两个对象进行判定,查看是否支持相加的操作。
1 | static PyObject * |
可以看出一个小小的二元运算,需要经历以下几个过程。
concat
。如果有一个办法可以提前知道这两个对象的类型,提前确定它们的二元运算是什么就好了,这样就可以绕过一系列的条件判断语句,直达核心,省去大量的预测分支,从而提高性能。
经过前面的背景铺垫,我们可以先试想一下,如何去做指令特化?
首先要明确地是什么时候做指令特化?如果每个函数执行的时候都做一次指令特化,那么很可能会消耗更多的时间,这点和 JIT
的思路一致,只有对调用频率高的函数做优化才有意义。
其次要明确指令特化失败了怎么办?因为 Python
是脚本语言,很可能下次传进来的对象不再是原来的那个类型了,这个时候就可能会发生指令特化失效的情况,但是如果每次都在指令特化后的执行流程中检查对象的类型,那又回到了老路子,性能可能提升不了,解决这个问题的思路是,在指令后面缓存一些数据,减少条件判断的个数。
接下来我们将开始实战指令特化,首先根据前面分析,我们需要记录每个对象的执行次数,还记得前面的字节码吗?RESUME
就是拿来做这个事情的。
在编译生成字节码阶段,每当进入一个新的作用域时,就会创建一个 RESUME
的指令,这是新版本中特有的。
1 | static int |
可以看出,co_warmup
会在每次进入该作用域时自增,当其为 0 时,进行 quicken
操作。其默认值目前为 -8
。
1 |
|
那么 quicken
操作是什么呢?其实就是将原本的指令替换为 自适应
指令,自适应指令也会有个变量记录进入该指令的次数,当达到一定次数时,才考虑将其进行特化。之所以不在一开始就生成 自适应的二元操作指令,主要是避免一些性能损耗吧,毕竟有一些函数调用次数少。
1 | uint8_t _PyOpcode_Adaptive[256] = { |
在此处 BINARY_OP
的自适应指令则为 BINARY_OP_ADAPTIVE
,同时细心的读者可以发现,在 quicken
过程中,还会将 RESUME
替换为 RESUME_QUICK
这主要是因为,既然都已经决定特化了这个函数了,我再每次都去算进入这个函数多少次,意义不大,想办法将其特化掉,省去一部分性能损耗。
BINARY_OP_ADAPTIVE
在这条指令后面藏了一个缓存,存储了当前指令还差多少次进行特化(我猜测是因为与0对比的时候,运算的比较快),当 counter
为0时,进行特化。
目前默认的 counter
为 53
,作者说:大了优化的少,小了整天优化,只有50附近比较靠谱,但是又不想选50
,就选了个53
质数。
当回退的时候,指令特化失败时,会被修改为 64
。
1 | TARGET(BINARY_OP_ADAPTIVE) { |
_Py_Specialize_BinaryOp
的过程也非常简单,就是检查对象类型,还有操作类型,进行决策即可。
1 | void |
关键是如果一开始指令特化成功,后面传入的对象不再是原来的对象了,那应该怎么回退呢?带着这个问题,我们来到特化后的指令 BINARY_OP_ADD_FLOAT
。
可以看到,在这里就只是简单检查一下两边对象类型,然后快速的用浮点相加完成了两对象相加,这就是性能提速的原因。
DEOPT_IF
就是用来判断是否特化失效的宏,特化失败走向 miss
。
1 |
|
当指令特化失效后,就会找回该特化指令原始的指令进行执行,还会尝试去再次特化该指令。
1 | miss: |
整个指令的变化可以参考下图。
至此我们的分析结束,指令特化真好玩,下次(一定)我就将它实现到 Lua
上。
2021
年的时候,我的工作主要集中在改进 Lua虚拟机
,后来由于工作变动,现在主要的工作语言已经切换为了 Python
,因此打算阅读下 Python 3.10
的源码,学习一下它的设计,对比 Lua
的优势。希望在接下来的阅读过程中,能够体会到一种 回家
的畅快感。
本篇将以 float
作为起点,了解如何创建出一个浮点对象,深入剖析 float
其内部实现。
一切皆对象 这句话都要被讲烂了,但是还要讲多一次。
Python
是一门面向对象的强类型动态语言,里面的任何东西都是对象,以浮点数为例。
1 | # a 是一个浮点实例对象,类型是 float |
以上我们可以确定,Python
中类型也是对象。
此外所有对象的类型都是 type
,可以称其为元类。而所有对象都继承自 object
。
1 | type(int) |
而 object
的类型也是 type
,type
的类型也为 type
。
1 | type(object) |
至此我们可以得出以下几个结论,方便后续继续阅读 float 的实现。
object
type
1 | type.__base__ |
type
的父类也是 object
type
的类型也是 type
object
的类型也是 type
object
的父类为 None
两者互为表里,相辅相成。
理解了以上的内容,就能开始正式阅读源码了。CPython
为了表示一种继承的关系,但苦于 C语言
没有这种机制,不得不手动模拟,抽出 PyObject
作为父类。
PyObject
的结构相当简单,和 Lua
一样,需要自动垃圾回收,给每个对象头部都加了 double-link
,当创建对象的时候就将所有对象串起来,主要用于扫描与分代垃圾回收。
1 |
|
PyObject
是所有对象的起点,后续任何一个对象都继承自它。它包含双向链表和引用计数(ob_refcnt),通过这两个结构运用了多种垃圾回收机制。
ob_type
则是类型指针,指向该对象真正的类型,表示该对象的一些行为,用于实现多态。
PyVarObject
则是 PyObject
的增强版,用于支持 变长对象。
1 | typedef struct { |
之所以需要 变长对象 是因为有的类型是一个容器,需要存储动态变更大小,例如 List
。既然 PyVarObject
是变长对象,那么 PyObject
就可以看作是定长对象。
前面我们知道,在 Python
的世界中,类型也是对象,实例是由类型对象生成出来的。 PyTypeObject
就是所谓的类型实例对象, PyType_Type
则是类型的类型对象,它用于表示该类型的一些行为,生成出来的实例也会遵循它的规则进行,一定要先搞清楚这两者的关系,才好去理解 Python
。
具体的 PyTypeObject
结构在此处先不展开,留到后续阅读各个内建对象时,再解释说明。
1 |
|
在 Python
虚拟机启动后,内建类型对象就可以拿来实例化对象了,这说明内建类型对象是在启动时就准备好了。
而 PyType_Type
就是提前准备好的类型对象。
1 | // 垃圾回收链表, 之所以都为空, 是因为这些提前准备好的对象不是动态生成的, 不需要垃圾回收 |
我们可以看出,type
的类型还是 type
。其次有好多地方都是空的,这是因为有的参数是等到用到的时候再添加,由 PyType_Ready
函数完成,内置对象都会在 _PyTypes_Init
时就已经初始化好。
现在,我们已经知道 所有的对象都是先由 type
这一元类生成,那么对象是怎么被生成的?
对象生成主要有两种方式,一种是调用类型对象,也就是使用类型对象的 __call__
,另一种则是在语法分析时,就可确定该对象的类型,直接调用内部的CAPI(对应指令为 LOAD_CONST
)。
1 | # 1 |
这两种的区别主要在于性能上,在语法分析阶段直接能确定类型的,会比调用类型对象生成的要快的多。
float(1.5)
⇒ float.__class__.__call__(float, 1.5)
⇒ type.__call__(float, 1.5)
⇒ type_call(float, 1.5)
而在 type_call
中还会去检查是否可以转换为 float
对象,自然就慢了。
f = 1.5
⇒ PyFloat_FromDouble(1.5)
一步到位,没有更多的类型判断。
怎么证明以上的结论呢?有个很简单的方法。
1 | print(float.__call__) |
可以看出 类型对象的 __call__
实际上就是 type
的 __call__
。同时我们还可以知道,结构体中的 slot 的函数指针,在 Python 的世界中也是对象! PyWrapperDescrObject
对函数指针进行包装还加了一些描述。
有了以上的前置知识,接下来就是要关注一个对象的创建流程了,从 type_call
函数开始阅读,因为 type
的 __call__
调用的是 type_call
。
1 | static PyObject * |
这么看就简单多了,通过调用类型对象进行实例化,会先执行 __new__
,若返回的类型正确则继续调用 __init__
。
如果说 PyTypeObject
是万物的元类,那么 PyBaseObject
就是万物的父类。而父也是由造物主 type
创造出来的,它们两是一体,不可分割(因为 object 的类型 也是 type)。
整体上看非常普通,没什么特别的,主要是定义了一些最基础的方法,给子类用,比如比较之类的。
1 | PyDoc_STRVAR(object_doc, |
现在不去关注这里面的内容,等到对其他的对象足够了解后,再回到 type
和 object
中剖析。这样做的好处是,自上而下阅读,不容易产生疑惑。
终于到了本文的重点,PyFloatObject
是一个浮点数实例对象,我们就以它为起点,去窥探其中的设计。之所以选择它,是因为它是所有对象里面最简单的了。
1 | // 可以看出是个定长对象,里面就只有一个 double |
PyFloat_Type
看命名就知道是浮点数的类型对象了。
里面的行为都比较简单,要注意的是没有 __init__
,因为浮点对象比较简单,可以在 __new__
的时候就填充好。
1 | PyTypeObject PyFloat_Type = { |
为了接下来阅读方便,我将 floatobject.h
的一部分宏作了注释贴上来。
1 | // 浮点数缓存池大小 |
虚拟机在启动后,会进行浮点数的一些初始化,主要包含以下两个操作
ieee-754
的大端还是小端编码。1 | void |
float info
数据。1 | // floatinfo 浮点数一些信息 |
这样就可以通过 sys.float_info
来查看当前环境的浮点数参数。
1 | import sys |
浮点数创建主要在 float_new_impl
中。
1 | static PyObject * |
判断类型是否为 float_type
,不是则看看是否为 float
的子类,否则就尝试将字符串转为浮点数。
1 | static PyObject * |
重点关注 PyFloat_FromDouble
,可以看出,float 有个对象缓存链表,各个对象采用 ob_type
进行串联。
1 | // 通过C浮点数获取python 浮点对象, 注意虚拟机中有浮点缓存器。 |
除了 float_new
还有一个创建 浮点数的新方法 float_vectorcall
,内部也是调用的 float_new_impl
,用于提高性能,但是浮点数里面没有启用!因为它的 flag 没有 Py_TPFLAGS_HAVE_VECTORCALL
,可能只是暂时预留一个位置,还没有开发到,所以就先跳过吧
1 | // 析构 确保一定是 PyFloat_Type 类型 |
如何验证浮点数是不是真的用到了缓存池?有个很简单的方法验证。
1 | 1.3 a = |
a 与 b 的 id 一致 说明复用了浮点数对象。
浮点数的大部分操作都比较简单,唯独比较操作是一个非常麻烦的操作。
作者也曾提到,浮点数比较是一个噩梦,之所以这么麻烦,主要是当浮点数和整数比较时,将浮点数转换为整数去比较会丢失精度,用整数转换为浮点数也不可行,因为一个整数的有效位高达63位,而双精度浮点数的有效位为53位,无法直接进行比较。
大致步骤如下:
1 | static PyObject* |
看完这一段我就有疑惑了,我记得 Lua
实现浮点数比较非常简单啊。翻阅 Lua 5.3.6
源码进行查阅得知,Lua
直接将两个浮点数转换为整数进行比较,这样会有精度丢失的问题(将浮点直接向下取整取到整数)。
1 | int luaV_equalobj (lua_State *L, const TValue *t1, const TValue *t2) { |
copysign 是 ieee-754
中关于浮点数定义的一个辅助函数,用于确定一个浮点数的符号,在 Python
中为了支持符号0,实现了这个方法。
这个函数使用方法是 将 y 的符号赋给 x 并返回。
实现方式也挺巧妙的,利用 atan2(0, -1.)
会得到一个 -PI 的结果,如果机器支持-0,则为-PI,若不支持则为 +PI,以此来确定机器是否支持符号0。
1 | double |
本篇剖析了 Python3.10
的 float
对象的内部结构与实现,对比 Lua
可知其优势。
copysign
实现。Lua 多线程的垃圾回收
方案,另一个则是 LuaJIT 5.3.6
实现。其实也没用到三个月,实现代码加上测试一共花了一个月,至于剩下的两个月,主要是响应号召,去打了一下疫苗,腹泻,发烧,休息😓。这一块的思路主要是从 Redis
通过子线程释放内存这块学来的,通过这个小优化,使得我们游戏服务器在大量玩家下线时,不再出现大规模的掉帧,效果还是非常显著的。
之所以实现这个 Lua 5.3.6 JIT
,其实是因为 LuaJIT 2.0
不支持 5.3的新扩展,而项目已经进行到了中后期,没有时间去调整代码了,最后花了半个月的时间去实现了一个小版本,通过了 Lua 的官方测试用例,也在项目用上了。性能方面提高了2-5倍,接入成本为0,不需要修改任何逻辑代码。
至于解释器部分,借鉴了 Lua 5.4
的一个小优化点,将 switch case
修改为了 computed goto
,提升了约 5%
的性能,之后可能会学习 Lua 5.4
扩展字节码。如果这个项目还做下去的话(我有时间的话),我会想尝试解释器执行脚本时记录各个操作数的类型,实现动态替换字节码,减少不必要的类型判断,从而提升一定的解释器速度。不过这个方案风险太大,暂时先搁置。
在实现 LuaJIT 5.3.6
的过程中,顺带复习了一下编译原理的前端部分,实现了一些官方不支持的语法,比如 ‘+=’,自增表达式(当然没有提交),还是非常有趣的。
以上的代码实现已经开源,合并之前的 NOGC
优化思路,LuaJIT-5.3.6,欢迎 Star
,这对我很重要。
Redis 6
剖析的第二篇,主要探讨 Redis
是怎么做主从同步的,对代码会有所删减。通常启用主从同步,只要在从服务器执行 SLAVEOF HOST PORT
即可,这个时候就会执行到 replicaofCommand
。由于主从同步是从服务器发起的,因此我们先从 Slave
开始进行剖析。
Redis
的主从同步,是通过状态机驱动的,因此有必要在本篇一开始前,就先看看有哪些状态。
1 | typedef enum { |
REPL_STATE_NONE
,未启动同步。REPL_STATE_CONNECT
,需要连接到 Master
。REPL_STATE_RECEIVE_PING_REPLY
,等待 PING
的回包。REPL_STATE_SEND_HANDSHAKE
,验证密码。REPL_STATE_RECEIVE_AUTH_REPLY
,等待 AUTH
的回包。REPL_STATE_RECEIVE_PORT_REPLY
,等待 REPLCONF
针对端口的回包。REPL_STATE_RECEIVE_IP_REPLY
,等待 REPLCONF
针对IP的回包。REPL_STATE_RECEIVE_CAPA_REPLY
,等待 REPLCONF
针对”能力”(即支持的功能)的回包。REPL_STATE_SEND_PSYNC
,发送 PSYNC
。REPL_STATE_RECEIVE_PSYNC_REPLY
,等待 PSYNC
的回包。REPL_STATE_TRANSFER
,传送快照。REPL_STATE_CONNECTED
,主从同步完成。拿到 Master
的 IP
和 Port
。
1 | void replicaofCommand(client *c) { |
断连所有的 Slave
,然后取消掉原先的主从连接(如果有),设置 Cache Master
为了复用 PSYNC
(保存当前进度,不进行全量同步)。
REPL_STATE_CONNECT
,表示需要连接 Master
。1 | void replicationSetMaster(char *ip, int port) { |
Redis 6
支持 TLS
,为了简化剖析过程,此处默认不采用 TLS 连接
。
server.repl_transfer_lastio
,最后一次 IO 时间,用于超时处理。REPL_STATE_CONNECTING
,表示已连接到 Master
。1 | int connectWithMaster(void) { |
Slave → Master
连接完成后,会进入到 syncWithMaster
回调。这个函数共有 300多行
,因此分为多个部分讲解。
REPL_STATE_NONE
,直接返回。这种情况主要是出现在 Slave
连接上 Master
之后,Client 后悔了。
1 | void syncWithMaster(connection *conn) { |
REPL_STATE_CONNECTING
,设置 Read Handler
为当前函数。
发送命令 PING
到 Master
。
设置状态 REPL_STATE_RECEIVE_PING_REPLY
,表示等待 Master
返回 PONG
。
主要是因为 Connect Handler
只会执行一次,后面的状态机的处理流程都在本函数,因此需要再次进入该函数。
1 | /* Send a PING to check the master is able to reply without errors. */ |
同步读 Master
对 PING
的回包,正常情况只要有回包都是没错误的,除非对方是旧版本。
设置状态 REPL_STATE_SEND_HANDSHAKE
,表示需要进行握手。
1 | /* Receive the PONG command. */ |
握手阶段主要是进行密码验证,将 Slave
的 IP
和 PORT
传给 Master
方便查询,同时告诉 Master
我当前的能力,比如 EOF
为我支持 无盘传输
, psync2
表示支持部分同步。
设置状态 REPL_STATE_RECEIVE_AUTH_REPLY
,表示等待认证回包。
1 | if (server.repl_state == REPL_STATE_SEND_HANDSHAKE) { |
检测认证情况。
设置状态 REPL_STATE_RECEIVE_PORT_REPLY
,表示等待 Master
确认端口配置是否正常。
1 | if (server.repl_state == REPL_STATE_RECEIVE_AUTH_REPLY && !server.masterauth) |
检测端口配置情况。
设置状态 REPL_STATE_RECEIVE_CAPA_REPLY
,表示 Master
确认能力回包。
1 | if (server.repl_state == REPL_STATE_RECEIVE_IP_REPLY && !server.slave_announce_ip) |
检测能力设置是否正常。
设置状态 REPL_STATE_SEND_PSYNC
,表示 开始进行同步。
1 | /* Receive CAPA reply. */ |
slaveTryPartialResynchronization(conn, 0)
表示给 Master
发送 PSYNC ? -1
? 为 Master RunID
, -1
为进度。
设置状态 REPL_STATE_RECEIVE_PSYNC_REPLY
,表示等待 Master
对 PSYNC
回包。
1 | /* Try a partial resynchonization. If we don't have a cached master |
slaveTryPartialResynchronization(conn,1)
表示同步读 Master
针对 PSYNC
的回包,看是要全量同步,还是要增量同步。不支持 PSYNC
则进行全量同步。1 | psync_result = slaveTryPartialResynchronization(conn,1); |
slaveTryPartialResynchronization
中设置状态 REPL_STATE_CONNECTED
,表示已连接成功,直接返回。1 | /* If the master is in an transient error, we should try to PSYNC |
backLog
,毕竟要重头开始了,通过 SYNC
进行同步。1 | /* PSYNC failed or is not supported: we want our slaves to resync with us |
RDB
文件传输,则先创建临时文件。1 | /* Prepare a suitable temp file for bulk transfer */ |
Read Handler
,读文件,同时 设置状态 REPL_STATE_TRANSFER
,表示文件传送中。1 | /* Setup the non blocking download of the bulk file. */ |
slaveTryPartialResynchronization
主要是和 Master
通信获取是否可以增量同步的信息。
前半部分,则是通过发送命令 PSYNC
来进行对接, cached_master
是之前意外断开的 Master
节点信息。
1 |
|
后半部分则是读到 Master
的回包,并确认其是 全量同步 +FULLRESYNC
还是 增量同步 +CONTINUE
。
其中 RUN_ID
为一个40字符的随机值,每次启动实例随机生成, offset
相当于一个偏移量,用于之后同步完 RDB
后进行增量同步。
replid2
的出现主要是因为若从服务器被提拔为主服务器,其他的从服务器连到现在新的主服务器时,若直接校验 replid
则必然失败,因此出现了这个变量来保存上次同步的主服务器ID。
1 | /* Reading half */ |
readSyncBulkPayload
主要负责读取 Master
的 RDB
文件(也可以是无盘传输)。
server.repl_transfer_size == 1
判断),则先检查协议,同时查看是通过文件传输还是无盘传输,如果是文件,则可以提前获取文件大小,否则通过 EOF
标记代表无盘传输,以 eofmark
作为结尾的标记。1 |
|
若是无盘传输,通过 eofmark
与 lastbytes
对比得到是否传输完成。
Redis
源码将 无盘加载和有盘加载的代码进行拆分,为了方便剖析,此处进行合并。
1 | if (!use_diskless_load) { |
删除 socket
的 Read Handler
,因为后续的加载操作通过 RIO
去加载,一边读取 TCP流
,一边进行加载。
1 | if (use_diskless_load && |
replicationCron
在 Master
和 Slave
都会走到, Master
给 Slave
发心跳,而 Slave
给 Master
发当前的进度,用于展示时使用。
1 | void replicationCron(void) { |
Master
在收到 PSYNC
或者 SYNC
后,会调用 syncCommand
。
PSYNC
则会调用 masterTryPartialResynchronization
来判断是否可以增量同步(从 repl_backlog
缓冲区中查找),否则全量同步。SYNC
则 设置 Client→flags
为 CLIENT_PRE_PSYNC
,表示 Slave
不会发送 ACK
,不能因为其不发就认为其宕机。1 | void syncCommand(client *c) { |
BGSAVE
命令再执行,则尝试复用 生成出来的 RDB
,将其他 Slave
的输出缓冲区拷给当前 Slave
来达到同步的目的。1 | c->replstate = SLAVE_STATE_WAIT_BGSAVE_START; |
Socket
发给 Slave
,因此我们在这个时候应该等待。1 | /* CASE 2: BGSAVE is in progress, with socket target. */ |
无盘同步
还是 RDB同步
都会走到 startBgsaveForReplication
这个函数。1 | /* CASE 3: There is no BGSAVE is progress. */ |
决定无盘同步还是RDB同步, rdbSaveToSlavesSocket
和 rdbSaveBackground
名字已经很清晰了。
1 | int startBgsaveForReplication(int mincapa) { |
特别注意的是,无盘传输也是采用子进程的形式完成,但是绝不是通过子进程进行发送,而是子进程序列化好后通过匿名管道发给父进程,父进程再读取将其发往 Slave
。
创建 匿名管道
,通过 RIO
将内存序列化后写入 管道
中,父进程通过管道取出发到 Slave
。
1 | /* Spawn an RDB child that writes the RDB to the sockets of the slaves |
父进程注册管道的可读事件,从 rdbPipeReadHandler
读取。
1 | } else { |
至此主从同步就已剖析完了,之后的命令传送则通过 propagate
函数进行传递。
主从数据不一致
读到过期数据
外键
这玩意离开了学校就再也没见过。好在在 游戏领域
中,用的最多的都是 NoSQL
。熟悉我风格的人,可以看出这个系列的标题,不再是 源码剖析
,而是只有 剖析
两字,主要是考虑到 Redis 6.0
的代码量已经挺大了,同时网络中又有大量关于 Redis
数据结构的源码剖析,没必要再炒冷饭了。
出于以上的原因,我将 Redis
分为几个部分进行剖析和讨论。
本篇主要是来剖析 Redis
为了避免 阻塞
,是如何运用 多进程
与 多线程
,这两种异步机制的。
Redis
一般有以下几种阻塞的点。
从网络交互来看有
BIO
)从磁盘交互又分
BIO
)BIO
)Redis
在早期的版本中 采用的是 单线程 + I/O 多路复用
的模型,而在最新的 6.0
,采用了 Thread I/O
,默认不会开启,开启需要在配置中加入以下两行。
1 | io-threads-do-reads true // 开启多线程读和解析执行 |
Redis
在初始化的时候,会调用 initThreadedIO
。
根据配置,创建 server.io_threads_num
个子线程,如果只是 一个,则选择直接返回,将 网络I/O的处理放到主线程(相当于使用单线程I/O)。
通过为每个线程创建一个 mutex
来达到 临时开启暂停子线程的功能,之所以需要这样,主要是 子线程都是一个死循环,采用 自旋锁
的形式去获取任务链表,如果一直没有任务,CPU占用也会达到 100%
。
1 | /* Initialize the data structures needed for threaded I/O. */ |
通过 atomic
实现自旋锁的形式,去获取任务列表,再根据写任务或读任务去执行。其中在一开始的时候通过 lock(mutex)
的形式,给主线程暂停子线程的机会。
1 |
|
beforeSleep
会先遍历所有待读的客户端,采用 Round-Robin
将其分配到各个线程。I/O线程
操作,自旋等到操作完成,再回到主线程执行命令,并加入到 clients_pending_write
。I/O线程
操作,自旋等待完成。Write Handler
到 epoll
,之后未完成的写任务交给主线程去写。读操作,先检查 I/O 线程
是否关闭,从 clients_pending_read
中取出并进行分配到子线程, 访问 io_threads_list
不需要加锁, io_threads_list[i]
只会有主线程和 i子线程访问,而主线程与子线程之间又通过一个原子变量进行同步,之间通过自旋的形式解决了数据竞争的问题,在等待任务完成的同时,主线程也承担一部分的读操作。最后加入到 clients_pending_write
链表。
1 | int handleClientsWithPendingReadsUsingThreads(void) { |
写操作,检查一下 I/O线程
是否开启,当任务量少的时候,会通过 lock(mutex)
临时阻塞子线程,因为子线程是一个死循环,就算没有任务也会占满 CPU
。如果没有写完,则会设置写回调,注册到 epoll
中,下次由主线程去写。
1 | int stopThreadedIOIfNeeded(void) { |
可以看出, Redis
的多线程模型并不是那么优雅,主线程完全没必要去等待所有线程的读或写操作,同时 I/O线程
又很暴力,直接一个死循环,吃光CPU,实现起来不够好,不过这也确实解决了单线程下 Redis
因为 read
, write
系统调用导致的性能开销(用户缓冲区和内核缓冲区拷贝所带来的)。
在网络中,见到不少人批判 Redis
使用自旋锁是一种开倒车的行为,但我不这么认为,使用 mutex
或者 spinlock
要根据实际情况来,当锁的粒度非常小的时候, spinlock
能够省去不必要的上下文切换的开销。
BIO
是 Redis
的后台线程,主要接收以下三种任务,每个任务都会开一个单独的线程。
1 | /* Background job opcodes */ |
初始化三个后台线程的互斥量和条件变量。
1 | static pthread_t bio_threads[BIO_NUM_OPS]; |
设置线程名字,阻塞 SIGALRM
信号,然后不断获取任务,根据任务类型进行操作。
1 | struct bio_job { |
关闭文件描述符,有可能会删除掉文件,引起阻塞。因为 Redis
实现的时候会通过 rename
覆盖掉原有文件,将文件描述符的关闭交给 bio
子线程避免阻塞。
客户端操作,无非就是对数据结构进行增删改查,大部分的操作都是 O(1)
,需要注意的是对集合的查询和聚合操作,同时删除一个 BigKey
也会带来性能开销,即使 Redis
用的 jemalloc
已经性能够好了。因此 Redis
选择开子线程的方式,去另一个线程释放内存。
这里有几个条件必须满足。
这样做也就不需要加锁了。(Lua 好适合这种情况)
1 | void freeObjAsync(robj *key, robj *obj) { |
因此删除东西最好用 unlink
,当其为 BigKey 时,就会放入 bio
进行释放。同理 flushdb
也可以异步清除。
每当执行一条命令后,若开启了 AOF日志
则将其记录到 AOF 缓冲区
(写后日志)。
1 | propagate(c->cmd,c->db->id,c->argv,c->argc,propagate_flags); |
AOF日志若开启,则调用 feedAppendOnlyFile
将其写入到 server.aof_buf
中。
1 | void propagate(struct redisCommand *cmd, int dbid, robj **argv, int argc, |
先检查目前所用的 db, Redis
默认有 REDIS_DEFAULT_DBNUM
16个db。后将有相对时间过期的指令转换为绝对时间。如果有 AOF
子进程在重写日志,则还会将其写入server.aof_rewrite_buf_blocks
链表中,同时通过管道传输到子进程。就算子进程宕机了,主进程的 AOF日志
也还是完整的。
1 | void feedAppendOnlyFile(struct redisCommand *cmd, int dictid, robj **argv, int argc) { |
AOF日志同步到硬盘的策略有三种,第一种不同步,由内核自己决定Flush时机,另一种每次都同步,但是 fsync
是会阻塞的,因此还有第三种每秒同步,通过 BIO
子线程,每秒去同步 fsync
一次,其实说是 fsync
也不准确,在 Linux
下用的是 fdatasync
省去了写文件的元数据开销。
1 | void bioCreateFsyncJob(int fd) { |
前面提到的 AOF追加日志 是利用了子线程去执行 fsync
,而这里则是用子进程去重写 AOF日志。重写日志主要是根据数据库现状重新创建一份新的 AOF日志,如果在主线程上操作,会导致很长时间不能处理客户端的请求。
AOF日志重写要么是由客户端发起 BGREWRITEAOF
,要么是 serverCron
周期性判断是否触发了 AOF重写
。
当前没有其他子进程做事情,比如说 RDB快照,AOF重写,或者 loaded module。
同时默认要求大于 64*1024*1024
并且对比上一次重写后的文件大小是否增长了 100%
。
1 | /* Trigger an AOF rewrite if needed. */ |
fork
一个子进程,同时父进程在有子进程的时候, dict
不扩容,这主要是因为 fork
采用的 copy on write
,尽量不去改动进程的内存,避免物理页复制引起内存暴涨,同时一定不要开启 huge page
,原因同上。
最后子进程将数据库信息重写,并从父进程的管道中获取新的数据。
1 | int rewriteAppendOnlyFileBackground(void) { |
子进程完成之后,父进程会在 checkChildrenDone
接受它的返回值。
rename
AOF日志文件名,将原文件的文件描述符交给 bio
进行 close
避免阻塞。
可以从 ModuleForkDoneHandler
推论 Module
也预留了 fork
接口去多进程完成一些模块的自定义任务。
1 | void checkChildrenDone(void) { |
当使用 bgsaveCommand
命令时,类似 AOF重写
,也是通过 fork
子进程去完成,避免加锁或是减少内存拷贝。当然其也支持自动触发。
1 | /* If there is not a background saving/rewrite in progress check if |
多个检查点,查看是否触发存盘。
1 | int rdbSaveBackground(char *filename, rdbSaveInfo *rsi) { |
至于 RDB快照
传送,也是采用子进程生成,父进程发送,若采用无盘传输,则子进程直接序列化后通过管道发给父进程,父进程再发给从服务器,下一篇会比较详细讨论,这里就不细说了。
Redis
除了命令执行是单线程,其他的网络和耗时操作尽可能都转化为 多进程或多线程,简化了开发,这一点在游戏服务器上是非常值得借鉴的。
此外, Redis
通过子线程释放内存,这一点我认为可以将其引用到 Lua
的垃圾回收中,缩短 stop the world
的时间,找个时间,写个多线程垃圾回收的版本,看看其效果。LuaJIT-5.3.6(更新时间 2021年07月04日,已实现 Lua 多线程垃圾回收版本)
Gossip
协议,这篇主要看看 Raft
是如何实现的。本文主要分为两个部分,首先是粗略讲解一遍 Raft
的设计思想,在这一部分不会将 RPC
的各种字段(因为没有意义,只会徒增心智负担),而在第二部分则是通过解析一份优质的 Raft
源码实现,在这个部分再深入到 RPC
各个字段。
如果看了一遍看不懂也没关系,建议多去看看 Raft
的论文,笔者也是反复看了两周才大致理解其指导思想。
一提到共识算法,相信大部分人都能马上想到 Paxos
,但是我认为它不是算法,它的论文里面顶多算是一个指导思想,很少有人能够读完它就实现出一个可靠的共识算法(关键是要验证其的正确性),但是 Raft
不一样,它的一些设计非常巧妙,能够令人非常好的理解其指导思想,同时比较容易的实现(因为 Raft
从诞生那一刻就是为了弥补 Paxos
的可理解性,看看人家的论文名字 In Search of an Understandable Consensus Algorithm
可理解的分布式共识算法)。
用过 Zookeeper
的可能知道其内部的协议就是根据 Paxos
的指导实现的一个 Zab
算法,之所以不用 Raft
是因为 Raft
那时候还没出世呢。
Raft 中的节点只有三种类型。
领导人主要是负责一切的写入操作,当领导人收到客户端的日志条目(请求)时,将其先记录下来(你可以理解为拿个小本本记下我收到了这个请求,但是不提交),然后广播复制(通过心跳)到其他的服务器上,当收到大多数服务器成功的响应后,就将其提交(Commit)到自身的状态机(这个时候才是真正的应用于kv存储),最后通过心跳广播到所有服务器,告诉他们你们也可以应用。
如果领导人宕机了,这个时候就需要有候选人竞选领导,谁先收获到足够多的选票,谁就胜出。
当领导人还在的时候,整个分布式只会有领导人和跟随者,他们之间通过心跳维持,当领导者宕机了,跟随者就会跳出来说我来当候选人,于是就切换到候选人的身份了。
Raft
和 Paxos
最大的异同点我认为是引入了 强领导 的机制,因为这会使得整个分布式系统变得简单,多领导的机制简直就是灾难,你很难保证整个系统指令的顺序。
初始阶段,所有的节点都应该是跟随者,因为这个时候没有领导者与其维持心跳,因此会有一个跟随者发生心跳超时的情况,谁先超时,谁就变身成候选人,之所以有个先字,主要是因为 Raft
设计心跳超时的时候,采用了一种随机超时的机制,这个机制我个人觉得是非常巧妙地,它大幅度的减少了整个系统的复杂度,不再需要优先级各种系统的设计,直接通过随机的形式,也避免了瓜分选票导致长时间不能服务的问题。
有了候选人之后,先给自己来一票,然后发起 RequestVote
,当选票足够的时候就进化为领导人,如果一直没选出来则进入选举超时,重来一轮,如果收到领导者的消息,则对比 Term
任期,比候选人大则乖乖退回跟随者,小则无视。
决定投不投它一票的流程也很简单,采用 FIFO
先来先服务的形式,大前提是候选人的信息要比我的新。
关于这块如果不能理解,建议看 thesecretlivesofdata 这里的动画演示。
首先要认识日志,日志由三部分组成,日志于哪个任期产生,日志的索引,日志的内容。
领导人收到客户端的请求之后,将请求组装成日志,然后先存储下来(不是应用,只是记录一下),接着通过广播发给其他节点,当大多数节点成功响应,则应用到自身的kv存储(或者说应用于自身的状态机),这个时候就可以返回了,同时心跳广播也会将最新的提交记录传递给所有节点,其他节点也会将其应用于自身,这里面的提前返回相当于是将二阶段提交给优化为了一阶段(因为它只要大多数节点回应就行了),降低了一半的消息延迟。
如果是跟随者收到客户端的写请求则有多种方法,比如拒绝并返回领导者的地址给客户端,或转发给领导者,将领导者的返回结果返回给客户端,充当代理身份。
为什么我只提到了写请求呢?因为读请求也是需要视情况而定的,我们知道 Raft
是一个共识算法,很多人一直以为它实现出来的就一定是强一致性,然而它是不是强一致性取决于你客户端怎么实现。比如说你想要强一致性,则强制读的时候一定在领导者上读,同时要经过半数节点确认,这样一定不会返回旧数据。如果无所谓强一致性,则可以设计成任意节点上读,这样很有可能是旧数据。还有一种模式是虽然在领导者身上读,但是不经过大多数节点的确认就直接返回,这样有可能会有旧数据(比如 新的 风暴(领导者) 已经出现,但是因为网络的关系,没能通过心跳广播通知到其退位,它觉得它还是个领导者就擅自返回了数据,殊不知这个数据很有可能被新的领导者已经修改了)。
以上的三种读操作的一致性模型其实就是 Consul
所实现的。
这么一看, Raft
的缺点很明显,因为强领导者导致写性能很弱,相当于单机,这也是为什么在分布式存储领域中,大多采用分片的形式去使用(相当于多个 Raft
组),而不是采用大分布式的形式。
日志复制的安全性
来自于几个方面。
首先领导者
不能删除和覆盖日志,只能够新增,如果跟随者和领导者不一致则强制让跟随者的日志与领导者同步。这么做之所以是安全的是因为,领导者的日志一定是最新最全的。
如何保证领导者的日志一定是最新的呢?前面也提到了 日志由 Term
任期, index
日志索引,日志内容所构成,每次复制都会去检查前一个日志的任期和索引是否相同,如果相同,我们则可以断定前面的日志也一定是相同的。
其次如果领导者复制给了跟随者日志,但是随后就宕机了,这个时候没有应用于状态机,怎么办?这个时候就依赖于 Term
任期字段,新的领导者首先通过上面的机制保证了它的日志一定是最全的,同时它的任期一定是更高的,于是就可以将其任期之前的未提交的直接提交了,然后同步给其他节点。再加上 Raft
整个系统实现是幂等性的,即使因为超时或者种种原因重新执行指令也不会发生任何副作用。
那么可能有的人就会想,日志一直在增加,我总不能一直存着所有的日志来和其他跟随者进行比对吧?论文里面的 Snapshot
就是做这块功能,将日志进行快照压缩,其实和 Redis
aof重写挺像的,然后将快照同步出去即可。
关于日志复制,如果有疑惑的可以参阅 Raft Visualization 一个非常详细的动画演示。
Raft
通过单节点变更,避免了集群变化时出现的脑裂情况,每次只添加单个节点不会形成另一个大多数,从而避免多个领导者。除了单节点变更还可以用 联合共识
(其实就是个二阶段的规则,集群之间互相试探),但是难实现啊。
有了以上的前置知识,我们就可以通过阅读知名的 hashicorp/raft 实现来更深入的理解 Raft
。
RaftState
是 Raft
当前所处的状态,如上所说有三种状态。
1 | type RaftState uint32 |
raftState
则代表 Raft
节点信息。
1 | type raftState struct { |
附加日志 RPC 请求,这里可以对照着论文看了。
1 | type AppendEntriesRequest struct { |
附加日志 RPC 响应。
1 | type AppendEntriesResponse struct { |
投票 RPC 请求。
1 | type RequestVoteRequest struct { |
投票 RPC 响应。
1 | type RequestVoteResponse struct { |
安装快照 RPC 请求。
快照主要是当 日志项太多的时候,将其合并成一个快照复制。
1 | type InstallSnapshotRequest struct { |
安装快照 RPC 响应。
1 | type InstallSnapshotResponse struct { |
这里就是创建一个 Raft
节点的方法,其实就是验证一下配置,初始化日志,从db中拿出旧的数据(如果有),默认是一个 Follower
的状态,就开着三个协程去跑了。
1 | func NewRaft(conf *Config, fsm FSM, logs LogStore, stable StableStore, snaps SnapshotStore, trans Transport) (*Raft, error) { |
以下围绕着三个协程去讨论。
协程 run
则根据节点状态跑相应的函数。
1 | func (r *Raft) run() { |
跟随者下接收RPC请求,这里有一个 bootstrapCh
,用于启动时接收集群信息。
除了接收附加日志,投票,安装快照请求,其他请求都不支持(代码已省略)。
心跳超时之后会变为候选者,即 Candidate
。
1 | func (r *Raft) runFollower() { |
候选人默认先给自己来上一票,然后就到处要票,视情况决定是退回到跟随者,还是当上领导者。
除了日志和投票的请求,其他都是直接返回错误,选举超时则退回到跟随者,等待新一轮选举。
1 | func (r *Raft) runCandidate() { |
领导者主要是初始化多个拷贝协程,然后新建一个 noop
的日志项(就是不应用到状态机的日志),非常重要,相当于领导者一当选就马上告诉其他跟随者你们给我把之前任期未提交的日志给我提交了(隐式提交)。
noop
日志相当于一条分界线,只有其他节点同步到了这个日志,才正式提供服务,避免客户端从其他节点读到未 Commit
的数据(过时数据)。
1 | func (r *Raft) runLeader() { |
剩余的 RPC
请求处理,就不继续解析了,无非就是根据当前身上的信息和心跳发来的信息进行比对。
MultiRaft
, 因为 Raft
是强领导者类型的,性能相当于单点。Leader
这有助于避免在对称网络分区错误(三节点,两机房,两节点在同一个机房)的时候把一个明明有 Leader
的集群转换为选举状态。Leader
到当前的通信时间是否超过重新选举的时间可避免这一问题。Raft
把 超时
玩出了花,通过引入超时机制(心跳超时选举领导,选举超时重新选举领导)把整个系统的复杂性降低,同时通过心跳来附加日志和提交日志,不需要等待完全确认,将 二阶段的提交过程优化为了一阶段。 Leader
上位后通过 noop
日志巧妙的避免了即日志不一致,旧读的问题。关于成员变更,则是采用单节点变更的形式,避免了 脑裂
,不得不说 Raft
真的是把可理解这一特性发挥到了极致。
Gossip
和 Raft
作为起点,之所以这么选择有两个原因。AP
,一个基于 CP
,分别是可用性优先和一致性优先的代表。Gossip
协议主要通过谣言传播的形式,传播给其他节点。
我这里称 Gossip
为 协议 而不是算法是因为这只是个思想,基于这个思想有很多的变种。
Gossip 能够正常运作需要以下三种实现组合。
反熵
其实就是通过推拉
的形式,将两个节点的数据进行交换,进而达成一致。之所以有了广播还要有反熵去推拉,是因为有可能缓存区满了,丢了数据,或者是一个新节点刚刚上线,它肯定就没办法得到之前广播出来的消息啦,那就需要反熵进行修复。
其实谣言传播和广播大多数时候都是做到一块的,换句话说 谣言传播是随机从节点里选K个进行广播。
主要分析 memberlist 的实现,其依赖于 Gossip
的变种,SWIM
: Scalable Weakly-consistent Infection-style Process Group Membership Protocol
每个节点有有以下四种状态,存活,怀疑,死亡,离开(相当于死亡的一种补充)。
1 | const ( |
根据配置创建节点
1 | func Create(conf *Config) (*Memberlist, error) { |
填充结构体,建立 TCP 与 UDP 连接。
1 | func newMemberlist(conf *Config) (*Memberlist, error) { |
节点状态同步,Push-Pull,用户数据同步。
读出数据,根据消息类型进行操作,反熵体现在 pushPullMsg 这个类型中。
1 | func (m *Memberlist) handleConn(conn net.Conn) { |
各种消息处理。
将数据转为命令进行处理,用户自定义数据分优先级。
1 | func (m *Memberlist) ingestPacket(buf []byte, from net.Addr, timestamp time.Time) { |
开三个协程
1 | func (m *Memberlist) schedule() { |
随机选取一个节点,然后通过UDP发送 ping 消息,如果不通则通过 indirect-ping 消息完成,意思是发给其他随机几个节点,由他们替你去 ping。
如果配置打开 TCP 开关,也会通过 TCP 去 ping(如果 TCP 判断存活,UDP间接判断不存活,还是认为存活)。
1 | func (m *Memberlist) probeNode(node *nodeState) { |
随机选 1 个节点,通过 UDP 进行推拉,反熵修复值。
1 | func (m *Memberlist) pushPull() { |
根据配置随机找几个节点,通过 UDP 进行谣言传播,即从广播队列(TCP 同步节点状态的时候会将消息放入广播队列)中取出来进行广播。
1 | func (m *Memberlist) gossip() { |
如果配置打开 TCP 开关,也会通过 TCP 去 ping(如果 TCP 判断存活,UDP间接判断不存活,还是认为存活)。
Gossip
是一个 AP
的分布式协议,总体来说还是比较简单的。
glibc
的内存管理并没有将内存释放给OS,为了解决这个问题,对 ptmalloc2
进行了剖析。本篇中,不谈论 brk
和 mmap
系统调用的使用方法,默认环境为 Linux-x86-64
,讨论的 ptmalloc2
的版本为 glibc 2.17
的版本。
ptmalloc2
分配给用户的内存都以 chunk 来表示,可以理解为 chunk 为分配释放内存的载体。
1 |
|
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
中,表示 上/下一个大小的指针,加快链表遍历。从上可以得出以下结论:
prev_size
无意义,可以被前一个 chunk 所利用。size
的低3位为属性位,说明 size
一定是 8 的倍数,A
为是否为非主分配区,1是0否,M
为是否从 mmap
中获取, P
为前一个 chunk 是否被使用。fd bk,fd_nextsize bk_nextsize
都无意义,因此返回给用户的可用内存应为 size
之后。1 | /* size field is or'ed with PREV_INUSE when previous adjacent chunk in use */ |
mem
为用户真正可用的内存起始地址,可以看出 最小的 chunk 应该至少 4*8 = 32字节
,因为 fd_nextsize 和 bk_nextsize
只有在 large chunk 才用的上。
request2size
将用户申请的内存大小转化为 需要分配的 chunk 大小,用户请求大小 (req + prev_size + size) = req + 16B
,但是由于内存复用的关系,可以从下一个 chunk 中借用 prev_size
的空间(反正对于下一个 chunk 来说,前一个 chunk 已经被使用了,知道前一个 chunk 的大小也没有意义),因此应为 req + prev_size + size - prev_size(next chunk) = req + 8B
,同时 req + 8B
不应小于 MINSIZE
所以二者取最大,为 max(req + 8B, 32B)
。
bin
可以理解桶,存放着 chunk
,在 ptmalloc
的世界中存在四种 bin。
fast bins
是小内存块的缓存,当小内存块被回收时,会先放入 fast bins
,当下次分配小内存时,就会优先从 fast bins
中找,节约时间。
unsorted bin
只有一个,回收的 chunk
若大于 fast bins
的阈值即 global_max_fast
,则放入 unsorted bin
。
small bins
顾名思义,就是 ptmalloc
觉得小的 chunk,就放进去,呈等差数列的形式递增,每个 bin 的 chunk 均为同一大小,通过 fd, bk
链接 chunk 链表。
large bins
同上,不过每个 bin 中的 chunk 有大小排序,大的在前,小的在后,通过 fd_nextsize, bk_nextsize
快速找到上/下 一个大小节点。
1 |
bins
共有 small bins
有 62 个, large bins
有 63个, unsorted bin
为 1个,总共为 62+63+1 = 126 个
,其中 bin[0] 和 bin[127] 不用,因此 bins 总数为 128 个。要注意 fast bins
并不放入同一数组。
fast bins
小内存块的缓存,大小小于 DEFAULT_MXFAST
的 chunk 分配与回收都会在 fast bins
中先查找,在64位上为 128字节
,这个参数可以通过 mallopt
函数进行修改,最大值为 160B
。一共有 9
个,bin[0] 和 bin[1]
没有用上,剩余 7 个 为 small bins 的小 7 个。
1 |
|
FASTBIN_CONSOLIDATION_THRESHOLD
表示当回收的 chunk
与相邻的 chunk
合并后大于该值 64k
,则合并 fast bins
中所有的 chunk
放回到 unsorted bin
unsorted bin
只有一个, fast bins
合并后的 chunk
会先放到这里,从名字可以看出这里面的 chunk
没有排序。如果从这里面分配不到合适的 chunk
就会将其放到正确的 small bins
或者 large bins
中。
1 |
small bins
在64位平台上,共有62个bin,最小的 chunk
为 32字节
,等差数列的公差为 16B
(SMALLBIN_WIDTH),最大为 1008B
。
1 |
将数值带进 smallbin_index
会发现最小的 chunk
是在 bin[2]
上,这是因为为了编程的方便, small bins
从2开始,可以形成 chunk size = 2 * size_t * index
的等差数列,bin[1]
则用来存 unsorted bin
而 bin[0]
为空。
每个 bin
中的 chunk
大小相同,通过双向链表链接起来。
large bins
则接在 small bins
之后,MIN_LARGE_SIZE
可以看到最小的 large chunk
为 1024B
。共有63个。
1 |
large bins
中的每个 bin
里的 chunk
大小为一个区间,从大到小排序,通过双向链表链接,同时为了加快遍历的过程,通过 fd_nextsize, bk_nextsize
将前后不同大小的对象链接起来。
1 | typedef struct malloc_chunk* mbinptr; |
一些辅助宏,可能会好奇为什么有那么多个 对 malloc_chunk*
的 typedef struct
,其实就是 ptmalloc
把内存从不同的角度看待的意思,类似 C++ 的 union
。
malloc_par
可以理解为一个全局的参数。
1 | struct malloc_par { |
其中较为重要的参数有:
以上任一项的修改,都会关闭 动态调整分配阈值,之所以有这个机制,是为了减少 mmap
的次数,因为 mmap
的效率远远低于 brk
。更多细节建议阅读 mallopt(3) — Linux manual page。
但是使用 mmap
分配的内存有一个好处,当释放的时候可以直接还回给内核,而且当虚拟内存空间有洞时,只能用 mmap
进行分配,在本次服务器压测的过程中,通过修改以下配置达到释放内存的目的,但是强烈不建议使用, mmap
分配的内存以页为单位,哪怕你申请 1B
,都会变成向内核申请一块页大小的内存块,仅适合用于排查内存不释放究竟位于 ptmalloc2
的哪个地方。
1 |
|
前面提到过,申请出来的 chunk
可能来自三个地方。
malloc_state
就是用来管理分配区的。非主分配区的出现主要是为了缓解多线程的场景下,减少锁争用的情况,一般情况是一个线程对应一个非主分配区,尽管是这样还是会进行加锁,因此性能不佳,分配区达到CPU核心数时,则会停止创建非主分配区,转而进行复用,复用也很简单,轮询判断是否可以加锁。
1 |
|
brk
进行分配,而非主分配区都是采用 mmap
。但也有一种情况,主分配区用 mmap
,静态链接 glibc
的时候,就会禁用 brk
,我想是担心出现洞。malloc_stats(3)
进行查看。本节先通过文字描述一遍内存分配流程,再进行代码分析。malloc glibc 内部名字为 __libc_malloc
_int_malloc
在分配区中分配内存,如果分配失败,则解锁分配区并换一个分配区,如果分配区的数量少于CPU核心数,则默认是新建一个非主分配区,并调用 mmap
分配一块大内存并设置好 top chunk。_int_malloc
逻辑。128B
,是则在 fast bins 中查找并返回,否则下一步。1008B
则在 small bins 进行分配,优先用 last_remainder
,从尾节点先分配,头结点还回,使每一个 chunk
都有机会被用上,成功则返回,否则下一步。unsorted bin
只有一个 chunk,且 该 chunk 为 last remainder chunk,且我们需要的是一个 small bin chunk,则将其切分,剩余部分依然不动,此步骤最多尝试 MAX_ITERS(10000)
次,防止因为 unsored bin 的 chunk 过多而影响分配效率。large bins
中按照最佳匹配的原则,从更大的 bins 中进行查找,查找方式是通过遍历 binmap
,找一个合适的 chunk
,并将其切分,成功则返回,否则下一步。top chunk
进行切分了(回收的时候也是从 top chunk
进行切分,埋下了长周期的内存无法回收导致内存暴涨的伏笔),不成功下一步。fast bins
的注意了,主要是 fast bins
回收的时候没有加锁,而是采用 lock-free
方式(Compareand-Swap)回收,因此有可能里面已经有 chunk 了,这时候又开始合并,放入 unsorted bin,但是却是从 small bins 或从 large bins 中再去查找,这主要是因为,在第 5,6 步的时候,如果在 small bins 中找不到合适的 chunk,就合并 fast bins 到 unsorted bin,然后放回到指定的 small bins 和 large bins 中,但是并没有再去扫描一下相应的 bins,这里相当于再补上一刀。sysmalloc
向内核申请内存了,先看看是否超过 mmap 分配的阈值,若没超过,主分配区采用 brk
扩充 top chunk 大小(若静态链接 brk
会被禁用,此时采用 mmap
),非主分配区则默认用 mmap
进行扩充,超过就更不用讲了,直接 mmap
分配给用户,释放也是直接释放即可。1 |
|
可以看出 分配区是绑定在线程的,但并不代表每个线程独占一个分配区,因此都要加锁,导致性能无论在单线程还是多线程上都不佳。同时 分配区的数量取决于 CPU核心数,若获取不到则默认为 8。
1 | void* |
_int_malloc
可以说是 ptmalloc2
中最重要的函数之一,它可以说是 ptmalloc2
内存分配策略的实现。
1 | static void* |
以上为内存分配的第 4 步 fast bins
,这里采用了 CAS 操作,换句话说 回收 fast bins
不需要加锁。
1 | if (in_smallbin_range(nb)) { |
内存分配第五步 small bins
至此结束。
1 | else { |
第六步至此结束,到这要么是 small bins
不满足 或者 本身请求就是一个 大请求,因此先整合 fast bins
的 chunk,将其放入 unsorted bin
中,一边又从 unsorted bin
中查找,顺便放入正确的 bins 中,如果碰巧就找到了 那就返回就完事了,同时还会设置 binmap
,方便之后搜索。
1 | /* |
第七步主要是从更大的 bins
中进行查找,然后进行切分,如果切分后剩余的内存太小则一起送给用户,还有很多的话,则将其插入到 unsorted bin
,分配的是小内存则还会将其剩余部分保存到 last_remainder
供下次优先分配。
1 | use_top: |
第八步,从 top chunk
中进行切分,回收也是从 top chunk
从高往低释放回给内核,因此如果后分配的没有释放,会导致先分配的已释放都没办法还回给内核。
1 | /* When we are using atomic ops to free fast chunks we can get |
第九步,fast bins
,因为 fast bins
的回收是不需要锁的,有可能回收了。
1 | /* |
第十步,一滴也没有了,通过 sysmalloc
从内核申请内存。
主分配区用 brk
申请一块内存进行内存分配,若是静态链接 glibc
则只能用 mmap
防止有洞。非主分配区则只能用 mmap
。还会先看看所需内存是否大于 mmap
的阈值,大过就直接采用 mmap
返回。但是 mmap
的效率不高,在内核中属于串行运作,因此 ptmalloc2
会动态调整这个阈值(默认为 128KB
,最大可达 32MB
)换句话说你要想百分百用 mmap
申请内存,那请你申请大于 32MB
的内存。
1 | static void* sysmalloc(INTERNAL_SIZE_T nb, mstate av) |
依然是文字先总结一遍流程。
mmap
分配的 chunk,则用 munmap
将其释放,如果释放的 chunk
大小大于 mmap
分配的阈值,且未关闭动态调整阈值开关,则调整一下 mmap
的阈值为当前 chunk
大小。_int_free
释放内存。chunk_size
< 128B
,且 chunk 不与 top chunk
相邻则放入 fast bins
中,这里不会加锁,而是用的 CAS
,返回。top chunk
,则将其合并到 top chunk
中,若不是也合并,将其放到 unosrted bin
。chunk
大于 64KB
,则开始整合 fast bins
到 unsorted bin
,若 top chunk
的大小 大过 收缩阈值了,默认为 128K
,则收缩堆,也就是还给内核。chunk_size
> 64KB
,且 top chunk
大于收缩阈值,则释放。1 | void |
只放出最重要的一段,收缩堆的条件。
1 | if ((unsigned long)(size) >= FASTBIN_CONSOLIDATION_THRESHOLD) { |
由于 ptmalloc
用了 mutex
,如果一个多线程的进程执行 fork
会将执行 fork
的线程进行拷贝,其他线程会突然消失,这个时候子进程的 mutex
处于不安全的状态,只能直接重新初始化。关于这一点可以查看 ptmalloc_unlock_all2
这个函数。
1 | thread_atfork(ptmalloc_lock_all, ptmalloc_unlock_all, ptmalloc_unlock_all2); |
扩展堆和收缩堆还有释放堆的几个操作补充一下。
1 | /* Grow a heap. size is automatically rounded up to a |
通过了解 ptmalloc2
分配释放内存的策略,可以知道,它比较适合短生命周期的内存分配,若是长生命周期的内存,则会不断抬高 top chunk
,导致无法将内存释放回内核,引起内存暴涨。而游戏服务器中,玩家的内存数据很有可能要等一个小时以上才释放,生命周期比较长,因此最好的做法还是自己写一个基于 mmap
的内存池(打脸了 在Lua GC垃圾回收优化方案中我还提到,认为内存池没有必要),之所以特意强调是基于 mmap
主要是 brk
它类似于栈,会将堆顶抬高,如果堆顶内存没释放,会导致堆顶以下的内存都不能还回内核,又会导致内存暴涨。
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源码。
说的那么好,那如何使用呢?
目前提供了四个接口。
1 | nogc("open", Table) -- 这一整个 Table 都不被扫描不被清理 |
Table 中的元素支持,字符串,整数,浮点数,布尔值,表,Lua 闭包。
不支持 当 Table 是弱表的情况。
需要注意的是,当一个 Table 被打上标记之后,就不能够再修改其内部的数据,因为有可能会创建出一个新的对象,但是又不会被 Lua 的垃圾回收扫描到,导致这个对象被回收,发生段错误。
首先需要引入我写的两个文件, YGC.c, YGC.h
。
然后跟着我的步伐修改以下几个文件。
添加头文件,然后导出 nogc
函数给 Lua 使用。
1 |
|
添加头文件,在 pushclosure
函数这里, if (!isblack(p))
改为以下的代码。
这是因为,当我们标记的 Table 中含有的闭包,被执行到的时候,会动态的生成 Closure
,但是这个 Closure
是没办法被标记到的,因为是动态生成的,因此不应该指过去。
1 |
|
在 global_State
记录两个辅助的值,其中一个是 nogc 的对象内存大小,另一个是 不参与GC的链表,都是为了方便调试用的。
1 | typedef struct global_State { |
初始化上面的对象
1 | LUA_API lua_State *lua_newstate (lua_Alloc f, void *ud) { |
这是最后一个文件了,依然是添加头文件。然后将以下代码进行对比替换。
1 |
提前返回对象,减少垃圾回收耗时。
将 以下代码 进行替换,简单的来说就是将不需要GC的对象,移出 allgc
链表。
1 | static GCObject **sweeplist (lua_State *L, GCObject **p, lu_mem count) { |
替换为以下这段。
1 | static GCObject **sweeplist (lua_State *L, GCObject **p, lu_mem count) { |
propagatemark 的修改主要是为了提前返回,不要遍历不需要GC的对象。
1 | static void propagatemark (global_State *g) { |
替换为。
1 | static void propagatemark (global_State *g) { |
至此完结,享受提速后的快感吧。
]]>1 | package.loaded[name] = nil |
这种方式的热更好处就是简单,不过有的代码写起来就要特别小心,当你在代码中看到以下类似的片段,很有可能是为了热更新做的一种妥协。
1 | Activity.c2sFun = Activity.c2sFun or {}; |
同时,如果 Lua 代码中存有大量的 upvalue
时,还要记得保存原有的状态信息,否则会丢失原值,对于开发人员来说,这种热更方式费心费力。
因此, Lua HotFix
就是为了摆脱以上的限制,或者说减少需要关心的事情,让开发人员能够更为简单的做热更新。之所以要自己写这么一套东西,主要是因为网络上开源的热更方案不适合项目,要么支持的Lua版本过旧,要么就约束的过多,项目已经进行到了中后期,这个时候再来规范已经来不及了,其次有很多的错误,这点我会在本文中的第二部分进行讨论。
本文主要分为两个部分,第一部分为 HotFix 实现,第二部分为热更新的错误案例。
首先放出 HotFix 源码。
通过 loadfile
将文件读入 Lua ,此时为一个 function
也就是 chunk,设置这个 function
的执行环境为我的 假环境表 我管它叫 fakeEnv
,在里面替换掉一些函数,然后执行 chunk
,就能从 fakeEnv
得到一系列的函数,全局变量信息。
接下来是确定什么能更新,什么不能更新。首先函数必更新,因为你热更不更逻辑,要你有何用?其次数据默认不更新,为什么是默认不更新,主要考虑到 upvalue
,优先保证服务器正常运作(哪怕我热更失败),但是 table 这个类型我们要更新,只更新函数即可,table 中的数据也采用默认不更新的思路(因为项目中会在 table 中保存状态数据)。
这个时候就能成功的更新上新的逻辑了,此时就要考虑数据的更新,因为我们不确定什么数据是需要更新的(比如说配置信息),因此默认是不更新数据的,如果需要更新数据,则通过 在模块中加入 __RELOAD
函数,因为什么数据要更新,使用者最为清楚,其次使用这个 __RELOAD
函数,代码入库也极为方便,基本上把热更修改后的文件直接入库就行了。
代码片段示例
1 | yuerer = {} |
因此,使用这套热更新有以下约束
1 | require("fix1") |
这样就能实现最基础的 除 userdata
thread
类型的热更新
__RELOAD
函数1 | function __RELOAD() |
热更新的方案在网络上多种多样,我将会挑选出几个常见的错误,在这里进行讨论。
更新前的函数没有 _ENV
这个 upvalue
,依赖对 table
进行热更的方式无法生效。
假设我有一个函数 error1
写错了,现在要进行热更。以下代码片段分别表示热更前与热更后,如果我采用函数替代的形式,我能更新的上吗?
1 | --------------- 热更前 |
显然是不能的,我们先来看看 error1
的热更前的版本的指令,可以看出,函数体只有一个 upvalue
。
1 | function <test.lua:3,5> (4 instructions at 0x1687da0) |
这个时候因为函数体没有调用任何全局函数 或是 全局变量,自然没有 _ENV
这个环境表作为 upvalue
,也就没有办法通过改写 _ENV[error1] = error1
的方式修改全局表的 error1
的函数地址(除非你显式的加上 rawset(_ENV, error1, xxxx),然而大多数开源的方案都没注意到这个问题)。因此当你热更后调用 error1
的时候还是会调用的热更前的版本。
而下面的版本就可以更新成功。
1 | --------------- 热更前 |
我们再来看一下 这个版本的热更前的指令。可以看到 这次函数里面有了 _ENV
,我们此时可以通过改写这个 upvalue
的内容来达到替换 error1
的目的。
1 | function <test.lua:3,6> (6 instructions at 0x733da0) |
解决方案我认为分为两种。
_ENV
如果没有,则通过 rawset(_ENV, k, v)
这种补丁的形式覆写环境表。使用 debug.setupvalue
进行 upvalue
修复。以下代码猜一下执行结果。
1 | local count = 0 |
答案揭晓,都为 1000。大部分的热更都没有考虑到一个 upvalue
会同时被一个以上的函数所使用的情况。
正确的热更方式是采用 debug.upvaluejoin
进行关联。
出现这样的错误主要还是因为分不清 debug
中 setupvalue
与 upvaluejoin
的区别。
还记得上个错误案例的 debug.setupvalue
吗?这里要讨论的是它和它的兄弟 debug.getupvalue
。
1 | debug.getupvalue (f, up) |
可以看到,它的第二个参数为索引,那么考虑一下下面的代码片段,能否热更成功呢?
1 | --------------- 热更前 |
聪明的人可能已经发现了,如果我们按照索引来取 upvalue
然后更新到下面的那个函数中,是有问题的,我们来分别看看指令。
热更前的函数,第一个 upvalue
为 count。
1 | function <test.lua:3,5> (4 instructions at 0x19acda0) |
热更后的函数,第一个 upvalue
则为 _ENV
这是因为我们在这里先调用了 print
这个全局函数。
1 | function <test.lua:8,10> (4 instructions at 0x19aceb0) |
因此,热更 upvalue
的时候,一定不能默认更新前后的函数 upvalue
的顺序是不变的。
小心重复更新。下面的代码展示了一个错误案例。
1 | --------------- 热更前 |
正如我们前面所说,热更模块的实现无非是只替换函数与表中的函数,而数据则是默认用旧值,需要更新的数据在我所写的框架中需要定义一个 __RELOAD()
函数,在里面填写需要更新的数据(这块如果不记得的话,可以回到前面,了解一下我为什么要这么设计)
首先我们来讨论更新前,更新前没有 c3
函数,
现在来讨论更新后,更新后增加了一个 c3
函数,这种时候更新到 c1
时,会跟着 c1
的 _ENV
去更新 c2
(因为 ENV 是一个 table 需要更新), c2
也有 _ENV
然后又会更新 c1
,还会更新 c3
此时因为 c3
原本是一个不存在函数,直接设进 _ENV
就行了。
这个时候更新了 c2
,你可能有疑惑 不是 c2
已经更新过了吗?之前更新的 c2
是因为 c1
的 _ENV
更新到的,这次是由 假环境表中找出来更新的, c2
顺着自己的 _ENV
又会更新到 c3
,第二次更新 c3
的时候,因为之前我们已经设置到真正的 _ENV
去了,此时就要重新更新 c3
的 upvalue
,可这个“旧函数” 是同一次热更中产生的,因此在有的时候会导致 c3
的 upvalue
关联到错误的地址。
因此,要小心重复更新,无论是 table 也好, 还是 function 也好。
Lua 热更新是一个值得研究的东西,它非常有趣,能够让你更理解 Lua 的运行机制,同时能够减轻项目开发人员的负担,由于时间关系,目前足够支持各个项目组各种奇怪的写法,在开源中的实现里应该是较为全面的,由于现有项目中不使用协程,而且是一个全Lua的框架,因此也没有 userdata
,目前来看是足够了。
唯一我觉得不足的地方,当其他地方存储一个 function
作为 callback 的时候,没法直接更新到,通常是采用调用字符串的形式来调用函数(其实就是从 _ENV
找这个函数的地址),如果可以将这一块做到 Lua 虚拟机中,就能实现更完美的热更新了。
在 Lua 5.0 之前,Lua 因为没有 userdata
,垃圾回收的工作就很简单了,因为没有 userdata
也就没有了 __gc
元方法,也就不需要针对有特殊析构操作的对象进行特殊处理。
Lua 从早期到现在 2020年
推出的 最新版 Lua 5.4 都是采用的标记扫描算法,垃圾回收算法一般分为两类。
引用计数的话,每个对象都要占用多一块内存,同时需要频繁的增减引用计数值,特别指的是在栈上的时候,Lua 解释器做的又非常简单,如果采用引用计数,还要对指令进行优化。
而早期 标记扫描 也是比较简单,首先它每次扫描且回收垃圾都是需要一次执行完的,其次它只有两种标记,用到或没用到,而且每次创建新对象都会跑一次GC。
显然,这种垃圾回收注定了没人敢用。。。我每创建一个对象,你都跑一次GC,这谁顶得住?
到了 Lua 5.0,就采用了折中的办法,当内存分配超过了上次GC后的两倍,就跑一次全量GC。而且 这个版本里 支持了 userdata
,当一个 userdata
有 __gc
元方法时,需要对 userdata
作特殊处理,所谓的处理就是将其从所有对象的链表 也就是 allgc
拿出来,放到一个单独的链上 finobj
,因为还要调用完 __gc
方法,再将其释放。(这一个操作是在对 userdata
设置 metatable
后进行的,因为一个 userdata
如果没有 metatable
必然没有这个 __gc
元方法,当然 table 也可以有 __gc
元方法)
依然是全量GC,没人敢用,只不过稍微好一些,只有内存分配超过上次GC的两倍,才进行GC。
Lua 5.1 支持了渐进式垃圾回收,原理就是三色扫描,两种白分别表示不同回合的需要回收的对象的标记,灰色代表没扫描完,黑色代表一定别给我回收了!
但是这样也有问题,因为是渐进式扫描,如果一个 table
已经被扫描完了,这时再给他加一个对象,这个新对象默认为白色,到最后会被回收。
因此有两种方式,一种是 barrier forward
就是将白色改为灰色,另一种是 barrier back
就是将黑色的 table 改为灰色。
在 Lua 实现中,如果你对 一个扫描完的 table 进行修改操作,会默认将 table 改为 灰色,且加入到 grayagain
,等到 atomic
的时候 再一次性扫过。因为 table 被改过一次,说明它还有可能再被改,为了避免其在 黑色 与 灰色里面 反复横跳,干脆直接丢 grayagain
链表上,等到时候一次性解决,也就是 atomic 阶段。
如果对象在栈上的话,则直接变为灰色,而不是将栈改为灰色,减少对栈的操作。
关键是 含有 __gc
元方法的对象,从 Lua 的角度,只有两类可以设置 元表,table 与 userdata,从 C 的角度,任何类型都可以有自己的元表。
如果给一个黑色对象设置一个元表,那么将元表置为灰色即可。
拥有__gc
元方法的对象,在设置的那一刻,会将该对象,从 allgc
链表上弄下来,将其加入到 finobj
链表上。
atomic
时刻,扫描一次 finobj
链表,将可回收对象转移到 tobefnz
链上,同时标记为灰色 不可回收,这是为了到最后阶段,先执行一次 __gc
然后将其重新链回到 allgc
走常规对象的 GC 流程。
因此,不要有过多含有 __gc
元方法的对象,毕竟都是在 atomic
阶段扫的,不可分割。
其次是弱表,弱表的话就是避免因为引用而无法被GC清理,它也是在 atomic
阶段进行扫描的,尽量减少 __gc
和 弱表,就能减少 GC 的时间消耗。
键值都弱放 allweak
链表,键弱放 ephemeron
链表,弱值放 weak
链表。
在 Lua 5.2 中,推出了分代GC,不过又在 Lua 5.3 中将其删除,现又在 Lua 5.4 中加入。
再次推出了 分代GC。所谓的 分代GC 指的是对象分为老年代和新生代。老年代指的是常驻对象,长时间不需要GC的对象,但是在 之前的版本中,大量的时间都是在扫描标记这些“老年代”,因此如果能够减少扫描标记老年代的话,GC性能就能达到提升。
至于新生代,则是刚创建出来的对象,很有可能需要进行清理,比如在栈上创建的对象,这样不只是GC效率有提升,还能保持内存占用的稳定(毕竟刚创建出来的对象,如果不用了就马上回收掉,而不是一直拖着)。
分代GC目前看来是挺好的,不过一旦与渐进式GC混用就很难受了,因为你没法复用 barrier forward
和 barrier back
,这里不指的是颜色/标记,而是指老年还是新生,试想渐进的时候,创建了个新对象,那么是应该把引用到新对象的老对象改为新生,还是把新对象改为老年代。这是一个问题,新对象改为老年,那老年就会有特别多,起不到回收的作用,老对象改为新生也是同理。
因此这个时候就需要第三种状态,类似于之前标记扫描法的第三种颜色,触碰过的对象,可以理解为触碰态,如果老对象指向了一个新的对象,则认为它处于触碰态,下次扫描把他一起扫了。
新生代和被触碰过的对象连续两次被扫描到,就说明它有可能经常被用到,就将其转为老年代。
分代GC 减少了老对象重复被扫描和标记的代价,提升了GC性能,但是总会有一个适合,会进行全量GC,只不过这个代价比较少,毕竟大部分对象都在新生代的时候就被回收了,如果项目要上 Lua 5.4,要特别小心这个全量GC的过程,最好主动的切换到步进模式,回收完一个周期后,再切回分代GC。
从上面我们可以知道,所有的对象,都会在创建的时候挂上 allgc
链表,但是在游戏服务器中,我们有很多的对象,根本不需要GC,特别是配置表信息,(目前的几个项目都是重Lua的架构,所有配置都在 Lua 中进行读取。哪怕是 Lua 5.4 这些对象肯定会进入老年代,还是会被全量扫描标记到)。因此我们可以考虑给 table 加个函数,例如 table.nogc()
,把所有配置表的对象从 allgc
链拿下来,这样我们就能减少 O(N)
的时间。但是仅仅这样还是不够的,我们还要在扫描阶段提前返回,当扫描到我们标记过的 不需要 GC 的 table,则提前返回,减少扫描标记的时间。理论上,配置越多,越大,减少的GC时间越多
同理,我们还可以对一些全局函数进行这样的操作,旨在于减少需要扫描标记的对象个数。
如果不进行这样的优化,几乎每次重新开始GC,前面的一大段时间都是在标记扫描我们的不能垃圾回收的对象,非常浪费。
这个思路,我将会在之后进行尝试,最后再链接过去。
还有个思路,则是在 内存分配和释放上做手脚,简单来说就是你写个内存池,进行小内存分配。不过个人感觉,优化不大,基本上和 原生的 malloc
性能差不多,毕竟现代的内存分配器早就迭代了N个版本了。
在此之前,我们还是先来过一下 Lua 5.3 的GC的设计与实现吧。
GC 的时机,主要由以下宏控制,可以看出默认是分步GC。
1 |
|
除此之外,还可以手动调用 lua_gc
api。
分步GC 可以通过 LUA_GCSETPAUSE
控制执行GC 的时机,默认是新增内存为上一次的两倍 也就是 200
。
LUA_GCSETSTEPMUL
则是控制 GC 的速度,默认为 2,是新增内存速度的两倍,这个值不能低于 40,也就是 0.4,最小也是 40。
1 | static lu_mem singlestep (lua_State *L) { |
__gc
元方法,执行后再放回 allgc
走常规回收流程)。grayagain
finobj
链表,还有弱键,弱值,弱表,将 finobj
可回收的对象转移到 tobefnz
链表。finobj
对象,这一个我一开始没反应过来,因为 finobj
链表中的对象难道不是在 atomic
阶段就已经将可回收的都转移到 tobefnz
链表吗?怎么还要进行清理 finobj
呢?sweepstep
的原因只是为了将其标记为另一种白色而已。tobefnz
对象,也可以和上面那样理解。GCScallfin
tobefnz
的 __gc
函数,后将其转移到 allgc
链表,走常规对象回收流程。还有一条 fixedgc
链表,存储的都是不会被GC的对象,目前都是短字符串,但是它还是有可能会被扫描到,浪费了一定的时间,不过因为比较少,所以其实也还好。
首先 Upvalue
受不受扫描标记控制,这个问题是有条件的,当 Upvalue
指向的对象处于栈上时,栈上的对象会被栈引用到,因此会被标记,但是 不会通过 闭包去扫描到 Upvalue
。
一旦 Upvalue
被关闭(就是返回的时候,离开了作用域),就会将其拷贝到 闭包内部的 UpVal
中,这个时候就不受到扫描标记的管控了,而是被引用计数所管理。
1 | void luaF_close (lua_State *L, StkId level) { |
而只有 Closure
被回收的时候,才会将 UpValue
的引用计数减少,因此被关闭的 UpValue
是否被回收依赖于其寄生的 Closure
。
1 | static void freeLclosure (lua_State *L, LClosure *cl) { |
这就说明 Closure
在初始化的时候,要把 UpValue
被关掉的时候的藏身的内存也给提前分配好,这点可以在以下代码可以看到。
1 | struct UpVal { |
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) { |
先来看创建操作,调用 lua_newthread
创建一个新协程,这里面的协程的状态信息还是 lua_State
,各个协程之间的公共数据则在 global_State
。
lua_xmove
则是将两个 lua_State
的数据转移。
1 | LUA_API void |
创建好协程,还需要手动调用 resume
才能执行,主要依托于 auxresume
,将参数拷贝到协程中,调用 lua_resume
。
1 | static int auxresume (lua_State *L, lua_State *co, int narg) { |
lua_resume 会检查各种条件,包括协程状态,调用层数。
接下来会将 nny
设置为 0,这个 nny 指的是 number of non-yieldable" calls
,它是用来控制是否允许 yield
的,最终会以保护的形式调用 resume
。
1 | LUA_API int lua_resume (lua_State *L, lua_State *from, int nargs) { |
如果是协程刚开始的时候,那就像是执行一个函数那么简单。相反如果是从 yield
状态切换回来,
其实这必然是 C函数中过来的,因为 lua调用 yield
其实还是到了C函数这。
如果在 lua 则继续解析指令即可,这里的 lua 其实是 hook 函数,看起来是 lua 函数 其实还是 C函数,可以看到 之前的堆栈信息存在了 CallInfo->extra
,所以 resume
回来之后,实际上不会有 Lua函数,但是我们要跳过 Lua的指令。
若是在C中 调用的 lua函数,而lua函数又调用了 yield
,则看看 我们之前保存的继续处理函数和上下文存不存在,再去调用即可(调用的是C函数剩余的部分)。
执行完之前遗留的工作以后,只是说恢复到了正确的工作,别忘了 lua 中可能还有要执行的任务,因此会调用 unroll
。
1 | static void resume (lua_State *L, void *ud) { |
unroll
较为简单,执行接下来的字节码,如果是停在了C函数,则会调用 finishCcall
去执行完剩余的C函数。
adjustresults(L, ci->nresults);
是因为此时一定停在了 luaD_precall
函数,而这后面就是这一句,因此可以写死,还有一句则是 luaD_poscall
。
1 | static void finishCcall (lua_State *L, int status) { |
lua_resume
以保护模式调用 resume
如果出现异常,则会调用 recover
去修复。可以看到 这里是去找 调用 pcall
的 CallInfo
。因为 pcall 确实会抛出异常,然后就会去找 pcall 在哪里,将其还未执行完的事情给完成(指的是 luaD_pcall 异常后应该做的事情)。
1 | static CallInfo *findpcall (lua_State *L) { |
交出CPU资源,给其他协程机会,有了前面的基础,比较好理解,保存了当下次 resume
的时候,应该继续执行的C函数和上下文环境。
1 | LUA_API int lua_yieldk (lua_State *L, int nresults, lua_KContext ctx, |
Closure
其实对于 C/C++
程序员可以简单理解为 函数。不过由于有了 Upvalues
的概念,会让人理解起来不那么容易,但是 Lua 中的所有函数 其实都是 闭包,包括我们第一篇 Lua 5.3 设计实现(一) Lua是怎么跑起来的?) 文章中提到的运行流程的第一个主函数,其实也是一个闭包。本文中 函数与闭包的名字会混用,请根据其是否含有 Upvalue 进行区分。
闭包是由 函数原型(Proto)+ (UpValue)组合而成的。
而 Proto
其实就是拥有所有执行所需要的信息,因为这一块在第一篇已经讲过,故大幅度跳过。
1 | typedef struct Proto { |
我们更关注的是 Upvalues
。
upvalue 主要由 一个union 和 TValue 构成,在这里要理解一个概念。
upvalue 的 open
状态。
UpVal.u.value
中,不受到垃圾回收的管控,而是被引用计数管理。1 | struct UpVal { |
因此 当 upvalue 为 open 时,v 指向 栈上原始值的地址。反之,则将其值存入到 UpVal 这个结构体自身。
这也就是为什么 下面的代码能够正确执行的原因。
1 | function Counter() |
return 回去这个 function
因为 t 已经不在栈上了,故将其值存入了这个 UpVal 结构体中,跟随着这个 function 一起。
结构中的 open
这一个结构体,则是当 UpVal 为 open态时,链接上所有的 open UpVal,方便后续的查找,而 touched
是为了防止垃圾回收时 还指向栈上对象的 upvalue
被清理。因为 垃圾回收的 atomic
有个 remarkupval
的函数,在里面进行重新标记 upvalue
。
无论是 C 函数,还是 Lua 函数,其 UpValues 都与函数本身分离,但又被包裹在一个结构体中。
1 | typedef struct CClosure { |
其中 C 函数很有可能没有 UpValue,因此 Lua 也提供了一种叫 light C function 的东西,直接将函数指针设到栈顶,其生命周期由 其 Host 去管理。
1 | LUA_API void lua_pushcclosure (lua_State *L, lua_CFunction fn, int n) { |
Lua 的闭包就比较复杂了
先是创建一个 闭包,然后才设置 其 UpValue。
1 | LClosure *luaF_newLclosure (lua_State *L, int n) { |
UpValue 会根据其是否在栈上,用 Upvaldesc 中的 instack 字段进行表示。(一般是在 代码被编译的时候,写入到调试信息中,或者是判断这个 key 是否出现在 local 中进行判断),这里的在栈上并不意味着它被打开,如果不在则在上层函数中进行寻找。
最后将这个 闭包 存入 Proto 的 cache中,如果下次还要根据 Proto
生成 Closure,则先检查该 CLosure 的 UpValue
是否完全一致,如果是则复用,因此最好不要写出动态生成闭包的代码,避免性能的损耗。
1 | // 动态建立,判断是否为 local 是的话,则是在栈中 |
如果在栈中,则会调用 luaF_findupval
函数。
这个函数从 openupval 链中找,如果找不到就新建一个。
1 | UpVal *luaF_findupval (lua_State *L, StkId level) { |
如果能答对以下几个问题相信对这一节的内容就已经完全理解了。
以下代码。
1 | local _table = {} |
可以先看看指令码。
1 | [root@localhost src]# luac -l -l main.lua |
upvalue
,指的是 _table
upvalue
,因为第一次 luaF_findupval
会发现 openupval
没有,于是新建了一个,第二次 pushclosure
也会执行到 luaF_findupval
,这时候 openupval
已经有了,于是直接指向它。upvalue
实际上为同一个,因此当这个文件被 return
的时候,只会拷贝一次到第一个闭包的 upvalue
上。Table
和 MetaMethod
的一些设计实现,谈论到了 Lua 会对 元方法的字符串名字作缓存,同时提到了 Lua 字符串分为长短字符串。这一篇主要是谈论一下 Lua 的长短字符串是怎么设计的?为什么要分长短这两种类型?可以看到字符串内部会记录哈希值,每个字符串被创建出来就不能被改写,因此为了节约内存,Lua会复用相同的字符串,但是逐字节比较太慢了,因此会预处理将字符串hash,存入字符串的 hash
字段中。
字符串的实际内容会追加到 TString
的后面。
1 | typedef struct TString { |
短字符串全局只有一份,Lua解释器会将其存到 stringtable
这个结构中。字符串 hash
会根据 global_State
的 seed
进行哈希。
1 | typedef struct stringtable { |
LUAI_MAXSHORTLE
作为分界来区分长短字符串,默认为40字节
1 |
|
其实在 Lua 5.3 之前,字符串并不分长短,之所以现在要分主要是因为 Hash Dos
攻击。
Lua 中的字符串会进行 Hash,然后将其放入 strt 中,如果发生了冲突,就会用最简单的开链法,将相同Hash值的字符串串起来。
Lua 5.2.0 中 创建字符串的规则比较简单,凡是阅读过源码的,都能大量构造出相同哈希值的字符串,导致 Lua解释器不得不根据链表上的字符串逐一比对字符,最终会因为比较字符串耗尽 CPU
资源。因此 Lua 5.2.1 之后才会采用 global_State 的 seed 去随机构造哈希。
1 | TString *luaS_newlstr (lua_State *L, const char *str, size_t l) { |
Lua 5.3.6 随机生成随机数种子。
1 |
|
随机数种子生成规律非常有趣 它根据以下几点进行随机生成
最后调用了 luaS_hash()
来创建 hash seed,这个函数即用来hash字符串,同时又用来创建 hash seed。
1 |
|
可以看到 luaS_hash
对字符串 hash 的时候,如果字符串过长,就会跳过部分字符来提高性能。
当冲突的字符串越来越多的时候,查询相同字符串的效率会越来越差,不过没关系,当字符串的数量 > strt的大小,会分配一个原strt两倍大小的哈希表。同时 将原有重新进行 Hash,放入新的哈希表中。同理,当字符串的数量 < strt的大小 / 4 的时候,strt 就会缩小为 原先的一半。
1 | static void checkSizes(lua_State* L, global_State* g) { |
1 | TString* luaS_newlstr(lua_State* L, const char* str, size_t l) { |
短字符串会直接进行 hash
若冲突则用开链法链起来。
1 | static TString* internshrstr(lua_State* L, const char* str, size_t l) { |
没有立即进行 hash
而是留到之后,再进行 hash
。
1 | TString* luaS_createlngstrobj(lua_State* L, size_t l) { |
在源码中,我只找到一处 对长字符串进行 hash
,就是在上一篇的 table
中,当要对 字符串key 进行 hash
的时候才 hash
(它都需要哈希了才哈希,是否可以看作 Lua 并不想对长字符串进行哈希呢?)
1 | static Node *mainposition (const Table *t, const TValue *key) { |
MetaTable
实现的 MetaMethod
。其实我觉得,Lua之所以能大放异彩,其一是它非常精小,其二是其开源,其三则是因为它的MetaMethod
的设计。
虽然本篇主要讲 table,不过在那之前,最好先来认识一下 Lua 其他类型在 Lua解释器中的实现。
UserData 暂且不谈,NUMBER细分为浮点数和整数,字符串则分长短字符串,函数又分Lua函数和C函数还有轻量的C函数,这一部分会分别留到字符串和闭包的时候再谈论。
1 |
先来想想,我们一般是怎么使用 table 的,是不是大部分时候都是既用来当数组又用来当哈希表。
因此,可以很简单的想到,table 很有可能底层是使用哈希表来实现的。事实上Lua早期版本也确实是这么做的,只不过后来优化了 table 被当做数组用的性能(就是加了个数组)。
可以看到 Table 的结构中,有表示 metatable,也有数组,还有哈希表,跟我们猜想的几乎一致。而且这还更激进一点,两者都启用!
注意到 lsizenode
是以2位低的整数次幂,非实际大小。
1 | typedef struct Table { |
数组部分没什么好看的,我们主要看其哈希表的实现。 TKey
中的 nk
主要是用来当Key的哈希值相同时,开链用。
1 | typedef union TKey { |
创建 table 主要是对结构进行初始化,同时注意到一点,table 的 node 默认是 dummynode
,在lua设计中,当一个table的哈希表部分为空时,则默认使用一个 dummynode
的全局对象,因为是只读访问,没有线程安全问题,其实设置成 NULL
我想也是可以的,不过还记得上面的 lsizenode
是以2为底的幂次吗?2^0 == 1,因此设置一个 dummynode
,逻辑看起来更自然。不过如果你不小心链接了两次 Lua 库,内存上就有两份 dummynode
,根据 dummynode
运算的逻辑都将是 未定义行为。
1 |
|
经过以上,我们可能会思考,我对这个table的操作,到底是操作了数组还是哈希表?在这里我们来看看以下几个操作。
1 | local a = {1, 2, 3} |
可以看出,第一行的操作指令是 SETLIST
,而第二行则是 SETTABLE
。
SETLIST 这种操作默认是在 数组中的,因此会先检查 table 中数组的大小,然后进行赋值。 luaH_setint
会调用 luaH_newkey
通过哈希获取 Key 应当存在的位置,然后将其放入。
1 | vmcase(OP_SETLIST) { |
luaH_resize
会对数组和哈希表进行扩容or缩容,数组中 nil的值 将会被省略。
这个操作就得根据情况来判断了,但最终都是调用到了 luaH_newkey
这个函数。如果不是个 table,则检查其元方法是否存在,检查方法就是根据 table 结构中的 flags
字段按位来找是否有元方法。查找元方法的路径不能过长,默认是 MAXTAGLOOP
2000。
1 |
|
根据 哈希规则,找到 mp即在哈希表中应该存放key的位置,如果被用掉了,就检查占据这个位置的键的位置是不是真的就在这(通过哈希,你可以理解为线性探查法),若真在这,就通过左移 lastfree
指针,找一个新位置,然后将其链起来。否则的话,老让给新的,老重新哈希找到合适的位置,如果还冲突继续往左走。(我个人觉得像是 线性探查+开链法的结合体)
1 | TValue *luaH_newkey (lua_State *L, Table *t, const TValue *key) { |
如果 getfreepos
找不到合适的位置(lastfree 走到最左边),则 调用 rehash
。
里面会统计数组大小,哈希表中可以合入数组的大小(就是看一下key是不是能转换成整数)。
1 | static void rehash (lua_State *L, Table *t, const TValue *ek) { |
Lua 中取长度采用 #
号获取,它会调用以下函数。
如果存在数组部分,则采用二分查找找到第一个 t[i] ≠nil && t[i + 1] = nil
,如果数组真的全在里面,才会走到哈希表的计算。isdummy 为 ((t)->lastfree == NULL)
,如果哈希表部分为空,就不算哈希部分呗,如果有,就在哈希表里面二分查找,将整数下标中的个数给加入进来。因此永远不要对非序列进行取长度操作。
1 | static lua_Unsigned unbound_search (Table *t, lua_Unsigned j) { |
前面提到过,table 的结构有个 flags
字段,表示哪些元方法不存在!然后对 一个类型操作时,会去检查其元方法,如果有元方法,则尝试调用,最多调用2000次,超过则抛出错误。同时会对 元方法的名字,进行优化,提前创建好这些字符串对象,并将其缓存起来。
1 | void luaT_init (lua_State *L) { |
table 最常用的两种遍历操作,pairs 是通过 luaH_next
函数实现的。当key 为nil时,则从头开始遍历。
1 | int luaH_next (lua_State *L, Table *t, StkId key) { |
需要注意的是,如果 table 中某个键的值被设置为nil,有可能会被GC回收,但是此时还在遍历,Lua官方称其为死键。
其实也没做什么特殊的,标志为死键又不是被删除了,不过如果被 rehash
则会被从哈希表清除,触发 rehash
的条件是添加新键且空间不够了,因此如果你不添加新键,遍历就挺安全的。
1 | static unsigned int findindex (lua_State *L, Table *t, StkId key) { |
至于 ipairs 则是通过 lua_geti
实现,其真正的操作是在 luaH_getint
中,如果还是找不到,则会通过 luaV_finishget
去找其元方法。ipairs 当遍历到 nil
时则会停止,要特别注意不能有黑洞。
1 | LUA_API int lua_geti (lua_State *L, int idx, lua_Integer n) { |
本系列,不会谈论 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, |
确实拥有这个函数,其本质会调用 luaD_protectedparser
,其内部又调用了 f_parser
,不用害怕 luaD_pcall
这个函数,其内部就是调用了传进去的函数指针,这里指 f_parser
。函数名p 指 Protect 安全的调用,其实就是有捕获异常的功能的调用函数,由于C语言没有异常机制,因此它内部用的 setjmp
来实现函数间跳转,模拟异常机制。
1 | int luaD_protectedparser (lua_State *L, ZIO *z, const char *name, |
f_parser
会根据实际情况,选择从二进制或者文本中解析 Chunk,为了简单起见,我们只关注从二进制中解析的方法 即 luaU_undump
。
1 | static void f_parser (lua_State *L, void *ud) { |
luaU_undump
会先检查 Header,然后创建一个 closure,可以理解为是一个函数,里面会有其各种试行信息,然后将其放在虚拟机的栈顶,最后返回回去。
1 | LClosure *luaU_undump(lua_State *L, ZIO *Z, const char *name) { |
checkHeader
主要是检查 Chunk 的Lua版本,大端小端字节序,浮点数是怎么存储的等信息,可以看出 Lua的设计理念是,不同版本我就直接不让你运行,非常霸道。
1 | static void checkHeader (LoadState *S) { |
现在回过头来看 closure
的结构定义。我们可以确定 cl 中的 Proto 才是函数原型,同时 cl 分为 Lua函数和 C函数。 upvals
根据字面意思可以翻译为 上值,属于 Lua 特有,因为 Lua 支持嵌套函数,函数是一等公民,采用了 静态作用域,将外界的变量绑定进来,可以暂时理解为将全局变量绑定进来。
1 | typedef struct Proto { |
LoadFunction
将填充 Proto
,要注意 Proto 是嵌套的,如果有多个函数的情况下。
1 | static void LoadFunction (LoadState *S, Proto *f, TString *psource) { |
加载完了 Chunk
,目光回到 f_parser
其最后会调用 luaF_initupvals
初始化 upVals 就是置nil。
1 | void luaF_initupvals (lua_State *L, LClosure *cl) { |
Load 完之后,我们也能猜测到应当还有个 Call 方法,才能将加载进来的内容 跑起来。将 func读入到 CallInfo(可以理解为Lua解释器中的执行栈),会设置一下是不是可变参,有几个返回值等行为,最后调用 luaV_execute
去执行指令。
1 | int luaD_precall (lua_State *L, StkId func, int nresults) { |
luaV_execute
会将指令读入,然后去执行,Lua 的指令长度为32位,其中6位为指令,剩余位数为操作数。
1 | void luaV_execute (lua_State *L) { |
luaD_precall
会将要执行的函数或称为闭包存放到 CallInfo
,接着 luaV_execute
会调用 vmfetch
获取指令,savedpc 就是我们当前执行到的指令。
1 |
在这里,有必要看看 CallInfo
的结构,因为执行的函数有可能是C函数和Lua函数,故源码用 union将其包起来,我们目前只在意 Lua 的部分,可以看到 savedpc 存的就是每一条指令,它的实际类型就是 uint32
,采用了定长指令,前六位为指令。
1 | typedef struct CallInfo { |
就这样,Lua解释器从加载 Chunk 到执行 Chunk 的流程走完了。
但仅如此还不够,我们可以看到以上大部分函数,都以 lua_state
作为参数,因此我们还需要先实例化 lua_state
,不过在此之前,我们要先简单认识一下 lua_state
的结构定义。
去除掉大量的无关信息,一个 Lua 解释器,仅需要以下几项即可运作。分别是栈的信息(如果你有Lua经验,想必早已知道Lua是通过栈模拟寄存器),调用栈信息即 CallInfo
。
1 | struct lua_State { |
简单地初始化 lua_State,在这里我将无关的内容给删除了,可以看到初始化后会调用 f_luaopen
函数去打开Lua基础库。
1 | LUA_API lua_State *lua_newstate (lua_Alloc f, void *ud) { |
stack_init
初始化栈和初始化调用栈即 CallInfo
, init_registry
初始化注册表,往后的全局对象,还有一些C函数都会注册到这里面。
1 | static void f_luaopen (lua_State *L, void *ud) { |
经过以上的洗礼,可以看到 Lua 在加载 Chunk的时候,要先创建好Lua解释器,然后通过指定格式Load进内存,再调用 precall 预处理,最后将一条条的指令执行。
其实之前看Lua源码的时候感觉很复杂,特别难看懂,特别是C语言的通病各种宏,看一下后面的,过一阵又忘了宏里面写的是什么。这次则采用一种新的方式来阅读,即先想想如果是你来做这个功能,你会怎么做?想到的方法不会相差太多,这个时候顺着自己的思路来寻觅作者的思路,会简单的多。
]]>