作者:曹俊跃
1. 前言
随着 5G 时代的到来,多媒体音视频、特效在 5G 时代下会迎来新的云端的挑战,同时也会催生更多的跟云端相关的业务场景。因此我们需要规划云端的建设,把音视频和特效在移动端积累的能力和经验复制扩展到云端,在云端全面打磨提升音视频能力,支持公司更多业务创造更大价值。同时随着 WebAssembly 的逐步成熟,也给了我们将编辑 SDK 放到 Web 运行的机会。
本文首先帮大家回顾一下 WebAssembly 的一些基本情况,然后将介绍Web视频编辑所依赖的关键特性的情况,以及再迁移过程中我们遇到的问题和解决思路。
图 1. Web 视频编辑器示意图
2. 技术选型 - WebAssembly
由于编辑 SDK 是采用 C++ 编写而成,因此在早期探索 Web 端视频编辑的过程中对于是基于 JavaScript 重写还是使用 WebAssembly 技术,也对两种不同的技术选型进行了一些调研与分析,例如在兼容性是否满足需求、与 JavaScript 方案对比的优势和劣势等。
2.1 WebAssembly 回顾
关于WebAssembly的发展历史和技术演进,可以参考课程前面的章节,这里引用一张图来再次总结一下这项技术的发展时间线,参考下图 2 所示。
图 2. WebAssembly 发展时间线
随着2015年 WebAssembly 的标准开始推进以来,浏览器对于 WebAssembly 标准的支持越来越完善,以 Chrome 为例, 根据 caniuse 和 Chrome Platform Status [3] 上的数据:
WebAssembly(Chrome 57)==> SharedArrayBuffer (Chrome 68) ==> 多线程(Chrome 71)==> SIMD (Chrome 91)==> 异常捕获(Chrome95)
到目前为止,Chrome 上的 WebAssembly 多线程应用的可用性已经相当高了。有许多我们熟知的软件通过 WebAssembly 搬到了 Web 上或者利用 WebAssembly 技术提升可用性和性能,例如 Adobe PS[4],Adobe Acrobat[5],pspdfkit[6], Figma[7], Google Earch[8],QT[9] 这里不再展开,相关内容请参见课程 "WebAssembly 使用场景和未来发展趋势" 章节内容。
2.2 浏览器兼容性如何?
基本功能
目前主流浏览器都支持基本的 WebAssembly 功能。兼容性最好的为 Chrome 和同内核的 Edge ,其次是 Firefox,如下图 3 所示,完整版列表参考文档[10]所述。
图 3. WebAssembly 浏览器兼容性概况
高级功能
截至2022年底,WebAssembly 的关键功能如多线程、异常、类型推导、SIMD 等能力已经比较完善,详细信息参见文档[11]所述。
图 4. WebAssembly 高级功能兼容性情况
2.3 JavaScript 方案 vs WebAssembly 方案
Web 视频编辑目前主要有两个方向,第一种是使用原生 JS,基于浏览器提供的 、、 以及 WebGL 等技术,通过控制多个视频的播放来实现多轨视频的排列与预览;第二种是直接使用 WebAssembly 将现有基于 C/C++ 等代码的视频编辑框架编译到浏览器上运行。两种方案的优缺点简单分析如下:
JavaScript | WebAssembly | |
---|---|---|
视频解码 | 基于 video 标签、WebAudio、WebCodecs 及 MSE 等前端技术 | 可接入使用 WebCodecs 解码、同时支持 FFMPEG 解码器兜底 |
渲染 | WebGL 渲染视频纹理,或者直接基于 Canvas 2D 实现绘制 | YUV、VideoFrame 等转纹理,基于 OpenGL 接口(底层依然是WebGL)渲染,整体上两种方案单看渲染性能应该是接近的 |
编码导出 | 两者差不多,主要有四种导出方案 1. 依赖 WebAssembly 做软编 2. WebCodecs 的 VideoEncoder 3. captureStream(耗时与视频时长相同) 4. 云端编码导出 | |
性能 | 基于硬解码,性能较好,容易实现高清编辑。但是支持的文件格式有限。且只能使用基于 WebGL 的特效或者计算的效果 | 基于 WebCodecs、软解码兜底,渲染性能由于也是基于 WebGL,基本上与 JavaScript 相当,但是得益于计算能力,可以实现更多样的特效。而如果基于软解的话,有较大的性能差距 |
包体积 | 大部分为调用浏览器能力,一般来说框架层面的包体积优势较大 | 由于代码库较为庞大,且许多能力为自研代码库,因此在包体积上劣势较大 |
特效 | 需要使用 WebGL 实现 | 可复用字节积累的特效 |
复杂AI算法 | JavaScript 性能不足,较难实现,或者也需要在 JavaScript 方案中采用 wasm 集成 | 适配目前公司内部的算法以及集成成本较低,大量算法在特效 SDK 或者编辑 SDK 中都有集成过,无需重复集成 |
兼容性 | 较新版本主流浏览器都支持 | 支持程度没有 JavaScript 版好,目前主流浏览器都支持了多线程,但是 OffscreenCanvas 目前还仅有 Chrome 和 Edge 支持,需要进行一定的渲染线程改造,理论上可以支持主流桌面端浏览器 |
研发模式&其他 | 单独研发流程,纯前端工程 不考虑多端的话,研发效率相比 C++ 研发相对更高,部署成本也比较低 | 可复用目前的研发资源,包括一些跨端需求,可以移植到 Web,降低重复开发的效率(例如可以移植和搬运剪映的能力) |
生态 | 一般需要自建素材生态或者去适配少量的已有生态(效果对齐也有较大成本) | 能够实现复用现有特效 SDK 的特效资源库。 支持复用的生态包括:特效资源库、剪同款模板、模板内容生态库、业务跨端草稿。 |
表 1. JavaScript 和 WebAssembly 方案的对比
3. WebAssembly 特性与编辑 SDK 移植
WebAssembly 起源于 Web,但实际上由于浏览器的诸多限制,在许多对于原生操作系统非常常见的能力要在Web上使用依然是不容易的,需要做相对应的改造和适配。其中多线程机制、图形接口部分(OpenGL)、文件系统、网络连接、音频播放接口等与 native 平台差异有比较大的差异,Emscripten 官方文档的移植部分[12]详细介绍了这些差异。本小节将从多线程机制、图形接口部分(OpenGL)、文件系统这几个方面进行详细地介绍。
3.1 多线程
首先第一个问题就是多线程,一个复杂的应用往往都是基于多线程来开发的,或者会使用到多线程来提升处理速度或者提高UI流畅度。在浏览器上,UI都是运行在主线程中,JS 的运行环境也是运行在主线程中,而浏览器异步机制则是基于浏览器的事件循环(Event loop)的,实际上也是运行在一个单线程环境中。为了提升浏览器异步处理的能力,减少对UI线程的干扰,出现了 Web Worker[13]。
3.1.1 浏览器里的并发机制: Web Worker
Web Workers 使得一个 Web 应用程序可以在与主执行线程分离的后台线程中运行一个脚本操作。这样做的好处是可以在一个单独的线程中执行费时的处理任务,从而允许主(通常是 UI )线程运行而不被阻塞。
使用构造函数(例如, Worker()
)创建一个 worker 对象, 构造函数接受一个 JavaScript 文件 URL — 这个文件包含了将在 worker 线程中运行的代码。Worker 将运行在与当前 window 不同的另一个全局上下文中。主线程和 worker 线程相互之间使用 postMessage()
方法来发送信息, 并且通过 onMessage
这个 event handler
来接收信息(传递的信息包含在 Message 这个事件的 data 属性内) 。数据的交互方式为传递副本,而不是直接共享数据。
3.1.2 WebAssembly 与多线程
多线程支持并不在 WebAssembly 最初标准提案中,截止到目前为止,多线程的标准[14]已经在 Chrome 中实现并默认启用[15]。这就意味着我们可以在 WebAssembly 中使用多线程的API来使用线程来实现更广泛的应用。由于线程支持的实现最终都要依赖于 WebAssembly 标准的实现方(Web 平台就是浏览器了),因此我们也必须要知道由于浏览器的特殊性,多线程在浏览器中也是和原生操作系统线程差别很大。
WebAssembly 的多线程标准主要包含三个方面:
- 共享线性内存( SharedArrayBuffer 、 SAB)
共享线性内存( SharedArrayBuffer )[16]是指多个线程间能够同时访问同一『块』内存区域,以此实现线程间的内存共享访问。
- 原子操作( Atomics )
写过多线程代码的程序员都知道,当多个线程同时访问同一块内存时,有可能导致运行错误,因此,必须要有一个原子操作来保证两个线程不会同时写同一块内存地址,原子操作就是用来干这个事的。
- Wait / notify操作( Atomics.wait/notify )
Wait / notify操作提供线程阻塞和唤醒的机制,也是一套多线程模型必备的机制。
图 5. WebAssembly 多线程模型示意图(浏览器环境)
Emscripten 实现了一套基于 Web Worker 的准 posix 标准的多线程运行环境,之所以叫准 posix 标准,是因为有一些行为和标准不一致[17],但在大部分时候,可以当做一个普通的线程来使用。通过 pthread_create
启动一个线程的流程如下图所示
图 6. Emscripten 线程启动顺序(浏览器环境)
这里解释一下 ① 和 ② 两处。
①:启动线程时会检查是否有空闲 Worker,如果发现没有的话需要创建新的 Worker,由于浏览器的 Worker 与主线程的环境隔离机制,这个新的 Worker 是没有 WebAssembly 运行环境的,因此必须执行一遍主 JS,执行完成后我们才能使用 Emscripten 提供的 API。也正是因为这个机制,导致了一些机制在多线程环境下无法使用,例如通过 addFunction
动态创建的函数指针,是无法直接在其他线程中访问到的。
②:如果使用了基于打包的 data 文件的虚拟文件系统,在我们加载主 JavaScript 文件时,会自动发送一个异步网络请求,下载 data 文件,并且加载到内存中;但是如果编译配置中没有配置这个 data 文件,那就不需要请求了。这个过程是异步的,启动线程时并不会等待这个网络请求完全结束。当加载完成后,当前这个线程就可以读写文件系统了。
这里有一个坑,以下操作会引起浏览器连接被占满:
- 当多线程启动 Worker 时,XHR 会发出网络请求,此时网络还未返回
- 线程已经开始执行,如果此时在线程中阻塞自己(例如调用wait等操作),那么发出的 XHR 的请求结果将永远没有机会处理
- 由于浏览器对于同域名的请求连接都有上限( Chrome 默认6个),这种行为将导致连接被占满,导致浏览器再也无法请求这个域名。
3.1.3 线程Main Loop
传统的线程使用的模型中,除非是一个短暂运行一段代码后自动退出的线程,线程启动后通常会使用 busy-waiting 的模型,这种模型意味着会在当前线程进行 wait(例如调用 pthread_cond_wait
),这种模型在如果在主线程跑的话,将导致整个 UI 的无响应。emscripten 提供了另一套无阻塞模型,将 while
循环替换成 emscripten_set_main_loop
[18],它的原理实际上就是使用 setTimeout
或者 requestAnimationFrame
重复调用线程入口函数。从而在每次执行完入口函数后释放控制权给浏览器,以支持在这个 worker 中调用浏览器的异步接口和进行回调。
3.1.4 Promisify接口线程模型
由于编辑 SDK 的许多接口都是使用同步的方式来实现的,也就是说在原有的编辑 SDK 接口设计中,存在许多的接口,它们都在代码中同步等待某个处理结果,从而导致上述的死锁问题经常发生。因此我们将 JavaScript 层接口都设计为基于 Promise 的方案。对于一次接口请求,大致的流程如下:
-
用户对浏览器进行操作
-
调用编辑 SDK 的 JavaScript 层 API,此时返回一个 Promise
-
JavaScript 层 API 将执行至 C++ 层接口 API,创建线程
-
在线程中,执行真正的 C++ 的逻辑,此时代码可能包含同步等待,但是由于不在主线程了,因此不再导致死锁
-
C++ 代码执行完毕后,将执行结果返回至执行 C++ 层接口 API 处
-
C++ 接口API,将结果通过 postMessage 的形式,发送至浏览器主线程
-
主线程将 Promise 进行 resolve,从而调用方能够知道执行结果
-
根据执行结果,展示给用户
另外,为了防止同时调用两个接口导致底层状态出错,我们还设计了一套串行调用机制,通过适用 busy 和 free 两种状态,在 JavaScript 层每个 API 开始执行时将状态置为 busy,结果返回并 resolve 之后,置为 free。这样可以避免由于底层都是在子线程执行代码导致的时序错乱出现多线程竞争的问题。
3.1.5 避免实时创建 worker
Emscripten 中,线程是运行在 Worker 中的,而创建 Worker 的代价非常大,所以我们要避免在代码中实时创建 Worker ,而必须于预先创建好 Worker 作为线程池。
前面介绍的-s PTHREAD_POOL_SIZE=30
参数可以在初始化之后,预创建30个 Worker。
以下是开启和关闭线程池的数据对比
图 7. Worker 池对线程启动性能的影响
从对比可以看出,不启用 Worker 池的情况下,启动一个线程耗时高达 250ms,而启用线程池的情况下,耗时几乎可以忽略不计,只有 1ms。这是一个巨大的差距,因此如果在渲染过程中启动线程,而没有开启 Worker 池的话,将会导致创建 Worker ,进而导致画面卡顿。
3.1.6 关于多线程应用的一些 Tips
-
线程不是越多越好,每个线程都存在系统开销和不小的内存开销(特别是 wasm 代码庞大的情况下).
-
创建 thread 很快,但是创建 Worker 非常慢,注意在 Worker 池数量和内存开销之间找到一个平衡,另外尽量精简线程数量
-
如果需要在子线程中使用 OpenGL,需要使用
emscripten_set_main_loop_arg
之类的启动一个mainLoop
,while
死循环是无法渲染上屏的。 -
mainLoop
中尽量一次批量执行 task,并且保证 task 粒度更小,并且在mainLoop
中设置 16ms(假设是基于 rAF 实现的 loop)的超时时间。可最大限度提高吞吐量并且不影响性能。 -
如果每次
mainLoop
可以跑满,使用 fps 为 0 作为mainLoop
参数,用requestAnimationFrame
作为mainLoop
驱动可最大限度提高吞吐量。setTimeout
有 4ms 的间隙无法运行代码。 -
如果 task 数量较少,使用
setTimeout
模式的mainloop
。下面的代码是一个mainLoop
模型的伪代码示意。
// Mainloop 伪代码示意
void mainLoop() {
double startTime = emscripten_performance_now();
while (true) {
double curTime = emscripten_performance_now();
if (curTime - startTime > 16) {
// return control to browser every 16 ms for gl draws
break;
}
auto task = mTaskQueue.take();
if (task != nullptr) {
task.run();
} else {
// we will get back to mainLoop next round
break;
}
}
}
3.2 OpenGL 与图形渲染
OpenGL 是目前最广泛使用的图形 API,一般来说在桌面程序中由操作系统或者显卡驱动程序会提供 OpenGL 的接口与环境给到应用程序,而在 Web 端来说这套图形接口是 JavaScript 版本的 WebGL。本节将介绍在 WebAssembly 应用中的图形接口进行图形渲染。
3.2.1 WebGL 与 OpenGL ES
WebGL 是一种 JavaScript API,用于在不使用插件的情况下在任何兼容的网页浏览器中呈现交互式 2D 和 3D 图形。WebGL 完全集成到浏览器的所有网页标准中,可将影像处理和效果的 GPU 加速使用方式当做网页 Canvas 的一部分。WebGL 元素可以加入其他 HTML 元素之中并与网页或网页背景的其他部分混合。
OpenGL ES(OpenGL for Embedded Systems) 是三维图形应用程序接口 OpenGL 的子集,针对手机、PDA和游戏主机等嵌入式设备而设计。OpenGL ES 是从 OpenGL 裁剪定制而来的,去除了 glBegin/glEnd
,四边形(GL_QUADS)、多边形(GL_POLYGONS)等复杂图元等许多非绝对必要的特性。OpenGL ES 提供 C 的 API 接口,Android 提供了 Java 的包装,iOS 提供了 obj-c 的包装。关于 OpenGL ES 和 WebGL 的关系,可以参考如下的示意图。
图 8. WebGL 和 OpenGL 的关系示意图
WebGL 1.0 基于 OpenGL ES 2.0,提供了 3D 图形的 API,并且基于浏览器等复杂的运行环境做了适配。它使用 HTML5 Canvas 并允许利用文档对象模型接口。
WebGL 2.0 基于 OpenGL ES 3.0,确保了提供许多选择性的 WebGL 1.0 扩展,并引入新的 API。可利用部分 JavaScript 实现自动存储器管理。
Emscripten 基于 WebGL 实现了一套 OpenGL ES 2.0 和 3.0 的 API,C++ 中对于 OpenGL 的调用可以直接使用。但是由于 WebGL 和 OpenGL ES 并不是完全相同的[19],emscripten 也提供了一套完整支持 OpenGL ES 的模拟(封装)代码(性能差一些),可以通过 -sFULL_ES2
和 -sFULL_ES3
启用。
3.2.2 浏览器的限制
-
线程 (Worker) 之间无法共享 GL 资源(如纹理),导致在移植过程中,必须修改一些底层线程共享 GL 资源的代码。
-
模拟 EGL 环境问题较多,移植过程中 EGL 环境相关处理代码可能需要修改。
-
glGet
操作在浏览器上很慢,不要在主渲染流程中进行glGet
操作 -
WEBGL 性能影响因素
-
GPU 方面,可期待获得接近本机应用程序的性能,因为 WebGL 图形 API 使用 GPU 进行硬件加速渲染:将 WebGL API 调用和着色器转换为操作系统图形 API(通常为 Windows 上的 DirectX 或者 Mac 或 Linux 上的 OpenGL)只需要少量开销。
-
CPU 方面,Emscripten 会将您的代码转换为 WebAssembly,因此性能取决于 Web 浏览器。
-
JavaScript 语言不支持类似 C/C++ 的多线程复杂场景,只能使用 worker 模拟简单的多线程场景。
-
JavaScript 不支持 SIMD,一些计算的速率会降低。
-
部分 Shader 语法不支持:WebAssembly-WebGL 坑(持续更新中)
-
浏览器兼容性,总体上大部分浏览器版本都支持,但是也存在少量浏览器不支持 WebGL2 的情况。使用前需要判断 WebGL2 是否支持
-
WebGL1
图 9. WebGL 1.0 兼容性情况
- WebGL2
图 10. WebGL 2.0 兼容性情况
3.2.3 子线程渲染与OffscreenCanvas
渲染线程是一个特殊的线程,它必须使用 Main loop
模式,不可阻塞。在 Android 等 native 平台上,带 OpenGL 环境的渲染线程可能有多个,每个线程都可以做纹理加载、绘制等操作,但是在 Web 平台,一个Canvas只允许在一个线程中使用,包括以下两种情况:
-
主线程,这种情况可以直接使用这个 canvas 获取
webgl context
-
非主线程,在这种情况下,只能将 canvas 转为 OffscreenCanvas,然后发送到 Worker 中使用。并且创建 context 之后无法再次转移。
为了释放主线程压力,编辑 SDK 将渲染放到了子线程,这就意味着需要依赖浏览器的 OffscreenCanvas[20] 的能力,要指定离屏渲染的可能会用到以下选项
-
-s USE_PTHREADS=1
-
-s OFFSCREENCANVAS_SUPPORT=1
指定离屏渲染的canvas可以通过 pthread_create
创建渲染线程时指定 emscripten_pthread_attr_settransferredcanvases
的参数,示例代码如下
pthread_attr_t attr;
pthread_attr_init(&attr);
emscripten_pthread_attr_settransferredcanvases(&attr, mCanvasName.c_str());
ret = pthread_create(&thread, &attr, func, arg);
pthread_attr_destroy(&attr);
但是,OffscreenCanvas 的兼容性较差。 截至目前,FireFox 22 年 9 月的 105 版本才支持,Safari 至今不支持。
图 11. OffscreenCanvas 兼容性情况
3.2.4 窗口( Canvas )绑定
方式 1. EGL:
EGL 是用于绑定窗口系统和 OpenGL 操作的一套接口,emscripten 提供了比较完善的 EGL 的 API 支持,如果代码中已经使用了 EGL 进行窗口调用,可以在传递 OffscreenCanvas 之后直接使用 EGL 接口创建上下文。
方式 2. html5.h 接口:
Emscripten 提供了直接基于 html5 接口的 canvas 绑定接口,更加简洁,推荐使用。
#include <emscripten/html5.h>
// 创建
EmscriptenWebGLContextAttributes attr;
emscripten_webgl_init_context_attributes(&attr);
attr.explicitSwapControl = EM_FALSE;
attr.majorVersion = 2;
attr.minorVersion = 0;
mWebglCtx = emscripten_webgl_create_context("#canvas", &attr);
emscripten_webgl_make_context_current(mWebglCtx);
// 销毁
emscripten_webgl_make_context_current(0);
3.2.5 渲染帧率优化
在我们探索 Web 编辑早期,帧率一直是我们比较头疼的事,由于对整体性能优化掌握的不够,我们的帧率长期低于 15 帧,经常播放过程中出现卡顿线程。在经过长时间的尝试和调研之后,经过优化,我们最终在预览上能够做到 30FPS 满帧,我们总结了一些性能优化的思路。
3.2.5.1 解码优化
在解码上,由于目前使用的都是软编码,在解码速度上并不是非常好,CPU 占用也较高,在解码上我们主要是在解码线程上经历了单线程->多线程->双线程
的过程。这主要是因为我们解码视频的分辨率并不是很高,使用过多线程并不会有更好的解码速度。双线程在解码效率和 CPU 占用上能够取的较好的平衡。
另外一个在解码上能够大幅提高性能的是 SIMD[21]。由于 FFmpeg的指令优化都是写的机器汇编,而不是 intrinsics 接口,因此我们没办法直接使用 Emscripten 提供的移植能力。所以只能自己用 wasm 的 SIMD 指令实现,我们基于 FFmpeg 对 H264 解码器进行了 SIMD 优化的尝试,大约提升解码速度 30%-50%。
3.2.5.2 GL 渲染性能
GL 调用的性能很大程度上决定了整个渲染的性能。在使用 OpenGL 的上,有一些常见提升性能的方式,这些在各个平台上基本上是可以通用的,例如:
-
避免不必要的调用
-
缓存 GL 状态,避免在不需要改变的时候多次调用同一个设置函数
-
尽量避免每次执行完成之后的【重置状态】操作
-
针对于绑定纹理等操作,如果两次
draw call
用的是同样的纹理,就没有必要重新设置了 -
使用 VAO 避免重复调用 GL 函数来构造顶点属性
-
批处理
glUniform*
,例如使用glUniform4fv()
来直接设置多个 float,而不是调用多次glUniform4f()
-
不要在运行时调用
glGetUniformLocation()
之类的东西,应该在 program 编译好之后就获取,然后缓存 -
避免 GPU-CPU 数据交换
-
避免在渲染过程中创建资源,例如
glGen*()
和glCreate*()
之类的 -
同样,避免在渲染过程中删除资源,例如
glDelete*()
-
不要在渲染过程中调用
glGetError()
和glCheckFramebufferStatus()
,他们都需要等待 pipeline 的完全同步 -
同样地,也不要在渲染时调用
glGet*()
-
避免在渲染时创建 shader 和编译 program
-
避免在渲染过程中调用
glReadPixels
来拷贝纹理到内存里,如果支持的话,可以考虑用 GL_PIXEL_PACK_BUFFER 先将内容拷贝到离屏目标上,然后再去读取到内存 -
相对于多个 VBO 保存的 plarnar 数据来说,使用交错的单一 VBO 效率更高
3.2.5.3 如何找到渲染耗时瓶颈?
利用 Web 平台 JavaScript 的动态语言天然优势,我们在 JavaScipt 代码中可以很容易的替换一个函数的实现。而 C++ 的 OpenGL 调用,实际上最终都会走到 Emscripten 编译的 JavaScript 文件的 WebGL 调用中,因此我们对 JavaScript 文件中的所有 OpenGL 调用进行耗时统计,一旦发现执行时间过长的问题,就通过日志显示在控制台中。渲染耗时问题无处遁形,非常方便我们做性能优化。
图 12. WebGL调用耗时操作堆栈示意
当然这个思路也可以应用到其他平台,例如 Android 等(需要 hook 所有 GL 调用)。
将渲染链路里的这些耗时调用都优化之后,单帧耗时降低 80% 以上(对比版本:两个视频轨道,无特效的情况),同时渲染耗时的方差降低 500 倍,这表明渲染耗时更加稳定,而不会出现突然有一些帧的渲染变得非常慢的情况。从数据可以看出,GL 渲染对帧率有决定性的作用。
图 13. glGet耗时操作优化效果对比
3.2.5.4 避免依赖主线程
在 Web 中,有一些操作必须在主线程中完成;另外,为了在线程间共享数据(例如文件系统),就必须将一些资源放在主线程中管理。这种情况下,在子线程中调用这部分功能将导致线程间的同步,从而影响性能。
下图显示的主线程的调用情况,可以看出主线程非常繁忙
图 14. 主线程调用情况
下图抓取的函数时序,其中,fopen
,fread
,printf
都有一部分代码需要在主线程执行。
图 15. fopen 调用链路
这里举几个在渲染线程可能出现的主线程同步的接口调用:
- 文件读写
由于各个线程间共享一套文件系统,因此文件的真正的读写操作都是在浏览器主线程完成的,这意味着任何一个文件 IO 操作都涉及到与主线程的同步,因此,应尽量避免在渲染线程中调用文件 IO。
- 日志
如果用 printf
来打印日志,需要用到一个缓冲区,这个缓冲区是在主线程管理的。因此,用 printf
打印的每一条日志都需要与主线程同步,而且不同线程同时打印日志会出现串行等问题,使用 cout
也是同理。在 Web 上,我们应该直接调用 JavaScript 的 console.log
的 API 进行日志输出,这个 API 直接调用浏览器的日志实现,不需要切换线程,性能远远高于使用 printf
来打印日志。emscripten 提供了 emscripten_log
接口方便我们在 C/C++ 代码中使用,它就是直接基于 console.log
来输出的。
- 音频播放 OpenAL
由于音频播放接口 OpenAL
是基于 Web Audio 的 API 来构建的,其实际播放也是在主线程。因此,如果使用 OpenAL
进行音频播放,也是会依赖主线程的。目前 W3C 在推进 Worker 中的 WebAudio 接口,但还未落地。
3.2.5.5 减轻主线程UI渲染负担
由于还是有一些不可避免要与主线程通信的接口的调用,从另一个思路来看,尽量减轻主线程负担也可以提高我们的运行速度,避免因为主线程过重的 UI 负担导致其他子线程运行速度的显著变慢。
3.3 文件系统
由于浏览器的特殊性,其内部并没有真正的文件系统,JavaScript 对文件的的访问实际上是运行在一个沙盒环境下的,无法访问宿主的文件系统。并且,JS里面只允许异步读取文件,而一般在 C/C++ 里面,都是直接用系统文件 API 同步读取文件的。Emscripten 提供了一套虚拟文件系统的实现,C 和 C++ 代码中使用文件系统时可以几乎不用改动 API 的使用。
图 16. Emscripten文件系统架构(官方文档)
3.3.1 文件系统存在的问题
Emscripten 目前的使用的文件系统主体是由 JavaScript 实现的,因此在多线程应用中,所有的文件读写都需要 proxy 到主线程,这导致了主线程需要处理大量的文件读写操作,对 UI 流畅性有不小的影响。目前我们在文件 IO 这部分,主要分为两部分:
-
使用 Posix 的标准
FILE
接口进行读写,这部分存量代码由于其跨平台特性,短时间内较难彻底移除 -
绕开 posix 标准。针对 FFmpeg 文件 IO ,我们重新设计了一版基于 Blob 读取的 IO 框架,绕过 FILE 接口,设计专门的 IO 线程,避免文件操作依赖主线程的问题。
图 17. Blob IO操作调用链路
随着 wasmfs [22]的推进与 OPFS [23]标准的上线,这个问题将得到很大的改善,它将文件管理移到了 C++ 层,从而充分利用 SharedArrayBuffer 的跨线程能力,避免了所有操作代理到主线程的问题,但是部分 Backend 的实际读写依然需要跨线程。
3.4 包大小优化
Web 端的 wasm 包是需要下载到浏览器中运行的,因此对于 wasm 产物的体积有比较严格的要求。而 Emscripten 提供了丰富的参数来控制功能、性能与包体积。对于 wasm 包大小的优化,一般来说主要思路可以从编译参数、压缩、代码裁剪等方面来进行。
3.4.1 编译参数
和普通的C++工程相似,从编译参数上,我们可以通过以下一些编译选项进行包大小的优化
去除DEBUG数据
-
--preload-file 提供的预先打包加载文件的参数,交付 release 版本可以完全去掉。
-
--source-map-base 提供的debug断点调试文件,交付 release 版本可以完全去掉
打包参数优化
-
-O 参数
-
-O0 没有任何的优化,这个推荐在一开始移植使用,因为可以兼容断言,编译速度最快。
-
-O1 进行了一些优化。 包含了 LLVM -O1 的优化,去掉了一些运行时断言,并且运行了 Binaryen optimizer。
-
-O2 包含 -O1 的优化,并且开启了 JavaScript optimizations ,原理是,不可达代码移除,需要格外注意的是,如果没有在 Module 里导出运行时的某些部分,则会删掉它。
-
-O3 包含 -O2 的一些优化,并且有一些附加优化,release 版本推荐使用这个等级。
-
-Os 包含 -O3 的优化,并且有一些附加优化,并且会权衡运行速度与大小。
-
-Oz 包含 -Os 的优化,并且有一些附加优化,可能运行时间会加长。
-
-g 参数
-
-g0 不保留所有调试信息。
-
-g1 JavaScript 中保留空格。
-
-g2 JavaScript 中保留函数名。
-
-g3 JavaScript 保留空格、函数名和 LLVM 的 debug 信息
-
-g4 生成 sourcemap,sourcemap 能让你在浏览器中 debug c/c++ 代码,缺憾是无法查看局部变量值。
-
ASYNCIFY
-
Asyncify 会带来包大小和运行速度的开销,会添加一部分代码允许堆栈展开和回溯,大约会带来 50% 的开销。
3.4.2 压缩
压缩是有效提高下载速度的方式,浏览器目前支持的主流压缩格式包括 gzip 和 brotli 两种。针对wasm包的压缩,brotli 算法有显著的优势。
-
gzip: 一种流行的压缩文件格式,能有效的降低文件大小。
-
brotli: Google 在 2015 年推出的一种压缩方式,相对于 Gzip 约有 20% 的压缩比提升,经过下图对比,最终选用了 brotli 压缩方式。
压缩方式(压缩比均为最高) | videoeditor.wasm | videoeditor.js | Editor.ts |
---|---|---|---|
gzip(压缩等级9) | 25.4MB->6.3MB | 216KB->61KB | 70KB->11KB |
brotli(压缩等级11) | 25.4MB->4.6MB | 216KB->52KB | 70KB->9KB |
表 2. 压缩方案效果对比
3.4.3 代码裁剪
这部分对于不同的业务来说,有很大的灵活空间,需要根据实际业务情况进行分析,这里不再深入讨论
-
对 FFmpeg 是全量的,进行了一些 DEMUXER、MUXER、ENCODER、DECODER、FILTER 的裁剪。
-
SDK 功能裁剪,根据具体业务需求进行
4. 开发体验与问题
虽然到目前为止,WebAssembly 在浏览器的调试上已经有可用的方案[24],但是总体的调试体验上还存在这比较大的问题
-
大型项目编译链接慢,特别是开启 -O2 及以上时,链接过程超过数分钟。而如果使用
-sERROR_ON_WASM_CHANGES_AFTER_LINK
来加速链接,那么编译出来的包体积庞大,超过 150M。 -
断点调试要求开启 -g3,但是在我们的项目中,这样编译出来的wasm包超过1G,打开直接OOM,完全无法断点。不过这一点随着技术迭代,已经有一些可行的解决方案了[25]。
-
编译 release 代码时,无法像 Android 等平台一样,同时产出一份符号表,供线上堆栈解析使用;目前仅有一个函数列表,无法定位到具体代码行。
4.2 IDE支持
由于我们项目是基于 CMake 的,目前像 VSCode 和 CLion 等 IDE 都能够很好地支持,最主要的就是需要配置好 Emscripten 的 toolchain 路径和一些编译宏,配好之后像代码跳转等常用功能,都可以很好地支持。
例如以下代码配置在CLion中(Settings-> Build, Execution, Deployment -> CMake):
-DCMAKE_TOOLCHAIN_FILE=/path/to/emscripten/cmake/Modules/Platform/Emscripten.cmake
-DCMAKE_CROSSCOMPILING_EMULATOR=
/path/to/emsdk/node/12.9.1_64bit/bin/node
5. 未来展望
5.1 前端合成
纯粹的前端软编码合成是代码能力支持的,但是在实际的技术调研中,耗时过长、所占资源过多等因素,前端合成还是比不上后端进行合成。并且一旦视频分辨率过大、时间过长、所占空间过大,前端合成成功率就会大大降低,用户的前端操作会卡顿,极其影响用户的体验。并且后端合成用户体验更佳,耗时更短,更具有想象力,也更符合 Web 这随时联网的平台特性,最终选择了后端合成为最终方案。但是随着浏览器 WebCodecs 等技术的成熟,在前端合成从技术角度来看已经没有太大的问题了。
5.2 协同编辑
如今的办公场景一直朝着多人协同的方向发展,各种在线文档和多人协作的工具层出不穷,而在传统视频编辑领域由于素材的大小以及项目规模的限制,动辄几个 G 的素材文件想要在多地实时同步也是一个难题,而 Web 在这方面有天然的优势,我们未来也会逐步去探索评论、批注、实时协同编辑等能力。
5.3 新的 WASM 特性
对于 WebAssembly 技术,从目前来看虽然已经能够满足我们进行移植,但是无论从开发体验到运行性能以及特性支持上都还有许多值得期待的发展空间。例如更好的底层多线程支持、更完善的 SIMD 指令集、更好的动态链接机制、更好的开发调试体验、更好的浏览器接口集成调用方案等等。
6. 总结
随着浏览器的跟进,以及我们的产品迭代,WebAssembly 正在改变 Web 平台的现状,也极大拓展了 Web 平台的能力范围,特别是许多年来积累的 C/C++ 软件生态,多了一种在浏览器中直接运行的可能。未来在描述产品跨平台的时候,我们就可以给 Web 增加一个✔了。
对于一个游戏或者应用,WebAssembly 和 Emscripten 提供了基本的运行时环境的实现,如多线程、 图形接口、文件系统、网络请求、音频播放等等。有很多现成的 C/C++ 应用经过一定的改造,完全有可能放到 Web 端运行,业界也已经有多个知名软件通过这项技术移植到了 Web 上。经过几年时间迭代,目前这项技术已经初步成熟,在合适的场景下,完全可以放心地在生产环境去进行尝试。让我们一起来期待 WebAssembly 在未来更多领域大放异彩。
7. 参考文献
[1]. Talk: the Nuts and Bolts of Webassembly: sriku.org/blog/2019/0…
[2]. 十年磨一剑,WebAssembly 是如何诞生的?:developer.aliyun.com/article/787…
[3]. Chrome Platform Status :chromestatus.com/
[4]. Adobe PS: web.dev/ps-on-the-w…
[5]. Adobe Acrobat: blog.developer.adobe.com/acrobat-on-…
[6]. pspdfkit: pspdfkit.com/blog/2017/w…
[7]. Figma: medium.com/figma-desig…
[8]. Google Earch: medium.com/google-eart…
[9]. QT: www.qt.io/blog/2018/1…
[10]. WebAssembly|MDN : developer.mozilla.org/en-US/docs/…
[11]. Roadmap - WebAssembly: webassembly.org/roadmap/
[12]. Emscripten官方文档的移植部分 : emscripten.org/docs/portin…
[13]. 使用 Web Workers developer.mozilla.org/zh-CN/docs/…
[14]. Threads Proposal for WebAssembly: github.com/WebAssembly…
[15]. WebAssembly Threads ready to try in Chrome 70: web.dev/wasm-thread…
[16]. SharedArrayBuffer: developer.mozilla.org/zh-CN/docs/…
[17]. Pthreads support: emscripten.org/docs/portin…
[18]. emscripten_set_main_loop: emscripten.org/docs/api_re…
[19]. Differences Between WebGL and OpenGL ES 2.0: registry.khronos.org/webgl/specs…
[20]. OffscreenCanvas: developer.mozilla.org/en-US/docs/…
[21]. Porting SIMD code targeting WebAssembly: emscripten.org/docs/portin…
[22]. New File System Implementation: github.com/emscripten-…
[23]. Feature: Origin Private File System extension: AccessHandle: chromestatus.com/feature/570…
[24]. Debugging WebAssembly with modern tools: developer.chrome.com/blog/wasm-d…
[25]. Debugging WebAssembly Faster: developer.chrome.com/blog/faster…
扫码关注公众号 👆 追更不迷路