这是这个博客的第4000篇文章,想想还挺有纪念意义的。4000篇文章,记录了这些年在技术上的学习和成长。这第4000篇,我想写一个让我熬夜最多的技术话题:WebAssembly。
WebAssembly,简称WASM,这几年很火。它能让C、C++、Rust等语言编译成可以在浏览器里运行的二进制格式,性能接近原生。我们团队在一个项目中用WebAssembly做了一个图像处理引擎,本来以为能大幅提升性能,结果踩了一堆坑,熬了无数个夜。
这篇文章我想记录一下在使用WebAssembly过程中遇到的各种坑,从编译、内存、线程到兼容性,分享踩坑经验和解决方案。希望能帮到正在用或者准备用WebAssembly的朋友,让你们少熬点夜。
坑一:编译出来的包太大
第一个坑,也是最常见的坑,就是编译出来的WASM包太大。
我们刚开始用Emscripten编译一个C++的图像处理库,编译出来的WASM文件居然有10MB。这对于Web应用来说太大了,用户加载要等很久,特别是在移动端网络不好的情况下。
一开始我们以为是库本身就这么大,后来研究了一下,发现是编译选项的问题。Emscripten默认会包含很多不需要的东西,比如完整的C标准库、异常处理、调试信息等。
解决方案是优化编译选项。第一,用-Oz优化级别,这个级别会优先优化体积,比-O3的体积小很多。第二,开启--gc-sections,把没有用到的代码段去掉。第三,用-s WASM=1 -s ONLYMYCODE=1,只编译我们自己的代码,不包含标准库。第四,用-s EXPORTED_FUNCTIONS只导出需要的函数,不要导出所有函数。
优化之后,WASM文件从10MB降到了1.5MB,效果非常明显。后来我们又用了Brotli压缩,实际传输的体积只有500KB左右,完全可以接受了。
还有一个技巧是,把WASM文件拆分,核心功能放在主包里,不常用的功能做成动态加载的模块。这样首屏加载的体积可以进一步减小。
这个坑的教训是:编译选项对WASM的体积影响非常大,一定要仔细研究每个选项的含义,不要用默认配置就上线。
坑二:内存管理让人头秃
第二个坑是内存管理,这个坑让我熬了好几个夜。
WebAssembly的内存模型和JavaScript不一样。WASM有自己的线性内存,是一个ArrayBuffer,JS和WASM共享这块内存。但WASM不能直接访问JS的对象,JS也不能直接访问WASM的堆,需要通过指针来传递数据。
我们刚开始的时候,在JS里创建了一个大数组,想直接传给WASM处理。结果发现不行,必须先把数据复制到WASM的线性内存里,处理完之后再复制出来。这个复制的过程,在数据量大的时候非常耗时,几乎抵消了WASM的性能优势。
解决方案是,尽量减少数据复制。第一,直接在WASM的线性内存里创建数据,不要在JS里创建了再复制。第二,用TypedArray直接操作WASM的内存,比如Module.HEAPU8、Module.HEAPF32等,这些视图直接指向WASM的线性内存,不需要复制。第三,对于大文件,可以用流式处理,边读边处理,不要一次性加载到内存里。
还有一个问题是内存泄漏。WASM的内存需要手动管理,malloc的内存要手动free,不然会泄漏。我们刚开始的时候,有几个函数忘记free内存,跑了一段时间之后浏览器就崩溃了。后来我们写了一个内存管理的封装,自动追踪分配的内存,在适当的时候释放,才解决了这个问题。
这个坑的教训是:WASM的内存管理和JS完全不同,一定要理解它的内存模型,尽量减少数据复制,注意内存泄漏。
坑三:线程支持的兼容性问题
第三个坑是线程支持。
我们的图像处理库是多线程的,用了OpenMP做并行计算。编译成WASM的时候,我们开启了线程支持,用的是SharedArrayBuffer和Atomics。结果上线之后,发现很多用户的浏览器不支持,特别是Safari和一些旧版本的浏览器。
查了一下才知道,SharedArrayBuffer因为安全问题,在很多浏览器里被禁用了,或者需要特殊的HTTP头才能启用。Chrome和Firefox需要设置Cross-Origin-Opener-Policy和Cross-Origin-Embedder-Policy这两个HTTP头,才能启用SharedArrayBuffer。Safari直到比较新的版本才支持,而且也需要特殊设置。
我们的解决方案是,做了两个版本的WASM,一个是多线程版本,一个是单线程版本。运行的时候检测浏览器是否支持SharedArrayBuffer,如果支持就加载多线程版本,不支持就加载单线程版本。这样虽然单线程版本慢一些,但至少能保证功能可用。
另外,即使浏览器支持SharedArrayBuffer,也要注意跨域隔离的问题。如果页面里有跨域的iframe或者脚本,可能会导致SharedArrayBuffer被禁用。我们在上线的时候,就因为页面里嵌入了一个第三方的广告脚本,导致SharedArrayBuffer被禁用,多线程版本用不了,排查了很久才找到原因。
这个坑的教训是:WASM的线程支持还不是所有浏览器都兼容,一定要做好降级方案,注意跨域隔离的HTTP头设置。
坑四:调试困难
第四个坑是调试困难,这个坑让我无数次想砸电脑。
WASM是二进制格式,调试起来比JS困难很多。刚开始的时候,WASM代码出了问题,我们只能看到一个模糊的错误信息,根本不知道是哪一行代码出了问题。
后来我们用了几个方法来改善调试体验。第一,编译的时候加上-g选项,保留调试信息,这样在Chrome的DevTools里可以看到WASM的源码,设置断点调试。第二,用Emscripten的ASSERTIONS选项,开启运行时的断言检查,能提前发现很多问题,比如数组越界、空指针等。第三,在C++代码里加日志,通过printf输出到JS的控制台,这样可以追踪代码的执行流程。第四,用单元测试,在C++层面把每个函数都测好,减少集成到WASM之后的问题。
但即使有了这些工具,调试WASM还是比调试JS困难很多。特别是一些和JS交互的问题,比如参数传递错误、内存访问越界,排查起来非常麻烦。我们有一次因为一个函数的参数类型不匹配,JS传的是32位整数,WASM期望的是64位浮点数,结果算出来的结果完全不对,排查了整整一天才找到原因。
这个坑的教训是:WASM的调试体验还不够好,一定要在编译成WASM之前把C++代码测好,尽量减少集成后的调试工作。
坑五:JS和WASM交互的开销
第五个坑是JS和WASM交互的开销。
我们刚开始的时候,以为把计算放到WASM里就一定快。结果发现,有些简单的计算,用WASM反而比用JS慢。原因就是JS和WASM之间的交互有开销,每次调用WASM函数,都要做参数转换、栈切换、类型检查等,这些开销对于简单计算来说,比计算本身还大。
比如我们有一个简单的颜色转换函数,输入一个RGB值,输出一个灰度值。用JS写只要几行代码,几纳秒就能算完。但用WASM的话,调用开销就有几十纳秒,反而更慢。
解决方案是,尽量减少JS和WASM之间的调用次数。第一,把多个小操作合并成一个大操作,一次性传给WASM处理,而不是每次调用一个小函数。第二,对于简单的计算,直接用JS实现就好,不要用WASM。第三,把数据批量传给WASM,在WASM里循环处理,而不是在JS里循环调用WASM函数。
我们后来重新设计了API,把图像处理的整个流程封装成一个函数,JS只需要调用一次,传入输入和输出的内存指针,WASM内部完成所有处理。这样交互次数大大减少,性能提升了很多。
这个坑的教训是:WASM不是银弹,不是所有计算放到WASM里都快。要考虑JS和WASM交互的开销,只把计算密集型的、批量的任务放到WASM里。
坑六:SIMD的兼容性和性能
第六个坑是SIMD。
SIMD(单指令多数据)是WASM的一个重要特性,可以大幅提升并行计算的性能。我们的图像处理库用了很多SIMD优化,编译成WASM的时候也开启了SIMD支持。结果上线之后,发现有些浏览器不支持WASM SIMD,加载的时候直接报错。
查了一下,WASM SIMD是比较新的特性,Chrome和Firefox比较新的版本支持,Safari直到最近才支持,旧版本的浏览器完全不支持。而且即使支持,不同浏览器的实现也有差异,性能表现不一样。
我们的解决方案是,编译两个版本,一个带SIMD,一个不带。运行的时候检测浏览器是否支持WASM SIMD,支持就加载带SIMD的版本,不支持就加载不带的版本。检测的方法很简单,用WebAssembly.validate验证一段SIMD的字节码就行。
还有一个问题是,SIMD的性能提升没有想象中那么大。我们原本以为能有2到4倍的提升,实际测下来只有1.5倍左右。原因是WASM的SIMD指令集还比较有限,很多高级的SIMD操作不支持,而且编译器的自动向量化还不够智能。很多SIMD优化需要手动写intrinsics,工作量很大。
这个坑的教训是:WASM SIMD还不够成熟,兼容性和性能都有局限,一定要做好降级,不要对性能提升期望太高。### 坑七:异常处理的坑
第七个坑是异常处理。
C++代码里经常用异常来处理错误,我们的图像处理库也不例外。编译成WASM的时候,异常处理默认是开启的,但开启异常处理会让WASM的体积变大,性能也会下降。
更麻烦的是,WASM的异常处理和JS的异常处理是两套体系。C++里throw的异常,不能直接被JS的try-catch捕获,需要通过Emscripten的特殊机制来转换。我们刚开始的时候,C++里抛了一个异常,结果JS里没有捕获到,整个WASM模块就崩溃了,浏览器直接报错。
解决方案是,尽量不要在C++代码里用异常,而是用返回错误码的方式来处理错误。这样既减小了WASM的体积,也避免了异常跨语言传递的问题。如果一定要用异常,要用Emscripten的EXCEPTION_CATCHING机制,在JS里用特殊的方法来捕获C++的异常。
我们后来把所有的异常都改成了返回错误码,JS里检查返回值来判断是否出错。虽然代码写起来麻烦一点,但稳定性提升了很多,WASM的体积也小了不少。
这个坑的教训是:WASM的异常处理有额外开销和兼容性问题,尽量用错误码代替异常。
坑八:启动加载的优化
第八个坑是启动加载。
WASM模块需要下载、编译、实例化之后才能使用,这个过程需要时间。特别是在移动端,CPU性能弱,编译一个大的WASM模块可能要几秒钟。如果处理不好,用户打开页面之后要等很久才能用,体验很差。
我们刚开始的时候,是在页面加载的时候同步加载WASM模块,结果首屏时间增加了好几秒,用户体验很差。后来我们做了几个优化。
第一,异步加载WASM模块,不要阻塞首屏渲染。页面先显示内容,WASM模块在后台加载,加载完成之后再启用相关功能。第二,用流式编译,边下载边编译,减少总的等待时间。第三,用WebAssembly.instantiateStreaming而不是WebAssembly.instantiate,前者性能更好。第四,做缓存,把编译好的WASM模块缓存到IndexedDB里,下次加载的时候直接用缓存,不用重新编译。第五,显示加载进度,让用户知道正在加载,减少焦虑。
优化之后,WASM的加载对首屏的影响大大减小了。用户打开页面就能看到内容,WASM在后台加载,等用户需要用到相关功能的时候,一般已经加载完成了。
这个坑的教训是:WASM的加载和编译需要时间,一定要异步加载,做好缓存,不要阻塞首屏。
坑九:移动端的性能问题
第九个坑是移动端的性能。
我们在桌面端测试的时候,WASM的性能很好,比JS快好几倍。但到了移动端,性能下降得很厉害,有些场景甚至比JS还慢。
分析之后发现,移动端的问题主要有几个。第一,移动端的CPU性能弱,WASM的编译和运行都比桌面端慢。第二,移动端的内存有限,WASM的线性内存分配太大的话会导致浏览器崩溃。第三,移动端的浏览器对WASM的优化不如桌面端,特别是Safari,WASM的性能比Chrome差很多。
我们做了几个优化来适配移动端。第一,限制WASM的初始内存大小,不要分配太大,用多少分配多少,需要的时候再增长。第二,在移动端降低处理的分辨率或者质量,换取性能。第三,做性能检测,如果设备性能太弱,就降级用JS实现,或者提示用户设备性能不足。第四,针对Safari做特殊优化,因为Safari的WASM性能确实差一些,有些操作在Safari上用JS反而更快。
这个坑的教训是:WASM在移动端的表现和桌面端差异很大,一定要在真机上测试,做好降级方案。
坑十:版本更新和兼容性
第十个坑是版本更新和兼容性。
WASM的标准还在不断发展,新的特性不断加入,比如线程、SIMD、异常处理、引用类型等。但不同浏览器对这些特性的支持进度不一样,导致我们的WASM代码经常因为兼容性问题而出错。
比如我们有一次升级了Emscripten的版本,编译出来的WASM用了一个新的特性,结果在旧版本的浏览器里加载失败。回滚到旧版本的Emscripten才解决问题。
还有一个问题是,WASM模块的接口要保持稳定。如果我们修改了导出函数的参数或者返回值,JS端也要同步修改,不然就会出错。我们有一次因为修改了一个函数的参数,忘记更新JS端的调用,结果线上出了bug,排查了很久。
我们的解决方案是,第一,锁定Emscripten的版本,不要随意升级,升级之前要充分测试。第二,WASM的接口设计要稳定,修改的时候要做版本兼容,比如保留旧的函数,新增新的函数。第三,做特性检测,运行的时候检测浏览器支持哪些WASM特性,加载对应的版本。第四,建立完善的测试流程,每次修改WASM代码之后,都要在各个浏览器和设备上测试。
这个坑的教训是:WASM生态还在快速变化,一定要锁定工具链版本,做好接口版本管理和兼容性测试。
写在最后
以上就是我们在使用WebAssembly过程中遇到的十个主要的坑。每一个坑都让我们熬了夜,也让我们学到了很多。
WebAssembly是一个很有前景的技术,它让浏览器能运行接近原生性能的代码,打开了很多新的可能性。比如图像处理、视频编辑、3D渲染、科学计算等,这些以前在浏览器里做不了或者做得不好的事情,现在用WASM都能实现了。
但WASM也不是银弹,它有自己的局限和坑。它不是所有场景都适用,不是用了就一定快。在决定用WASM之前,一定要想清楚你的场景是否适合,是否值得付出额外的复杂度和学习成本。
如果你正在考虑用WASM,我的建议是:先从小的模块开始尝试,积累经验,再逐步扩大使用范围。不要一开始就把核心业务全部用WASM重写,那样风险太大。
这是博客的第4000篇文章,感谢一直以来的陪伴。技术的路上,我们一起踩坑,一起成长。
最后用一句话来结束这篇文章:"技术没有银弹,理解原理,踩过坑,才能用好工具。"
愿每一个技术人,都能在踩坑中成长,写出更健壮的代码。
评论(0)
暂无评论,快来抢沙发~
评论功能仅对会员开放,请先登录
登录