Emscripten 帮助将 C 和 C++ 代码移植到 Web 上运行。在进行此类移植时,我们必须绕过 Web 平台的限制,其中之一是代码必须是**异步**的:您不能在 Web 上运行长时间运行的代码,它必须拆分为事件,因为其他重要的事情——渲染、输入等——在您的代码运行时无法发生。但是,C 和 C++ 代码通常是**同步**的!这篇文章将回顾 Emscripten 如何使用各种方法来处理此问题。我们将了解预加载虚拟文件系统以及最近添加的一个选项,该选项可以在特殊的解释器中执行编译后的代码。我们还将有机会玩一些 Doom!
首先,让我们更具体地了解一下问题。例如,考虑一下
FILE *f = fopen("data.txt", "rb");
fread(buffer, 100, 1, f);
fclose(f);
此 C 代码同步打开文件并从中读取。现在,在浏览器中,我们没有本地文件系统访问权限(内容出于安全原因而被沙盒化),因此在读取文件时,我们可能会向服务器发出远程请求,或从 IndexedDB 加载——这两者都是异步的!那么,任何东西是如何被移植的呢?让我们回顾三种解决此问题的方法。
1. 预加载到 Emscripten 的虚拟文件系统
Emscripten 的第一个工具是**虚拟内存文件系统**,它在 JavaScript 中实现(大部分代码归功于 inolen),可以在程序运行之前预先填充。如果您知道将访问哪些文件,则可以预加载它们(使用 emcc 的–preload-file 选项),当代码执行时,文件的副本已在内存中,随时可以进行同步访问。
在少量到中等数量的数据上,这是一种简单且有用的技术。编译后的代码不知道它正在使用虚拟文件系统,对它来说一切看起来都很正常且同步。一切正常。但是,对于大量数据,将所有数据预加载到内存中可能代价过高。您可能只需要每个文件一小段时间——例如,如果您将其加载到 WebGL 着色器中,然后忘记 CPU 端的内容——但如果全部预加载,则必须一次将所有内容都保留在内存中。此外,Emscripten 虚拟文件系统努力尽可能地兼容 POSIX,支持权限、mmap 等,这些会增加某些应用程序中可能不需要的开销。
这有多大问题不仅取决于您加载的数据量,还取决于浏览器和操作系统。例如,在 32 位浏览器上,您通常限于 4GB 的虚拟地址空间,并且碎片可能会成为问题。出于这些原因,64 位浏览器有时可以成功运行需要大量内存的应用程序,而 32 位浏览器则失败(或偶尔失败)。在某种程度上,您可以尝试通过将数据拆分为单独的资产包来解决内存碎片问题,通过分别多次运行 Emscripten 的 文件打包器,而不是使用–preload-file 一次加载所有内容。每个包都是您在页面上加载的 JavaScript 的组合,以及包含您在该资产包中打包的所有文件的数据的二进制文件,因此,通过这种方式,您获得了多个较小的文件而不是一个大的文件。您还可以使用–no-heap-copy 运行文件打包器,这将使下载的资产包数据保留在单独的类型化数组中,而不是将它们复制到程序的内存中。但是,即使在最佳情况下,这些也只会在某些情况下以不可预测的方式帮助解决内存碎片问题。
因此,预加载所有数据并非总是可行的解决方案:对于大量数据,我们可能没有足够的内存,或者碎片可能是问题。此外,我们可能事先不知道我们需要哪些文件。总的来说,即使预加载对某个项目有效,我们仍然希望避免它,以便尽可能少地使用内存,因为这样通常运行速度更快。这就是我们需要另外两种方法来处理同步代码问题的原因,我们现在将讨论这些方法。
2. 重构代码以使其异步
第二种方法是重构代码以将同步代码转换为异步代码。Emscripten 提供了可用于此目的的异步 API,例如,上面示例中的**fread()** 可以替换为异步网络下载(emscripten_async_wget, emscripten_async_wget_data),或本地缓存数据在 IndexedDB 中的异步访问(emscripten_idb_async_load, emscripten_idb_async_store 等)。
如果您有执行文件系统访问之外的其他操作的同步代码,例如渲染,Emscripten 提供了一个通用的 API 来执行异步回调(emscripten_async_call)。对于应从浏览器的事件循环中每帧调用一次的主循环的常见情况,Emscripten 有一个主循环 API(emscripten_set_main_loop 等)。
具体来说,**fread()** 将被类似以下内容替换:
emscripten_async_wget_data("filename.txt", 0, onLoad, onError);
其中第一个参数是远程服务器上的文件名,然后是一个可选的 void* 参数(将传递给回调),然后是加载和错误时的回调。棘手的是,应该在 fread() 之后执行的代码需要放在 onLoad 回调中——这就是重构的来源。有时这很容易做到,但可能并非如此。
将代码重构为异步通常是**最佳**做法。它使您的应用程序以其预期的方式使用 Web 上可用的 API。但是,它确实需要更改您的项目,并且可能需要以事件友好的方式设计整个项目,如果项目尚未以这种方式构建,这可能会很困难。出于这些原因,Emscripten 还有另一种方法可以帮助您解决此问题。
3. Emterpreter:自动将同步代码异步化
**Emterpreter** 是 Emscripten 中一个相当新的选项,最初是出于启动时间原因 而开发的。它将您的代码编译成**二进制字节码**,并将其与一个小型**解释器**(当然是用 JavaScript 编写的)一起提供,代码可以在其中执行。在解释器中运行的代码由我们“手动执行”,因此我们可以比普通 JavaScript 更轻松地控制它,并且可以添加暂停和恢复的功能,这正是我们将同步代码转换为异步代码所需的。因此,**Emterpreter-Async**(Emterpreter 加上对异步运行同步代码的支持)在现有的 Emterpreter 选项之上添加起来相当容易。
自动将同步代码转换为异步代码的想法在 2014 年夏天 Lu Wang 实习期间进行了试验:Asyncify 选项。Asyncify 在 LLVM 级别重写代码以支持暂停和恢复执行:您编写同步代码,编译器将其重写为异步运行。回到之前 fread() 的示例,Asyncify 将自动在该调用周围拆分函数,并将调用后的代码放入回调函数中——基本上,它做了我们在上面“重构代码以使其异步”部分中建议您手动执行的操作。这可以非常有效:例如,Lu 移植了 vim,这是一个包含大量同步代码的大型应用程序,到 Web 上。并且它可以工作!但是,由于 Asyncify 如何重组您的代码,我们在代码大小方面遇到了重大限制。
Emterpreter 的异步支持避免了 Asyncify 遇到的代码大小问题,因为它是一个运行字节码的解释器:字节码的大小始终相同(实际上,比 asm.js 小),我们可以在解释器中手动操作其控制流,而无需检测代码。
当然,在解释器中运行可能会非常慢,这个也不例外——速度可能比平时慢得多。因此,这不是您希望在其中运行大部分代码的模式。但是,Emterpreter 使您可以选择决定代码库的哪些部分是解释的,哪些部分不是解释的,这对有效使用此选项至关重要,我们现在将看到这一点。
让我们通过在 Doom 代码库上实践中展示此选项来具体说明。这是一个Doom 的普通移植(特别是 Boon:带有 Freedoom 开放艺术资产的 Doom 代码)。该链接只是使用 Emscripten 编译的 Doom,尚未使用同步代码或 Emterpreter。在该链接中,游戏看起来可以工作——我们还需要其他东西吗?事实证明,我们在 Doom 中需要在两个地方进行同步执行:首先,用于文件系统访问。由于 Doom 来自 1993 年,因此与今天的硬件相比,游戏的大小非常小。我们可以预加载所有数据文件,一切正常(这就是该链接中发生的事情)。到目前为止,一切顺利!
但是,第二个问题比较棘手:在大多数情况下,Doom 在主循环的每次迭代中渲染整个帧(我们可以从浏览器的事件循环中一次调用一个),但是它还使用同步代码执行一些视觉效果。这些效果在第一个链接中没有显示——Doom 粉丝可能已经注意到缺少了一些东西!:)
这是启用了 Emterpreter-Async 选项的构建。这会将整个应用程序作为字节码在解释器中运行,正如预期的那样,它非常慢。暂时忽略速度,您可能会注意到,当您启动游戏时,在您开始玩之前有一个“擦除**”效果,这在之前的版本中不存在。它看起来有点像下降的波浪。这是一个截图
该效果是用同步方式编写的(注意屏幕更新和睡眠)。结果是在游戏的初始移植中,擦除效果代码被执行,但 JavaScript 帧尚未结束,因此没有发生渲染。因此,我们在第一个版本中没有看到擦除!但是,我们在第二个版本中**确实**看到了它,因为我们启用了 Emterpreter-Async 选项,它支持同步代码。
第二个版本很慢。我们能做什么?Emterpreter 允许您决定哪些代码以全速 asm.js 正常运行,哪些代码被解释。我们只想在解释器中运行我们绝对必须运行的内容,其他所有内容都在 asm.js 中运行,这样速度尽可能快。**出于同步代码的目的,我们必须解释的代码是在同步操作期间位于堆栈上的任何内容。**要理解这意味着什么,请想象一下当前调用堆栈如下所示
main() => D_DoomMain() => D_Display() => D_Wipe() => I_uSleep()
并且最后一个执行了对 sleep 的调用。然后,Emterpreter 通过保存当前方法中执行的位置将此同步操作转换为异步操作(这使用解释器的 程序计数器 很容易做到,以及由于所有局部变量都已存储在全局类型化数组上的堆栈中),然后对调用它的方法执行相同的操作,并在执行此操作时退出所有方法(这也很容易,每次对解释器的调用都是对 JavaScript 方法的调用,该方法只是返回)。之后,我们可以对何时恢复进行 setTimeout()。到目前为止,我们已经保存了我们正在做什么,停止了,为将来的某个时间设置了异步回调,然后我们可以将控制权返回给浏览器的事件循环,以便它可以渲染等等。
当异步回调在稍后某个时间触发时,我们反转过程的第一部分:我们调用解释器以获取 main(),跳转到其中的正确位置,然后继续对其余调用堆栈执行此操作——基本上,完全重新创建之前的调用堆栈。此时,我们可以在解释器中恢复执行,这就像我们从未离开过一样:**同步执行已变为异步。**
这意味着如果 D_Wipe() 执行同步操作,则必须对其进行解释,以及任何可以调用它的内容,依此类推,递归地。好消息是,此类代码通常很小并且不需要很快:它通常是事件循环处理代码,而不是实际执行繁重工作的代码。抽象地说,在游戏中通常会看到这样的调用堆栈
main() => MainLoop() => RunTasks() => PhysicsTask() => HardWork()
和
main() => MainLoop() => RunTasks() => IOTask() => LoadFile()
假设 LoadFile() 对文件执行同步读取,则必须对其进行解释。如上所述,这意味着任何可能与它一起位于堆栈上的内容也必须进行解释:main()、MainLoop()、RunTasks() 和 IOTask()——但不是任何物理方法。换句话说,如果您从未同时在堆栈上使用物理和网络(网络事件调用最终调用物理的内容,或物理事件突然决定发出网络请求),那么您可以在解释器中运行网络,并在全速下运行物理。在 Doom 中以及其他现实世界的代码库中(甚至在棘手的代码库中,例如 Em-DOSBox,它在一个关键方法中具有递归,有时可以找到解决方案)都是如此。
这里有一个启用了该优化的 Doom 版本 – 它只解释我们必须解释的内容。它的运行速度与原始的优化版本大致相同,并且擦除效果也完全正常。此外,擦除效果非常流畅,之前则不然:即使擦除方法本身必须被解释 – 因为它调用了 sleep() – 但它在休眠之间调用的渲染代码可以全速运行,因为该渲染代码在休眠期间永远不会位于栈上!
为了使同步代码在项目保持全速运行的同时正常工作,在解释器中运行完全正确的方法至关重要。这里列出了我们在 Doom 中需要的那些方法(在“白名单”选项中) – 仅 15 个中的 1,425 个,或约 1%。为了帮助您找到项目列表,Emterpreter 提供了静态和动态工具,请参阅文档以获取更多详细信息。
结论
Emscripten 通常用于移植包含同步部分的代码,但 Web 上无法执行长时间运行的同步代码。如本文所述,处理这种情况有三种方法。
- 如果同步代码只是执行文件访问,那么预加载所有内容是一个简单的解决方案。
- 但是,如果数据量很大,或者您事先不知道需要什么,这可能效果不佳。另一种选择是重构您的代码使其异步。
- 如果这两种方法都不适用,可能是因为重构工作量过大,那么 Emscripten 现在提供了Emterpreter选项,可以在解释器中运行代码库的部分内容,该解释器确实支持同步执行。
总而言之,这些方法为处理同步代码提供了一系列选项,尤其是常见的同步文件系统访问情况。
关于 Alon Zakai
Alon 是 Mozilla 研究团队的成员,主要从事 Emscripten 的工作,Emscripten 是一种从 C 和 C++ 编译到 JavaScript 的编译器。Alon 于 2010 年创立了 Emscripten 项目。
2 条评论