我们把一个图片处理模块用WebAssembly重写后上线,本以为性能会大幅提升,结果线上出了一个诡异的Bug:部分用户上传图片后页面卡死,浏览器崩溃。我排查了整整一夜,从内存泄漏到线程安全,从Emscripten配置到浏览器兼容性,最终找到了根因。本文记录这次排查过程,以及WebAssembly在生产环境中需要注意的坑。

一、背景:为什么用WebAssembly

我们的产品有一个在线图片编辑功能,用户上传图片后可以做裁剪、滤镜、调色等操作。之前这个功能是纯JavaScript实现的,用Canvas API处理图片。随着功能越来越多,滤镜算法越来越复杂,JS的性能开始跟不上了,尤其是处理大图的时候,处理一张4K图片要好几秒,用户体验很差。

后来我们决定用WebAssembly重写核心的图片处理算法。把C++写的图像处理库(用了OpenCV的子集)编译成WebAssembly,在浏览器里运行,期望能大幅提升处理速度。

开发过程还算顺利,用Emscripten把C++代码编译成wasm,JS调用wasm的接口,本地测试性能提升了3-5倍,处理4K图片从5秒降到了1秒以内。我们很高兴,觉得这是一个成功的技术升级。

然后就上线了,然后就出问题了。

二、问题出现:部分用户页面卡死

上线后第一天,就有用户反馈:上传图片后页面卡死,浏览器无响应,有时候直接崩溃。而且不是所有用户都有这个问题,大概10%左右的用户会遇到,主要集中在Windows系统的Chrome浏览器上,Mac用户基本没反馈。

我们自己测试的时候完全复现不了,不管怎么操作都很流畅。这就麻烦了,复现不了的Bug最难排查。

用户越来越多,投诉越来越多,领导要求当晚必须解决。我开始了漫长的排查之夜。

三、排查第一步:收集信息

复现不了,就先收集信息。我们做了以下几件事:

  1. 加日志:在图片处理的关键节点加了日志,记录每一步的耗时和状态,通过前端监控系统上报
  2. 收集用户环境信息:记录出问题用户的浏览器版本、操作系统、硬件配置
  3. 远程调试:联系了几个愿意配合的用户,用远程调试工具看他们的控制台报错

收集到的信息:

  • 出问题的用户主要是Windows 10 + Chrome 83/84
  • 卡死发生在调用wasm处理图片的阶段
  • 控制台没有报错,就是页面无响应
  • 有些用户的内存占用飙升到2GB以上
  • 出问题的图片没有明显规律,大小、格式都不一样

有一个关键线索:出问题的用户,电脑配置都比较低,内存8GB或以下,CPU也比较老。而我们测试用的电脑都是16GB以上内存、最新的CPU,所以复现不了。

四、排查第二步:怀疑内存泄漏

内存占用飙升,第一反应是内存泄漏。WebAssembly的内存管理和JS不一样,wasm有自己的线性内存,需要手动管理(C++的new/delete),如果C++代码里有内存泄漏,JS的GC管不了,内存会一直涨。

我仔细审查了C++代码,发现了几个问题:

  1. 有些地方new了对象没有delete,尤其是异常处理的分支里,提前return的时候没有释放内存
  2. 图片处理的中间缓冲区没有复用,每次处理都new新的缓冲区
  3. Emscripten的编译配置里,ALLOWMEMORYGROWTH设为了1,内存可以无限增长

我修复了这些问题:

  • 所有new的对象都确保delete,用智能指针(std::unique_ptr)管理
  • 中间缓冲区改成全局复用,处理完不释放,下次直接用
  • 限制了最大内存,设为MAXIMUM_MEMORY=512MB

修复后重新编译上线,以为解决了。结果还是有用户反馈卡死,虽然比例降了一些,但还是有。

内存泄漏是一个问题,但不是根本原因。

五、排查第三步:怀疑线程和SharedArrayBuffer

我们的wasm用了多线程(Emscripten的pthread支持),图片处理的时候用多线程并行处理,速度更快。多线程需要SharedArrayBuffer,而SharedArrayBuffer有安全限制。

2018年Spectre和Meltdown漏洞曝光后,浏览器默认禁用了SharedArrayBuffer,需要服务器设置特定的HTTP头(Cross-Origin-Opener-Policy和Cross-Origin-Embedder-Policy)才能启用。如果没有正确设置,多线程的wasm会fallback到单线程模式,或者直接报错。

我检查了我们的服务器配置,发现COOP和COEP头确实没有设置!但我们本地测试的时候是正常的,因为localhost不受这个限制。生产环境没有这些头,SharedArrayBuffer不可用。

但问题是,如果SharedArrayBuffer不可用,Emscripten应该fallback到单线程,不应该卡死啊。我看了Emscripten的文档,发现了一个坑:如果编译时用了-pthread选项,但运行时SharedArrayBuffer不可用,某些版本的Emscripten不会优雅fallback,而是会陷入死循环或者崩溃。

这可能就是问题所在!低配置的电脑本来性能就差,再加上死循环或者异常,就卡死了。

我做了两个修改:

  1. 在服务器上正确设置了COOP和COEP头,启用SharedArrayBuffer
  2. 同时编译了一个单线程版本的wasm,运行时检测SharedArrayBuffer是否可用,不可用就加载单线程版本

修改后上线,卡死的问题大幅减少,但还是有极少数用户遇到。

六、排查第四步:发现真正的根因

还剩极少数用户有问题,我继续排查。这次我拿到了一个用户的完整崩溃转储,仔细分析后,终于发现了真正的根因。

问题出在图片尺寸上。我们的C++代码在处理图片时,会根据图片尺寸分配内存缓冲区。但代码里没有对图片尺寸做校验,如果用户上传了一张特别大的图片(比如某些手机拍的照片,分辨率很高,或者是经过编辑的超大图),分配的缓冲区会超出wasm的内存限制。

wasm的线性内存是连续的,默认初始16MB,可以增长到设置的最大值。如果一次性申请的内存超过了当前可用的连续空间,malloc会失败,返回nullptr。我们的C++代码没有检查malloc的返回值,直接用了nullptr,导致野指针访问,wasm运行时崩溃。

但为什么会导致整个页面卡死呢?因为wasm崩溃后,JS那边的Promise一直pending,没有reject,UI线程被阻塞,页面就无响应了。低配置电脑内存小,更容易触发内存分配失败,所以主要是这些用户出问题。

找到根因后,修复就简单了:

  1. 在C++代码里,所有malloc/new之后都检查返回值,失败了返回错误码
  2. 在JS层,调用wasm之前先校验图片尺寸,超过限制(比如最大4096x4096)就提示用户
  3. wasm处理加超时机制,超过30秒就终止,避免页面永久卡死
  4. 给wasm调用加try/catch,异常时给用户友好提示

修复后上线,终于没有用户反馈卡死了。

七、其他踩到的坑

这次排查过程中,还发现了WebAssembly在生产环境中的其他一些坑,一并记录下来。

1. 编译优化级别

Emscripten的编译优化级别(-O0到-O3,-Os,-Oz)对性能和体积影响很大。我们最开始用了-O0(调试模式),wasm文件有20MB,加载很慢。后来改成-O3优化,文件降到了3MB,性能也更好。但-O3优化可能会有一些兼容性问题,需要充分测试。

2. wasm文件的缓存

wasm文件比较大,一定要设置好缓存策略。我们用了Content-Hash命名,配合CDN缓存,用户只需要下载一次。如果每次都重新下载,首屏加载会很慢。

3. 浏览器兼容性

虽然主流浏览器都支持WebAssembly了,但不同浏览器的实现有差异。比如Safari对wasm的内存限制比较严格,Firefox的多线程支持和Chrome不一样。需要在各个浏览器上充分测试。

4. 移动端性能

手机浏览器的wasm性能比桌面端差很多,尤其是中低端手机。我们在手机上测试,处理速度只有桌面端的1/3到1/2。需要针对移动端做降级处理,比如降低处理分辨率,或者提示用户用桌面端。

5. 调试困难

wasm的调试比JS困难很多,C++源码编译后变成了wasm字节码,浏览器里只能看到汇编级别的代码。虽然有Source Map支持(DWARF),但配置复杂,体验也不如JS调试。开发阶段建议先用JS版本验证逻辑,再移植到wasm。

6. 启动开销

wasm模块的加载和编译有一定开销,尤其是大的wasm文件。需要做懒加载,在用户真正用到的时候再加载,不要在页面加载时就加载。同时可以用WebAssembly.instantiateStreaming流式编译,提升加载速度。

八、经验总结

这次通宵排查WebAssembly Bug的经历,让我总结了几条经验:

1. 本地测试不能代替生产测试

我们本地测试一切正常,但生产环境出了问题,因为本地环境(localhost、高配电脑)和生产环境(真实用户、各种配置)差异很大。上线前一定要在接近生产的环境中测试,包括低配电脑、不同浏览器、不同网络环境。

2. 边界条件一定要处理

内存分配失败、超大图片、异常输入,这些边界情况在开发时很容易忽略,但在生产环境一定会遇到。所有的资源分配都要检查返回值,所有的外部输入都要做校验,所有的异步调用都要有超时和错误处理。

3. 新技术要充分了解其坑

WebAssembly是个好技术,但它不是银弹,有自己的局限性和坑。在引入新技术之前,一定要充分了解它的特性、限制、兼容性问题,不要只看到性能提升就盲目上线。

4. 完善的监控和日志很重要

这次能排查到根因,靠的是完善的前端监控和日志。如果没有用户环境信息、没有性能数据、没有崩溃转储,根本无从下手。线上系统一定要有完善的监控和日志体系。

5. 降级方案必不可少

不管新技术多好,都要有降级方案。wasm加载失败、性能不达标、浏览器不支持,都要有JS的fallback方案,保证核心功能可用。不能因为一个新技术的问题,让整个功能不可用。

九、写在最后

WebAssembly确实能带来显著的性能提升,我们的图片处理功能最终也稳定运行了,用户体验很好。但这个过程中踩的坑、熬的夜,也让我对WebAssembly有了更深刻的理解。

任何新技术都是双刃剑,用好了能提升效率和体验,用不好就会带来各种问题。关键是要在充分了解的基础上,谨慎引入,充分测试,做好降级和监控。

希望这次排查经历能给正在用或打算用WebAssembly的朋友一些参考,让你们少踩一些坑,少熬一些夜。技术是为业务服务的,稳定和可靠永远比追求新技术更重要。