最近在项目中用了WebAssembly 2.0,本来以为能大幅提升性能,结果踩了无数的坑,熬了好几个夜才把问题一个个解决。
WebAssembly(简称Wasm)是一种可以在浏览器中运行的低级字节码格式,能让C/C++/Rust等语言编写的代码在浏览器中接近原生速度运行。Wasm 2.0在1.0的基础上增加了很多新特性,比如SIMD、线程、引用类型、多值返回等,功能更强大了,但坑也更多了。
这篇文章就来记录一下我使用Wasm 2.0过程中遇到的各种坑,以及解决方案,给正在用或者打算用Wasm的朋友一些参考。
为什么用WebAssembly
先说说为什么在项目中用WebAssembly。
我们的项目是一个在线图像处理工具,需要在浏览器中做一些复杂的图像算法,比如滤镜、降噪、超分辨率、人脸识别等。这些算法用JavaScript写,性能不够,处理一张高清图片要好几秒,用户体验很差。
我们试过用WebGL做GPU加速,但有些算法不适合并行化,而且WebGL的调试很麻烦。后来想到了WebAssembly,把C++写的图像处理算法编译成Wasm,在浏览器中运行,应该能提升性能。
Wasm 2.0的SIMD(单指令多数据)特性特别适合图像处理,可以一条指令处理多个像素数据,理论上能有几倍的性能提升。还有线程特性,可以做多线程并行处理。
听起来很美好,但实际用起来,坑一个接一个。
坑一:编译环境配置
第一个坑是编译环境的配置。
我们用的是Emscripten工具链,把C++代码编译成Wasm。Emscripten的安装就花了我大半天。
首先,Emscripten依赖Python和Node.js,版本要求比较严格。Python需要3.7以上,Node.js需要14以上。我的环境里Python版本是3.6,升级Python又花了一些时间。
然后,Emscripten的安装脚本在Windows上有各种问题。官方推荐用emsdk安装,但emsdk在Windows上经常因为网络问题下载失败。我换了好几个镜像,折腾了半天才安装成功。
安装完之后,配置环境变量也踩了坑。Emscripten需要激活环境,每次打开新的终端都要重新激活,不然找不到emcc命令。我把激活命令写进了系统环境变量,才解决这个问题。
编译的时候,Wasm 2.0的新特性需要手动开启。比如SIMD需要加-msimd128参数,线程需要加-pthread参数,引用类型需要加-mreference-types参数。这些参数在文档里藏得很深,我是翻了好久的文档和GitHub issue才找到的。
而且,不同版本的Emscripten,参数还不一样。有些参数在新版本里改了名字,有些参数被废弃了。我用的是最新版本,网上很多教程都是旧版本的,照着做会报错。
解决方案:
- 用Docker运行Emscripten,避免环境配置问题。官方提供了Docker镜像,拉下来直接用,省心很多。
- 仔细阅读官方文档,特别是每个版本的更新日志,确认参数是否有变化。
- 把编译命令写成脚本,避免每次手动输入参数出错。
坑二:内存管理
第二个坑是内存管理,这是Wasm最容易出问题的地方。
Wasm的内存是线性的,通过一个ArrayBuffer和JavaScript共享。C++代码里的new/malloc,实际上是在这个线性内存里分配空间。JS和Wasm之间传递数据,需要通过这个共享内存。
第一个问题是内存增长。Wasm的内存默认是16页(每页64KB,共1MB),如果C++代码需要更多内存,会自动增长。但内存增长的时候,原来的ArrayBuffer会被分离(detached),JS里持有的引用会失效。我就遇到过这个问题,JS里保存了一个指向Wasm内存的TypedArray,Wasm内存增长之后,这个TypedArray就失效了,访问的时候报错。
解决方案是,每次调用Wasm函数之后,重新获取内存引用,不要缓存TypedArray。或者在初始化的时候就把内存设置得足够大,避免运行时增长。可以用-s INITIAL_MEMORY=64MB参数设置初始内存大小。
第二个问题是内存泄漏。C++代码里用new分配的内存,如果不手动delete,就会泄漏。Wasm的内存不会被JS的垃圾回收器回收,必须手动管理。我们的图像处理算法,有些路径忘记释放内存了,跑了几次之后内存就满了,浏览器崩溃。
解决方案是,仔细检查C++代码,确保每个new都有对应的delete。或者用智能指针(std::uniqueptr、std::sharedptr)自动管理内存。也可以用Emscripten的--bind工具,让JS端能调用C++的析构函数。
第三个问题是数据拷贝的开销。JS和Wasm之间传递数据,如果用值传递,会有拷贝开销。比如把一张图片的像素数据从JS传到Wasm,如果用参数传递,会把整个ArrayBuffer拷贝一遍,大图片的拷贝开销很大。
解决方案是,直接在Wasm内存中分配空间,然后把JS的数据写进去,再把指针传给Wasm函数。这样就避免了拷贝。Emscripten提供了_malloc和HEAPU8等接口,可以直接操作Wasm内存。
坑三:JS和Wasm交互
第三个坑是JS和Wasm的交互。
Wasm 1.0的时候,JS和Wasm的交互比较简单,只能传数字类型。Wasm 2.0增加了引用类型(Reference Types),可以直接传JS对象给Wasm,也可以从Wasm返回JS对象。这个功能很强大,但也带来了很多坑。
第一个问题是引用类型的兼容性。引用类型是Wasm 2.0的新特性,不是所有浏览器都支持。特别是一些旧版本的浏览器,完全不支持引用类型。如果用户用的是旧浏览器,Wasm模块加载就会失败。
解决方案是,做特性检测,如果浏览器不支持引用类型,就降级到Wasm 1.0的方式(用数字索引代替引用)。或者用Babel和polyfill做兼容处理。也可以在编译的时候不开启引用类型,用传统的方式交互,兼容性更好。
第二个问题是多值返回。Wasm 2.0支持函数返回多个值,这个功能很实用,但JS的接收方式和单值返回不一样。如果用Emscripten的--bind,多值返回会被打包成一个JS对象;如果直接调用,返回的是一个数组。我刚开始不知道,按照单值返回的方式接收,结果拿到的是undefined,调试了半天才发现。
解决方案是,仔细阅读Emscripten文档,了解多值返回在JS端的接收方式。或者用--bind工具,它会自动处理多值返回,生成类型安全的JS绑定。
第三个问题是异常处理。Wasm 2.0增加了异常处理(Exception Handling)特性,C++的异常可以传播到JS端。但这个特性的兼容性不好,而且Emscripten的支持还不完善。我们的C++代码里用了异常,编译的时候开启了异常处理,结果在某些浏览器里运行时崩溃。
解决方案是,暂时不用Wasm的异常处理特性,在C++代码里用错误码代替异常。或者把异常在C++层就捕获处理掉,不要传播到JS层。等异常处理特性更成熟了再用。
坑四:SIMD的坑
第四个坑是SIMD,这是我踩得最深的一个坑。
SIMD(单指令多数据)是Wasm 2.0最重要的特性之一,可以一条指令处理多个数据,特别适合图像处理、音视频编解码等场景。我们的图像处理算法用了SIMD优化,理论上能有3-4倍的性能提升。
但SIMD的坑真的很多。
第一个问题是浏览器兼容性。SIMD在Chrome和Firefox里支持得比较好,但Safari的支持很晚,而且有bug。我们的用户里有不少用Safari的,在Safari里Wasm模块加载失败,整个功能用不了。
解决方案是,做特性检测。如果浏览器不支持SIMD,就加载一个没有SIMD优化的Wasm模块(降级版本),虽然性能差一些,但至少能用。可以编译两个版本的Wasm,一个带SIMD,一个不带,根据浏览器特性动态加载。
第二个问题是SIMD的性能不如预期。我们本来以为用了SIMD能有3-4倍提升,实际测试只有1.5倍左右。后来发现,原因是数据没有对齐。SIMD指令要求数据16字节对齐,如果数据没有对齐,性能会大打折扣,甚至比不用SIMD还慢。
解决方案是,用attribute((aligned(16)))或者posix_memalign确保数据16字节对齐。在分配内存的时候,就考虑对齐问题。也可以用Emscripten的SIMD内置函数,它会自动处理对齐。
第三个问题是SIMD的调试很困难。SIMD指令是向量操作,调试的时候看不到每个通道的值,很难定位问题。而且不同浏览器的SIMD实现有差异,同样的代码在Chrome里正常,在Firefox里结果不对。
解决方案是,先写一个标量版本(不用SIMD),确保算法正确。然后再写SIMD版本,和标量版本对比结果,确保SIMD版本的正确性。调试的时候,可以把SIMD向量的值拆成标量打印出来。
第四个问题是编译时间长。开启SIMD之后,编译时间明显变长,特别是开启优化(-O3)之后,编译一个大项目要十几分钟。开发调试的时候很影响效率。
解决方案是,开发阶段用Debug模式编译(不加优化,不开SIMD),编译快,调试方便。发布的时候再用Release模式编译(加优化,开SIMD)。可以用CI/CD自动做发布构建。
坑五:多线程的坑
第五个坑是多线程,这也是Wasm 2.0的重要特性。
Wasm 2.0支持多线程(基于SharedArrayBuffer和Atomics),可以在Wasm里用pthread创建线程,做多线程并行计算。我们的图像处理算法可以并行处理图片的不同区域,理论上能有接近线性的加速比。
但多线程的坑比SIMD还多。
第一个问题是浏览器安全限制。多线程需要SharedArrayBuffer,而浏览器出于安全考虑,要求页面必须跨域隔离(Cross-Origin Isolation)才能使用SharedArrayBuffer。具体来说,HTTP响应头需要设置Cross-Origin-Opener-Policy: same-origin和Cross-Origin-Embedder-Policy: require-corp。如果没有设置,SharedArrayBuffer不可用,Wasm的多线程功能就用不了。
解决方案是,在服务器上配置这两个HTTP头。如果用的是第三方托管服务(比如GitHub Pages、Vercel),需要确认是否支持自定义HTTP头。有些服务不支持,就没法用多线程。
第二个问题是线程创建开销大。Wasm里创建一个线程,实际上是创建一个Web Worker,开销很大。如果频繁创建和销毁线程,性能反而会下降。
解决方案是,使用线程池。在初始化的时候创建好固定数量的线程,任务来了就分配给空闲线程,任务完成后线程不销毁,等待下一个任务。这样就避免了频繁创建线程的开销。
第三个问题是线程间通信复杂。Wasm的多线程是基于共享内存的,线程之间通过共享内存通信,需要用互斥锁(mutex)和条件变量(condition variable)同步。如果同步没做好,会出现数据竞争、死锁等问题,调试起来非常困难。
解决方案是,仔细设计线程间的通信方式,尽量减少共享数据。能用消息传递的就不用共享内存。必须共享的,用互斥锁保护好。用ThreadSanitizer等工具检测数据竞争。
第四个问题是兼容性。多线程特性在移动端浏览器的支持不好,特别是iOS上的Safari,很长时间都不支持SharedArrayBuffer。如果你的用户很多用手机访问,多线程功能可能用不了。
解决方案是,做降级处理。如果浏览器不支持多线程,就用单线程版本。虽然性能差一些,但至少功能正常。
坑六:调试和性能分析
第六个坑是调试和性能分析。
Wasm的调试比JS困难很多。Wasm是低级字节码,在浏览器的开发者工具里,看到的是Wasm的指令,不是C++源码,很难定位问题。
第一个问题是源码映射。Emscripten支持生成Source Map,把Wasm指令映射回C++源码。但Source Map的支持不完善,有时候断点打不上,变量值看不到。而且Wasm 2.0的新特性(SIMD、线程)的Source Map支持更差。
解决方案是,用Chrome的DevTools进行Wasm调试,Chrome对Wasm的调试支持最好。开启-g参数生成调试信息,用--source-map-base指定Source Map的路径。调试的时候,尽量用简单的测试用例,缩小问题范围。
第二个问题是性能分析。Wasm的性能分析也很困难,浏览器的Performance面板能看到Wasm函数的执行时间,但看不到函数内部的细节。很难定位性能瓶颈在哪里。
解决方案是,用Emscripten的--profiling参数,生成带性能分析信息的Wasm模块。用Chrome DevTools的Performance面板分析,能看到每个Wasm函数的执行时间。也可以在C++代码里手动加计时,输出到控制台,定位性能瓶颈。
第三个问题是日志输出。C++代码里的printf和std::cout,Emscripten会把输出重定向到JS的console.log。但如果是多线程环境下,不同线程的输出会混在一起,很难看。而且Wasm崩溃的时候,有时候没有任何错误信息,直接就挂了。
解决方案是,用Emscripten的--bind工具,自定义日志输出函数,加上线程ID和时间戳,方便区分。Wasm崩溃的时候,用--catch参数捕获异常,输出错误信息。也可以用Sentry等错误监控工具,捕获Wasm的崩溃。
坑七:部署和加载
第七个坑是部署和加载。
Wasm模块的体积一般比较大,几百KB到几MB不等。加载速度是一个需要关注的问题。
第一个问题是加载速度。Wasm模块大,网络传输时间长,编译时间也长。用户第一次打开页面,可能要等好几秒才能用。
解决方案是,压缩Wasm模块。服务器开启gzip或Brotli压缩,Wasm的压缩率很高,能减小70%以上的体积。用-O3优化编译,减小Wasm体积。用流式编译(streaming compilation),让浏览器在下载的同时编译Wasm,减少等待时间。
第二个问题是缓存。Wasm模块编译之后,可以缓存编译结果,下次加载的时候直接用缓存,不需要重新编译。但不同浏览器的缓存机制不一样,而且Wasm模块更新之后,缓存会失效。
解决方案是,用Service Worker缓存Wasm模块和编译结果。给Wasm文件加上内容哈希,文件名里包含哈希值,这样文件内容变了文件名就变了,浏览器会自动加载新文件,缓存管理更方便。
第三个问题是CDN和跨域。如果Wasm文件放在CDN上,需要注意跨域问题。Wasm的多线程特性需要跨域隔离,如果Wasm文件和页面不在同一个域,需要配置CORS和COEP/COOP头。
解决方案是,尽量把Wasm文件和页面放在同一个域。如果必须用CDN,确保CDN支持自定义HTTP头,配置好CORS和跨域隔离相关的头。
一些建议
踩了这么多坑,总结一些建议给打算用Wasm 2.0的朋友。
第一,评估是否真的需要Wasm。Wasm不是银弹,不是所有场景都适合。如果JS的性能够用,就不要用Wasm,增加复杂度。只有在JS性能确实不够,而且算法适合用C++/Rust重写的时候,才考虑Wasm。
第二,从小处开始。不要一开始就把整个项目都用Wasm重写,先挑一个性能瓶颈模块,用Wasm实现,验证效果。如果效果好,再逐步扩大范围。
第三,做好降级方案。Wasm 2.0的新特性(SIMD、线程、引用类型)兼容性还不够好,一定要做好降级。浏览器不支持的时候,用Wasm 1.0或者纯JS版本,保证功能可用。
第四,仔细阅读官方文档。Wasm和Emscripten的文档更新很快,网上的很多教程已经过时了。以官方文档为准,特别是每个版本的更新日志。
第五,多测试。在不同的浏览器(Chrome、Firefox、Safari)、不同的平台(桌面、移动端)上测试,确保兼容性。特别是Safari和移动端,问题最多。
第六,关注性能。Wasm不是用了就一定快,数据拷贝、内存管理、线程同步等都可能成为性能瓶颈。要做性能分析,找到真正的瓶颈,针对性优化。
写在最后
WebAssembly 2.0是一个很有前景的技术,它让浏览器能运行接近原生性能的代码,打开了很多新的可能性。但它还不够成熟,坑很多,需要有足够的耐心和技术能力去踩坑、解决问题。
我们的项目最终用Wasm 2.0实现了性能提升,图像处理速度比纯JS快了3倍左右,用户体验好了很多。但这个过程中踩的坑、熬的夜,只有自己知道。
如果你正在考虑用Wasm 2.0,希望这篇文章能帮你避开一些坑。如果你已经在用Wasm 2.0,也遇到了其他的坑,欢迎交流分享。
技术的发展总是这样,新特性带来新能力,也带来新问题。在踩坑中学习,在解决问题中成长,这就是技术人的日常。
愿每个用Wasm的人,都能少踩坑,多提升性能。
评论(0)
暂无评论,快来抢沙发~
评论功能仅对会员开放,请先登录
登录