杂谈:Angelscript 动态类型、异步、多线程,全都要#
Angelscript 是一门静态类型脚本语言,轻量小巧,所以有些复杂的特性是不支持的,比如动态类型、异步、多线程。
虽然原生没有,但我们可以自行创造。得益于 Angelscript 与 C++ 的良好互动,借助 C++ 对内存的直接控制能力、充分利用 UE 已有的内容、以及 UE-Angelscript 插件的胶水层整合, 最终能实现安全良好的新特性。
动态类型 FAny#
这个模块主要解决一个问题: 1、当需要保存一个值,但其具体类型要等到运行时才能确定时,如何在静态类型语言中实现?
静态类型语言要实现动态类型的特征,需要手工进行类型的内存处理。这方面已经有先例了,比如 C++ 有一个叫做 std::any的类型,就是动态类型的一种经典方案。这个类型占用 64 字节,对齐 8 字节。里面做了小类型的缓冲,比较小的类型会被放入栈区,大的类型才会到堆区。
对于追求极限性能的情况来说,std::any 是不适合的,占用内存相对比较大。不过在非频繁调用场景下,性能是足够的。
std::any 并未提供控制内部缓冲区大小的 API。其缓冲区大小是固定的,无法自定义。 如果想进一步压缩缓冲区、以控制整体对象尺寸,就需要自己实现一个类似的版本。
对于一个动态类型的实例,只需保存两个信息即可完整描述自身:实例对象的内存地址与其类型信息。为了防止内存泄漏,应采用 RAII 模式来管理资源的获取与释放。
... 此处略过千言万语...
经过多次迭代,最终在 UE-Angelscript 中实现了一个能“承载万物”的类型:无论是 int、float 还是 UObject,都能被收纳其中。
经过权衡,目前容器总大小为 40 字节,其中缓存区为 24。 若进一步压缩,可实现总大小 32(缓存 16);若不惜增加复杂度,可极限压到总大小 24(缓存 16)。 若彻底放弃缓存、完全手动管理内存,理论上可以压缩到仅 16 字节。 不过在 UE 中,FVector 的大小为 24 字节,若缓存区连 FVector 都放不下,就显得不够实用,因此缓存区至少保持 24 字节是比较合理的。
不过 UE 当下 FVector 大小是 24,如果设置一个连 FVector 都放不下的缓存区,那么也太小了,所以还是缓存区至少 24 是比较合理的。

异步、多线程 FTask | ParallelFor#
这个主要是解决两个问题: 1、如何在脚本里轻松地异步调用某个函数? 2、如何在脚本里轻松地并行计算?
Unreal Engine 已经内置了一些异步工具,最常用的就是基于 UBlueprintAsyncActionBase 的各种子类。 不过如果在脚本中想要新增一个异步功能,就必须编写一个 UObject 类型来承载,既麻烦又浪费性能。
UE C++ 自身带了很多异步工具,比如比较常见的 Async() FAsyncGraph ,很多工具过于底层、或者过于简单,都有各自的适用面,如果脚本里需要使用,首先排除了过于底层的系统,对于过于简单的,意义也不大。
最终我选择了 UE::Tasks 命名空间下的任务系统。 它是一个较新的轻量级封装,API 简单易用。 当然,也有一些“坑”需要自己踩几次才能记住——例如部分类型其实并非完全线程安全,虽然官方有标注,但不崩几次很难真正重视那行注释(笑)。
关于多线程并行,选择了 ParallelFor,这个对于脚本来说,很够用了。
UE::Tasks 和 ParallelFor 都有返回结果可以获取,返回结果肯定是各种各样类型的,所以用一个可以包罗万象的类型来承接,这也是为什么需要弄出来一个 动态类型的原因。
... 此处略过千言万语...
经过多次迭代,最终实现了如下效果。

运行结果(忽略太多行的 ParallelFor)

异步没问题,FPipe 控制任务顺序也没问题。
总结#
动态类型模块解决了类型擦除的问题。 实现过程中至少需要上千行代码来覆盖各个环节,技术难度主要集中在内存操作上。 稍不留神就可能崩溃——不过“崩”反而是好事,要是偶发地崩(触发未定义行为),那才真叫麻烦(笑)。
异步与多线程支持,使脚本层能够有效分担主线程压力,不必所有性能瓶颈都交给 C++ 解决。 这一部分的实现大约需要两三千行代码,主要难点在于绑定逻辑的复杂性。 虽然过程中用了不少宏魔法来简化,但总体仍相当庞大。
这些特性得以实现,算是站在了巨人的肩膀上,虽历经艰辛,但能到达目的地。
此篇,只是说明了【此路可通】,至于阁下想要走一遭,阁下勇气可嘉,我相信阁下的实力,但还是多保重。
问:你要动态类型、异步、多线程的哪一个? 答:全都要!