« November 2018 | Main | January 2019 »

December 18, 2018

lua 5.4 可能会增加 to-be-closed 特性

如果你有关注 lua 在 github 上的仓库,就会发现,最近一段时间增加了一个新特性:to-be-closed 的 local 变量。

鉴于历史上 lua 每次的大版本开发过程中都会增加很多有趣的特性,却无法保持到版本正式发布。本文也只是介绍一下这个有趣的特性,并不保证它一定会被纳入语言标准。正式的发布版中即使有这个特性,语法上也可能有所不同。

我认为 Lua 加入这个特性的动机是它缺乏 RAII 机制。过去,我们必须用 pcall 来确保一段代码运行完毕,然后再清理相关的资源。这会导致代码实现繁琐,几乎无法正确实施。比如,如果你用 C 函数申请了一块资源,期望在使用完毕后可以清除干净,过去就只能依赖 __gc 方法。但 gc 的时机不可控,往往无法及时清理。如果你把释放过程放在运行过程的末尾,是很难确定整个运行过程中没有异常跳出的可能,那样就无法执行最后的释放流程。

lua 5.4 预计要增加的 to-be-closed 特性,是允许给堆栈上的 local 变量添加一个 to-be-closed 属性。有了这个属性的 local 变量,一旦超出它的作用域,就立刻运行一段相关的代码。这很像是结合了 golang 的 defer 和 C++ 的析构函数。

在 lua 代码中,我们可以使用 local *toclose c = function(errobj) ... end 来将一个 local 变量 c 定义成 to-be-closed 的。这里 c 是一个 function ,在 c 出了作用域后,它就会被调用。这很像 golang 的 defer 。

如果正常出作用域:比如是通过 goto break return 等,函数的参数为 nil ;如果是通过 error 异常跳出,那么 error 对象就会作为第一个参数传入。

这里的 to-be-closed 变量 c 也可以是非 function 类型,这时,触发的就是它的 __close 元方法。这很接近 C++ 的析构函数的行为。不过它并不等价于析构函数,因为对象还可以继续被使用。

触发 __close 时,会多传一个参数是对象本身。有了这个机制,我们就不再担心 io.open 这种临时打开的文件无法即使关闭了,因为 FILE 对象默认会加上 __close 方法,你需要做的是在需要的地方加上 *toclose 声明。

有了 to-be-closed 后,for 迭代器就可以实现的更完备了。for 会将迭代器声明为 to-be-closed 的,像 lfs.dir 这种操作系统目录迭代器,你就不会担心是否能及时关闭目录对象了。因为无论任何原因跳出 for 循环,迭代器的 __close 方法都会被调用。

你也有方法阻止 __close 方法调用,那就是使用 coroutine 来包装一个运行过程。如果使用 yield 跳出,是不会处罚 __close 方法的。新的 coroutine 库增加了 kill 方法来一次性触发所有挂起的 __close 并可以让线程对象可复用。

to-be-closed 也可以通过 C API lua_toclose 声明。有了这个,我们可以更放心的在 C 代码中申请临时资源,而不必担心某个 api 调用会抛出 error ,导致临时资源没有释放干净。在过去,我们通常是利用 lua_newuserdata 来申请临时内存,这往往无法及时回收。

December 03, 2018

惰性编译资源仓库中的源文件

我们的 3d engine 的资源仓库使用 Merkle tree 储存在本地文件系统中,我们称呼它为 vfs ,虚拟文件系统,其结构和 git 的仓库非常类似。关于这部分的设计,之前已写过好几篇 blog 了。

现阶段已完成的版本,已经做到把 lua 虚拟机和所有 C/C++ 实现的 lua 库静态编译打包为一个执行文件,可以零配置启动运行,通过网络远程访问一个 vfs 仓库,完成自举更新和运行远程仓库里的项目。

最近在开发的过程中,发现了一点 Merkle tree 的局限性,我做了一些改进。

由于 Merkle tree 的根结点记录着整棵树的 hash 值,所有树上任何一个地方的增删修改都会影响根 hash 。这也是为什么 git 可以不依赖版本号,只用一个 hash 就能指代任何一个分支的快照的算法基础。我们这个 vfs 也是如此。

但是,对于游戏开发来说,却有一个麻烦。

开发者维护的数据资源往往只是数据源,而不是目标平台的数据。例如,你可以只维护 shader 源文件,再根据你的目标平台是 windows 还是 ios 或是 andriod 生成不同平台的 shader ;你可以只维护通用 png 格式的贴图,再针对不同平台压缩为 dxt/etc/pvr 等格式。

如果我们依旧把最终平台所用的资源全部生成储存在 Merkle tree 中,那么就需要在修改源文件后,立刻生成所有平台的版本;或者为多个不同平台维护多棵树。无论是哪个方案,都比较影响使用体验。稍微做一些修改,就要进行编译流程。Unity 在这方面就做的不太好,虽然后来用 cache 服务解决了一部分问题,但是切目标平台这事还是和重编译 C++ 工程一样,让人等得烦心。

我们现在的 vfs 天生是 C/S 结构的,所有文件都放在资源仓库服务器上,运行时客户端只要按需同步。服务器可以采用高配置,多线程传输和编译数据文件,供多个客户端使用。编译资源的工作我认为还是惰性按需进行比较好。这就需要对 Merkle tree 结构做一点改进。

所以,我们还在在原始目录中储存源文件,比如贴图就储存 .png 文件。对于需要编译的资源,再附加一个 .lk 后缀(类似 unity 的 .meta 文件) 描述这个资源需要做什么额外编译。.lk 文件和源文件放在一起。例如一张贴图可能就是 foobar.png 和 foobar.png.lk 两个文件。

当程序需要读取 foobar.png 这张贴图时,它会从 vfs 中发现同时还有 foobar.png.lk 这个文件。它便把 foobar.png 的 hash 值,加上 foobar.png.lk 的 hash 值,以及自己当前平台名,三者连接成一个长串再计算一次 hash (下文称其为 LHASH) ,作为索引,向文件服务器请求。

也就是说,源文件内容以及 lk 文件(如何编译)内容于平台名,三者决定了一个目标数据块。最终的数据块还是以 hash 为索引储存在 vfs 仓库中,但是会在本地多建立一个 LHASH 的索引文件,指向这个真正的 hash 块。下次再需要时,根据 LHASH 就能直接从 vfs 仓库中取得编译好的数据了。

真正的编译后数据依旧保存在仓库中,但却是储存在 merkle tree 之外的。这样未生成的数据并不会影响 merkle tree 的结构。额外的 LHASH 可以保证源文件或 lk 文件(里面放置着编译参数)修改时,能更新到最新版本。

另外,编译数据只存放在资源数据仓库中,以 cache 的形式存在。也方便了开发者做源文件的版本管理。