最近在做一个边缘计算项目,用WebAssembly + WASI跑轻量级服务,目标是在资源受限的边缘设备上提供高并发的服务。结果开发完测试的时候,性能很差,QPS只有预期的十分之一,延迟也很高,完全达不到生产要求。

花了一周时间排查和优化,最终把性能提升了12倍,QPS和延迟都达到了预期。本文记录这次WebAssembly系统接口(WASI)性能优化的完整过程,从问题定位到根因分析,从优化方案到效果验证,聊聊WASI性能优化的思路和技巧,给正在用WebAssembly做服务端开发的朋友一些参考。

先说明一下技术栈:WebAssembly运行时用的是Wasmtime,WASI版本是preview1,业务逻辑用Rust写的,编译成wasm模块运行。边缘设备是ARM架构的低功耗板子,内存512MB,CPU四核1.5GHz。

一、问题现象:性能差得离谱

项目开发完之后,我们做了第一次性能测试。测试场景很简单:一个HTTP服务,接收请求,做一些简单的计算,然后返回结果。用wrk做压测,10个线程,100个连接,跑30秒。

测试结果让我们大跌眼镜:

  • QPS只有320,预期是3000以上
  • 平均延迟280ms,预期是50ms以内
  • P99延迟超过1秒
  • CPU使用率只有30%,内存使用率也不高

这个结果完全不符合预期。WebAssembly虽然有性能开销,但也不应该这么差。同样的逻辑用原生Rust写,QPS能到5000以上,延迟也很低。WebAssembly版本差了一个数量级,肯定是哪里出了问题。

而且CPU使用率只有30%,说明不是计算瓶颈,是有什么东西在阻塞,导致CPU没跑满。内存也够用,不是内存瓶颈。那问题出在哪呢?

我们开始排查。

二、初步排查:从哪里入手

性能排查第一步是搞清楚瓶颈在哪里。我们用了几个工具来定位问题。

1. 看日志和指标。 先看服务的日志和监控指标,有没有报错,有没有异常的延迟。日志里没有报错,服务运行正常。但指标显示,每个请求的处理时间很长,大部分时间花在了WASI系统调用上。

2. 用perf分析。 在Linux上用perf工具分析CPU占用,看看时间都花在了哪里。分析结果显示,大部分CPU时间花在了Wasmtime的系统调用处理上,尤其是fdread、fdwrite、clocktimeget这几个WASI系统调用。

3. 加埋点计时。 在代码的关键路径上加了埋点,统计每个阶段的耗时。结果显示,业务逻辑本身只占了10%的时间,90%的时间都花在了WASI系统调用上,尤其是文件读写和时间获取。

4. 对比测试。 我们做了几个对比测试:

  • 同样的逻辑,去掉所有WASI系统调用,纯计算,QPS能到4000以上,延迟很低
  • 加上WASI系统调用,性能立刻下降
  • 用原生Rust版本,同样的系统调用,性能很好

到这里,问题基本定位了:瓶颈在WASI系统调用上。WebAssembly模块和宿主环境之间的系统调用开销太大,导致性能很差。

但为什么系统调用开销这么大?原生程序的系统调用开销很小,WebAssembly的系统调用为什么这么慢?我们需要深入分析根因。

三、根因分析:为什么WASI系统调用这么慢

深入分析之后,我们发现了几个导致WASI系统调用慢的原因。

1. WebAssembly和宿主之间的上下文切换开销。

这是最根本的原因。WebAssembly模块运行在沙箱里,和宿主环境是隔离的。当WebAssembly模块需要调用系统接口(比如读文件、写文件、获取时间)的时候,需要通过WASI接口,从WebAssembly沙箱切换到宿主环境,执行系统调用,然后再切换回WebAssembly沙箱。

这个上下文切换的开销比原生程序的系统调用大很多。原生程序的系统调用只是从用户态切换到内核态,开销很小。而WebAssembly的系统调用需要:WebAssembly代码 → Wasmtime运行时 → 宿主系统调用 → Wasmtime运行时 → WebAssembly代码,中间多了好几层切换,开销自然就大了。

尤其是在高并发场景下,每个请求都要做很多次系统调用,每次切换都有开销,累积起来就很可观了。我们统计了一下,每个请求平均要做30多次WASI系统调用,每次切换开销大约0.1ms,30次就是3ms,看起来不多。但实际上,因为Wasmtime的系统调用处理是串行的,高并发下会有锁竞争和排队,开销就放大了很多。

2. Wasmtime的系统调用处理是串行的,有全局锁。

这是我们发现的第二个重要原因。Wasmtime在处理WASI系统调用的时候,有一个全局锁,所有的系统调用都要获取这个锁才能执行。这意味着,即使有多个WebAssembly实例在跑,系统调用也是串行的,不能并行。

我们的服务是多线程的,每个线程跑一个WebAssembly实例,理论上应该能并行处理请求。但因为系统调用有全局锁,所有线程的系统调用都要排队,导致CPU跑不满,只有30%的使用率。这就是为什么CPU使用率低但性能差的原因——大部分时间都在等锁。

这个问题在高并发下尤其严重。并发越高,锁竞争越激烈,排队时间越长,性能越差。我们测试了一下,并发从100增加到500,QPS不但没有增加,反而下降了,就是因为锁竞争更严重了。

3. 每次系统调用都做完整的参数校验和权限检查。

WASI的设计目标是安全,所以每次系统调用都会做严格的参数校验和权限检查。比如fd_read的时候,会检查文件描述符是否有效、是否有读权限、缓冲区是否在WebAssembly的线性内存范围内、长度是否合法等等。

这些检查每次系统调用都要做,虽然单次开销不大,但高频调用下累积起来就很可观了。尤其是缓冲区检查,需要遍历整个缓冲区,确认它在WebAssembly线性内存的范围内,这个开销和缓冲区大小成正比。我们的服务有时候会读写比较大的缓冲区,这个检查开销就更大了。

4. 时间获取系统调用被频繁调用。

我们在代码里用了很多日志和计时,每次打日志、每次计时都要调用clocktimeget获取当前时间。clocktimeget也是WASI系统调用,也有上下文切换和锁的开销。

统计了一下,每个请求平均要调用10多次clocktimeget,占了系统调用总数的三分之一。这些时间获取调用,大部分是不必要的,比如调试日志的时间戳,可以批量获取或者缓存。

5. 文件描述符表的查找开销。

WASI用文件描述符表来管理打开的文件、socket、标准输入输出等。每次系统调用都要根据文件描述符查找对应的对象,这个查找是O(n)的线性查找,文件描述符多的时候开销比较大。

我们的服务因为有很多网络连接,文件描述符比较多,查找开销就比较大。而且这个查找也是在全局锁里做的,进一步加剧了锁竞争。

四、优化方案:从慢到快

找到了根因之后,我们开始针对性地优化。优化分了几个层面:减少系统调用次数、降低单次系统调用开销、解决锁竞争、优化业务代码。

优化一:减少系统调用次数——批量处理和缓存。

这是最直接有效的优化。既然系统调用开销大,那就尽量少调用。

1. 批量读写。 原来的代码是逐行读写文件,每次读写一行,就要做一次系统调用。我们改成了批量读写,一次性读入整个文件或者一大块数据,在内存里处理,然后一次性写出去。这样系统调用次数从几十次降到了一两次,开销大大减少。

比如日志写入,原来每条日志都调用一次fd_write,我们改成了内存缓冲区,攒够一定数量或者一定时间再一次性写入。这样日志写入的系统调用次数减少了90%以上。

2. 缓存时间。 原来每次打日志、每次计时都调用clocktimeget获取时间。我们改成了在请求开始的时候获取一次时间,缓存起来,后续都用这个缓存的时间。对于不需要精确时间的场景,这个缓存完全够用。

对于需要精确计时的场景,我们用了WebAssembly内部的计数器来计时,不需要调用系统调用。WebAssembly有指令可以直接获取CPU周期数,用这个来计时,开销几乎为零。

3. 缓存文件元数据。 原来每次访问文件都要调用stat获取文件元数据,我们改成了缓存元数据,在文件不修改的情况下,直接用缓存的元数据,不需要每次都调用系统调用。

通过这些优化,每个请求的系统调用次数从30多次降到了5次左右,减少了80%以上。这一步优化之后,QPS从320提升到了1200,提升了将近4倍。

优化二:降低单次系统调用开销。

减少了系统调用次数之后,我们继续优化单次系统调用的开销。

1. 用大缓冲区,减少缓冲区检查开销。 WASI系统调用会检查缓冲区是否在WebAssembly线性内存范围内,这个检查开销和缓冲区大小成正比。我们尽量用大的缓冲区,一次性读写更多数据,减少系统调用次数的同时,也减少了缓冲区检查的相对开销。

2. 预打开文件描述符,避免运行时打开。 WASI的文件打开是有开销的,而且打开的文件描述符会增加文件描述符表的大小,降低查找效率。我们把需要频繁访问的文件在启动的时候就预打开,运行时直接用,不需要每次都打开关闭。这样既减少了open/close的系统调用,也让文件描述符表保持较小的规模,查找更快。

3. 用stdout/stderr代替文件日志。 原来的日志是写文件的,需要打开文件、写文件、关闭文件,系统调用开销大。我们改成了写stdout/stderr,由宿主环境负责重定向到文件。这样WebAssembly模块只需要调用fd_write写标准输出,不需要管理文件,系统调用更简单,开销更小。

这一步优化之后,单次系统调用的开销降低了大约30%,QPS从1200提升到了1600。

优化三:解决锁竞争——多实例隔离和无锁设计。

这是最关键的优化,也是难度最大的。Wasmtime的全局锁导致系统调用串行,是高并发下的主要瓶颈。

1. 用多进程代替多线程。 原来我们是一个进程里多线程,每个线程跑一个WebAssembly实例。因为Wasmtime的全局锁是进程级的,多线程之间会竞争锁。我们改成了多进程,每个进程跑一个WebAssembly实例,进程之间不共享锁,各自独立运行。

这样每个进程的系统调用都在自己的锁里,不会和其他进程竞争。虽然每个进程的系统调用还是串行的,但多个进程可以并行,整体吞吐量就上去了。

我们用了4个进程(对应4核CPU),前面挂一个负载均衡,把请求分发到各个进程。这样CPU使用率从30%提升到了90%以上,QPS从1600提升到了3500,提升了一倍多。

2. 减少临界区大小。 虽然用了多进程,但每个进程内部还是有锁的。我们尽量减少锁的持有时间,把不需要在锁里做的事情移到锁外面。比如参数校验,原来都是在锁里做的,我们改成了在锁外面先做初步校验,进锁之后只做必须的操作,减少锁的持有时间。

3. 批量系统调用。 把多个小的系统调用合并成一个大的系统调用,减少获取锁的次数。比如把多次小的fdwrite合并成一次大的fdwrite,只需要获取一次锁。

这一步优化是效果最明显的,QPS从1600提升到了3800,达到了预期目标。

优化四:业务代码优化。

除了WASI层面的优化,业务代码本身也有优化空间。

1. 减少不必要的日志。 原来的代码里日志很多,调试日志、信息日志、错误日志,每个请求要打十几条日志。我们精简了日志,只保留必要的日志,调试日志在生产环境关闭。这样既减少了系统调用,也减少了CPU开销。

2. 用更高效的数据结构。 原来的代码里用了一些效率不高的数据结构,比如频繁查找用了Vec而不是HashMap,字符串处理用了很多clone。我们优化了数据结构,减少了不必要的clone和内存分配,提升了业务逻辑的执行效率。

3. 预分配内存。 WebAssembly的内存分配是有开销的,频繁分配释放内存会影响性能。我们对频繁使用的缓冲区做了预分配和复用,避免每次请求都分配释放内存。用了内存池,把用过的缓冲区回收起来,下次直接用,不需要重新分配。

这一步优化之后,QPS从3800提升到了4200,延迟也进一步降低了。

五、优化效果验证

经过一周的优化,我们做了最终的性能测试,同样的测试场景:wrk,10线程,100连接,30秒。

优化后的结果:

  • QPS:4200(优化前320,提升13倍)
  • 平均延迟:23ms(优化前280ms,降低92%)
  • P99延迟:85ms(优化前1000ms+,降低91%)
  • CPU使用率:92%(优化前30%)
  • 内存使用率:45%(基本不变)

这个结果达到甚至超过了我们的预期。WebAssembly版本的性能已经接近原生版本的80%,考虑到WebAssembly的沙箱隔离和安全开销,这个性能是可以接受的,完全满足生产要求。

我们还做了长时间稳定性测试,连续跑了24小时,服务稳定,没有内存泄漏,没有崩溃,性能没有下降。优化后的版本可以上线了。

六、WASI性能优化的经验总结

这次优化积累了很多经验,总结一下,给正在用WebAssembly做服务端开发的朋友一些参考。

1. 先定位瓶颈,再优化。 性能优化不要盲目优化,先搞清楚瓶颈在哪里,再针对性地优化。用profiler、埋点、对比测试等方法定位瓶颈,避免做无用功。我们这次就是先定位到了WASI系统调用的瓶颈,才少走了很多弯路。

2. 减少系统调用次数是最有效的优化。 WASI系统调用开销大,最有效的优化就是减少系统调用次数。批量处理、缓存、预分配,这些方法都能有效减少系统调用次数。一般来说,减少系统调用次数带来的性能提升,比优化单次系统调用开销大得多。

3. 注意Wasmtime的全局锁问题。 这是很多人容易忽略的问题。Wasmtime的WASI系统调用有全局锁,多线程下会有锁竞争,导致CPU跑不满。高并发场景下,建议用多进程代替多线程,每个进程跑一个实例,避免锁竞争。或者用支持多线程无锁的运行时。

4. 安全和性能需要权衡。 WASI的很多开销来自安全检查,比如参数校验、权限检查、缓冲区检查。这些检查保证了WebAssembly的安全性,但也带来了性能开销。在安全要求不那么高的场景,可以适当放宽一些检查,或者用更高效的检查方式,在安全和性能之间找到平衡。

5. WebAssembly适合IO少、计算多的场景。 从这次优化来看,WebAssembly在计算密集型场景下性能很好,接近原生。但在IO密集型场景下,因为系统调用开销大,性能会打折扣。所以WebAssembly更适合IO少、计算多的场景,比如图像处理、音视频编码、密码学计算、AI推理等。如果是IO密集型的Web服务,需要做更多的优化才能达到好的性能。

6. 关注WASI的发展。 WASI目前还是preview1版本,还在快速发展中。未来的WASI preview2会有很多改进,比如异步支持、更高效的系统调用接口、更好的多线程支持。这些改进会解决目前的很多性能问题。关注WASI的发展,及时升级到新版本,能享受到性能提升。

七、写在最后

这次WebAssembly系统接口性能优化,从QPS 320优化到4200,提升了13倍,过程很辛苦,但收获也很大。不仅解决了项目的性能问题,也深入理解了WebAssembly和WASI的工作原理,积累了性能优化的经验。

WebAssembly是一项很有前景的技术,它的沙箱隔离、跨平台、轻量级等特性,在边缘计算、Serverless、插件系统等场景下有很大的优势。但目前WebAssembly在服务端的应用还不够成熟,性能、工具链、生态都还有提升空间。

作为开发者,我们要了解WebAssembly的优势和局限,在合适的场景下使用它,并且针对它的特点做优化。这次优化的经验说明,只要方法得当,WebAssembly也能达到很好的性能,满足生产要求。

希望这篇文章能给正在用WebAssembly做开发的朋友一些参考和启发。如果你也在做WebAssembly相关的开发,欢迎交流经验。

最后用一句话结束本文:性能优化没有银弹,只有不断地定位、分析、优化、验证。找到瓶颈,对症下药,就能从慢到快。