vlambda博客
学习文章列表

WebView稳定性提升30%+, X5内核多进程实践



前言

X5内核是QQ浏览器团队基于开源浏览器项目 Chromium 深度优化的浏览器内核,在安装包大小、启动速度、加载速度、流畅度、内存占用、稳定性、安全性、兼容性等方面均有较多积累,同时在合规、防劫持、同层框架、音视频等拓展能力上提供了完备的支持,被包括手机 QQ、QQ 浏览器、腾讯视频等公司内外上万款 App 集成,每天服务数亿用户。


浏览器内核因为实现复杂且服务的场景非常多样,在稳定性和安全性上都面临巨大挑战。虽然国内大部分浏览器内核厂商都支持动态化下发内核的能力,可以快速修复线上问题,但是受限于网络、宿主App版本等等原因,最新内核始终无法覆盖 100% 的用户,导致仍有相当数量的用户暴露在风险中。


X5 内核基于 Chromium 项目 Android WebView 架构研发。众所周知,Chromium 是通过多进程机制来缓解稳定性和安全性问题的,其将复杂且易被攻击的、容易产生远程代码执行漏洞的 V8 JavaScript 虚拟机和 Blink 解析排版引擎放到受沙箱保护的 Renderer 进程中。Chromium 的进程模型如下如所示:


WebView稳定性提升30%+, X5内核多进程实践

图源:https://developers.google.com/web/updates/2018/09/inside-browser-part3




其中
  • Browser:负责管理 WebView 或浏览器 Tab 实例(包括运行 UI、管理导航请求和历史栈等),发起实际的网络请求加载网页资源和访问文件等等

  • Renderer:负责网页内容解析、排版、绘制、JavaScript 执行等

  • GPU:负责 GL 指令的实际执行与上屏等

  • Plugin:负责控制网页中的插件

  • Utility:用于执行短期的任务


在 Chromium 多进程架构的支持下,即使 Renderer 进程发生稳定性 Crash,其他进程也均不受影响,并且可以通过重新加载等方式重启 Renderer 进程恢复网页。


WebView稳定性提升30%+, X5内核多进程实践


在沙箱机制的保护下,即使发生了漏洞攻击的行为,Chromium 也能保证用户数据安全无虞。


WebView稳定性提升30%+, X5内核多进程实践


这篇文章介绍的就是 X5 内核如何应用多进程和沙箱机制,并且过程中如何解决动态化、启动耗时等一系列问题的。



多进程架构

我们重点关注 Browser 进程和 Renderer 进程。
WebView稳定性提升30%+, X5内核多进程实践


单进程模式下,Browser 与 Renderer 均位于同一个进程中,Android WebView 下就是都在应用程序使用 WebView 的进程中。

Android WebView 多进程模式下,Renderer 位于独立的沙箱进程中,Renderer 进程与应用程序使用 WebView 的进程(简称 Browser 进程)一一对应:不管在一个 Browser 进程中打开多少个 WebView 实例,始终只有一个对应的 Renderer 进程;应用程序多个进程使用 WebView 时,即多个 Browser 进程,则会有对应的多个 Renderer 进程。


WebView稳定性提升30%+, X5内核多进程实践



沙箱多进程机制

在 Android 平台实现的沙箱进程机制共有 2 层,一层是 isolatedProcess,在 AndroidManifest.xml 中声明 android:isolatedProcess="true" 的进程(如下图所示),与系统的其余部分隔离,表现就是它和应用程序中非 isolated 的进程 uid 不同,该进程没有任何权限,除了应用程序内置的文件和资源,不能访问其他任何设备能力和目录,包括应用程序自身的 /data/data 目录。


WebView稳定性提升30%+, X5内核多进程实践


第二层是 Seccomp BPF 层,Seccomp BPF 是 Linux 内核提供的一种过滤系统调用的机制,在运行时通过 prctrl 或 seccomp 函数设置一段过滤用的字节码,可以选择哪些系统调用在什么条件下可以放开,默认只能调用 exit/sigreturn 函数,或读写已经打开的文件描述符。


WebView稳定性提升30%+, X5内核多进程实践




行业观察

Seccomp BPF 是运行时开启,逆向确认是否开启并不容易,不过对浏览器内核来说开启难度并不大,只需要屏蔽运行时受限制的系统调用接口就可以,所以借鉴意义不大。


事实上 isolated 进程的开启是有一定难度的,基于 Chromium 内核的浏览器厂商,均是内置 Chromium 内核,虽然都是多进程模式,但除了微软的 Edge,均未启用 isolated 进程。

Firefox 浏览器内置自有的 Gecko 内核,也是多进程模式,未开启 isolated 进程。


WebView稳定性提升30%+, X5内核多进程实践


各位读者看到这里可能会有疑问,既然大部分浏览器内核都是基于 Chromium 内核,直接开启 isolated 进程不就可以,为什么国内以及国外的大多数浏览器厂商均未开启呢?

这里我们先来介绍下系统内核与 X5 以及国内大部分支持动态化的其他浏览器厂商内核的差异点。

系统内核是一个单独的 Android 应用程序 APK,其通过 OTA 更新,走应用程序安装流程,更新过后仍然是从应用程序安装目录加载代码和 so;国内各厂商则是通过插件的方式将内核包下载下来并安装到自定义目录,运行时从自定义目录加载内核 dex 和 so,启用 isolated 进程面临下载的内核 dex 和 so 文件无法从自定义路径加载的问题。


受 Android 系统的限制,Service 必须注册在 AndroidManifest.xml 中,并且 Service 类必须位于应用程序主 dex,否则系统找不到该 Service 就无法初始化。也就是我们这些动态化内核仍然需要在宿主的 AndroidManifest.xml 中注册内核子进程 Service,并且将该 Service 类放在内核 SDK 中随宿主一起打包,所以内核子进程初始化必须先加载宿主应用程序主 dex 并初始化宿主 Application 类,这与系统内核的实现有很大不同。

WebView稳定性提升30%+, X5内核多进程实践

X5 内核多进程架构


前面说了系统内核是一个已安装的 APK,其内核子进程是直接注册在该 APK 中的,系统内核的子进程初始化与宿主应用完全没有关系,初始化时加载系统内核 APK 的主 dex 并初始化其 Application。甚至系统内核会选择以 32 位模式运行内核子进程,无论应用程序是 32 位还是 64 位,即系统内核存在 Browser 进程是 64 位,Renderer 进程是 32 位的情况,极大地降低 Renderer 进程的内存占用。


WebView稳定性提升30%+, X5内核多进程实践

Android 系统内核多进程架构


正是因为这些差异,导致启用 isolated 进程面临下载的内核 dex 和 so 文件无法从自定义路径加载。

虽然国外的浏览器厂商不存在动态化的问题,但是启用 isolated 进程还会存在启动耗时、内存膨胀等等。

如此种种都导致大部分浏览器厂商没有开启 isolated 进程。

接下来我们将一一分析,并对比系统内核和 Chrome 浏览器,以及最终 X5 采用了何种解决方案。



X5 解决方案

SDK 设计

前面提到因为 Android 系统限制,动态化的内核需要在宿主的 AndroidManifest.xml 中注册内核子进程 Service,并随宿主一起打包 Service 类;又因为 isolated 进程的限制,无法从自定义路径加载内核 dex。由于子进程运行时需要的 Java 代码比较少,自然地,我们可以想到提取这部分代码全部打到 SDK 中就可以解决上述问题。

但是该方案增加了 SDK 包大小,当前仍然有不少宿主对包大小非常敏感;同时子进程初始化逻辑并不是一成不变的,内核版本升级后,因为动态下发的内核与应用程序集成的 SDK 并不同步,存在高版本内核与低版本 SDK 子进程加载逻辑不兼容的问题。

事实上 Android 系统只限制 Service 注册和类定义必须位于宿主应用程序中,考虑到包大小和动态化兼容性,我们可以保证这部分代码足够精简,然后采用 dex 的方式动态加载子进程初始化代码。

这里我们采用了代理模式,将 SDK 中的 Service 类做成薄薄的一层,其中只包含子进程 dex 加载逻辑,具体的子进程初始化逻辑则是委托给 dex 中的 Service 实现类处理。


WebView稳定性提升30%+, X5内核多进程实践


接下来我们需要解决的就是 dex 和 so 动态加载的问题。




动态化

WebView稳定性提升30%+, X5内核多进程实践

X5 内核沙箱多进程动态化解决方案


前面我们说到开启 isolated 进程后就不能直接从自定义路径加载 dex 和 so,为解决该问题我们尝试了不少解决方案:


dex 加载

因为 isolated 进程的限制,直接从自定义路径构造 DexClassLoader 会抛 FileNotFoundException。
前面我们也提到,isolated 进程可以读写已经打开的文件描述符,那么在 Browser 进程打开 dex 文件并将其文件描述符传递给 Renderer 进程并加载是否可行呢?


首先需要解决文件描述符传递的问题:
1.直接将文件描述符的整数值通过绑定 Service 的 Intent 带过去并不可用,事实上 Android 平台下文件描述符必须通过 Binder 通信机制传递,传递过去的文件描述符数值与之前的也并不相等。
2.获取 Intent 的 Bundle 并用 putParcelable 把文件描述符塞进去也存在问题,运行时抛 RuntimeException:
WebView稳定性提升30%+, X5内核多进程实践
3.最终的处理方案是自定义一个 Binder 类型,在 onTransact 方法中响应文件描述符,其实就是手写了一个 AIDL 方法。

文件描述符传递的问题解决了,如何从文件描述符加载 DexClassLoader 呢?我们尝试了直接从 /proc/self/fd 目录下加载,虽然该目录下的文件链接了对应文件描述符的原始文件,但是直接加载会抛:
WebView稳定性提升30%+, X5内核多进程实践



何以为之?最终我们在学习 Android 源码的过程中发现了一个 Android 8.0 新引入的 API InMemoryDexClassLoader,可以从 ByteBuffer 中加载 dex。当前 Android 8.0 以下的用户数日趋减少,兼容性限制是可以接受的,不过这个过程还是存在一个坑:用 dx 工具打包出来的 dex 是一个压缩包,虽然能被 DexClassLoader 加载但是不能直接用 InMemoryDexClassLoader 加载,后者必须解压出其中的 classes.dex 才能加载。


so 加载

so 同样尝试了从 /proc/self/fd 目录下加载,产生的错误与从自定义路径加载一致。

与 dex 不同的是,Android 系统提供了从 fd 加载 so 的方法,即 android_dlopen_ext 函数,但无奈的是,从自定义路径加载仍然会遇到以下错误:
WebView稳定性提升30%+, X5内核多进程实践


幸好 Chromium 为了多进程下节省内存共享 relro section 提供了 android_dlopen_ext 的替代实现——Crazy Linker,我们得以快速跟进这个问题产生的原因。实际上就是从文件描述符 mmap 可执行段时无法赋予该内存区域执行权限。但是受 v8 JIT 的启发,我们可以确认的是 isolated 进程也是可以申请具可执行权限的内存区域的,要不然 v8 JIT 生成的机器码就没办法运行了。mmap 不行,那就直接将这块数据从文件描述符读到内存中,再通过 mprotect 赋予可执行权限。

如此这般,so 可以正常加载了,但是运行时又遇到 JNI 方法找不到的问题,因为内核 so 的所有 JNI 方法都是静态注册的,在用 System.load 方法时系统链接器会帮我们完成 Java 方法与 JNI 方法的绑定;换成了 Crazy Linker 则需通过 JNIEnv::RegisterNatives 方法手动注册。


Bingo!动态化问题全部解决,不过还有个小瑕疵,Crazy Linker 加载的 so 在 /proc/self/maps 中没有名字,导致回溯栈和解栈不方便,可以用下述方法设置一个格式为 [anno:<soname>] 的名字:
WebView稳定性提升30%+, X5内核多进程实践




启动优化

isolated 进程运行起来后,我们发的实验室版本数据却令人大失所望,大部分用户触发了 10 秒绑定超时。

一开始,我们认为是宿主应用 Application 初始化存在与 isolated 进程不兼容的逻辑,导致初始化过程中出现异常,Renderer 进程启动失败,进而导致的绑定超时。

后来我们对比了 Renderer 进程发起绑定数和绑定成功数,确认了所有的超时均为 isolated Renderer 进程绑定慢,因为最终绑定都成功了。


通过 System Tracing 我们可以看到:
1.非 isolated 进程可以直接从 base.odex 加载:
WebView稳定性提升30%+, X5内核多进程实践

2.isolated 进程则必须从 apk 中解压 dex 加载:
WebView稳定性提升30%+, X5内核多进程实践


前面我们说了,isolated 进程与非 isolated 进程有一个明显的区别是 uid 不同,跟踪后我们发现应用程序生成的 odex 文件并不总是 World Readable,从而导致拥有不同 uid 的 isolated 进程并不能读取应用程序生成的 base.odex 文件。


那为啥 Android 系统内核和 Chrome for Android 没有这个问题?

Chrome for Android 针对 odex 非 World Readable 的情况也做了处理,会单独执行以下命令以 speed 配置重新生成 World Readable 的 base.odex:
WebView稳定性提升30%+, X5内核多进程实践

系统内核则由 Android 做了特定的优化,其 odex 始终是 World Readable 的。


那我们是否可以采用上述重编 base.odex 的方式优化启动耗时?

经测试,在华为 P30 Pro(Android 10)和荣耀 V10(Android 9)系统上,分别可以将被测宿主下的 isolated 进程启动耗时由平均 2.1 秒和 4.6 秒降低到 113 毫秒和 132 毫秒。

但该方案也存在一定的负面影响,主要在于:
  1. 执行重编 base.odex 的命令耗时较长,在上述 2 个机型分别耗时 63 秒和 128 秒

  2. 执行上述命令在部分机型上比较耗费 CPU 和内存资源,实测荣耀 V10(Android 9)上 CPU 占用率峰值可以达到 50%+

  3. 执行上述命令期间 base.odex 不可用,导致应用程序其他进程启动时也不能直接加载 odex 文件

  4. 另外也存在生成的 base.odex 文件较大的问题


综上,由于叠加 2 和 3 的效果,在执行上述命令期间被测应用重启或启动其他子进程耗时会显著增加,并影响运行时性能,表现为用户操作比较卡顿。

这些现象 Chrome for Android 也存在,但是表现并不明显,原因在于 Chrome for Android 的 classes.dex 的个数较少、总大小较小,执行命令耗时、odex 大小和进程启动耗时都与 classes.dex 的总大小成正比。在上述 2 个机型执行重编 base.odex 的命令分别只要 12 秒和 21 秒,CPU 占用率也较低。

按照 Chromium 的解释,base.odex World Readable 的问题是系统 Bug,在 Android 11 以上的版本已解决,但根据我们的实验室版本数据显示,Android 11 以上仍有部分机器仍然不可读,同时发现 Android 11 以下版本部分机器是可读的,总体上 base.odex World Readable 的比例为 37% 左右,且呈 Android 版本越高比例越大的趋势。


除了启动耗时,isolated 进程还会存在下述内存膨胀的问题:
  1. 在不开启 isolated 进程的情况下,荣耀 V10 和华为 P30 Pro 上 Browser 进程与 Renderer 进程合计总 PSS 比单进程分别多 23M 和 34M
  2. 在开启了 isolated 进程且 odex 可读的情况下(线上大约 37% 用户 odex 可读),2 台机器总 PSS 比单进程分别多 94M 和 101M
  3. 在开启了 isolated 进程且 odex 不可读的情况下,2 台机器总 PSS 比单进程分别多 210M 和 203M


isolated 进程 PSS 占用主要是 Unknown SwapPss Dirty 占用非常多:

WebView稳定性提升30%+, X5内核多进程实践


非 isolated 进程以及其他的普通进程 Unknown SwapPss Dirty 占用都在0~2K 之间,但是 isolated 进程这一项就占几十上百 M。

结合 isolated 进程在启动耗时和内存占用上的影响,为了平衡安全性和用户体验,X5 只会在应用程序 base.odex World Readable 时才会启用 isolated 进程和 Seccomp BPF 2 层沙箱机制,否则只开启 Seccomp BPF。



内核子进程 dex 减包

默认地,包括系统内核在内,Renderer 进程加载的 dex 和 Browser 进程加载的完全一致。事实上需要在 Renderer 进程执行的代码非常少,主要就是一些 Android Service 初始化和进程启动的代码。加载同样的 dex 导致内核中很多与 Renderer 进程无关的代码也被加载,不仅增加了 Renderer 进程初始化启动耗时,也提高了 Renderer 进程内存占用,还存在 Renderer 进程中调用 Browser 进程才能调用代码的风险。

基于此,X5 内核果断将 dex 进行拆分,独立出一个专供内核子进程加载的 dex,这个 dex 只保留子进程访问的类型。

优化后,Renderer 进程 dex 的加载时间由之前的 300 多毫秒降低为 10 毫秒,dex 内存占用由之前的 6~7M 降低为 200K。同时快速发现了在 Renderer 进程中进行上报、获取云控开关等不合理逻辑。




宿主 Application 初始化屏蔽

前面我们说到,动态化内核的 Renderer 进程启动必须经历宿主 Application 的初始化逻辑。但是这部分逻辑对 Renderer 进程来说完全是多余的,徒增 Renderer 进程启动耗时;而且初始化的逻辑如果在后续访问网络或其他设备能力,会被 Seccomp BPF 机制捕获导致异常发生。

因此,X5 内核配合宿主应用在 Application 中判断是内核子进程主动屏蔽初始化逻辑。

优化后,内核子进程绑定 3 秒超时率从 21.3% 降为 4.26%,绑定耗时 90 分位从 2.6 秒降为 1.8 秒。


WebView稳定性提升30%+, X5内核多进程实践

子进程绑定 3 秒超时率


WebView稳定性提升30%+, X5内核多进程实践
子进程绑定耗时(单位:秒)







总结与展望

选择多进程方案最开始的目标就是稳定性和安全性:
稳定性方面,内核多进程因为将 Renderer 挪到单独的进程中,在 Renderer 进程发生 Crash 时并不会引起 Browser 进程 Crash,Browser 进程可以通过重新加载页面等操作重启 Renderer 进程恢复网页,降低了应用程序 Crash 率。同时内核多进程机制减轻了 Browser 进程的内存压力,也因此降低了内存不足的概率,连带地,由内存不足引起的应用程序无响应(ANR)的概率也同步降低。


WebView稳定性提升30%+, X5内核多进程实践

内核 Crash 率


内核 OOM 率


安全性方面,Seccomp BPF 机制和 isolated 进程机制隔绝了 Renderer 进程的系统调用和数据访问。因为 Seccomp BPF 是运行时启用且可编程的,所以在启用前以及过滤逻辑有漏洞时,都可能发生沙箱逃逸。叠加 isolated 进程会更安全一些,但是因为 isolated 进程隔离的非常彻底,所以存在不能读取应用程序主进程生成的 base.odex,不能共享内存区域的问题,导致启动耗时和内存占用都非常高,在某些情况下的用户体验是不能接受的。鱼和熊掌不可兼得,X5 内核不得已采取折中的手段,只在 base.odex World Readable 时才会启用 isolated 进程和 Seccomp BPF 2 层沙箱机制,否则只开启 Seccomp BPF。值得庆幸的是,随着 Android 版本的升级,base.odex World Readable 的比例是越来越高的,所以 isolated 开启的比例也会越来越高。

内核多进程的实践过程,就是不断发现问题、不断探索解决方案、不断权衡利弊、不断迭代优化的过程。因为系统限制比较多,颇有一种“螺蛳壳里做道场”的感觉。


总结经验:
  1. 答案大部分都在源码中:InMemoryDexClassLoader、Crazy Linker 和 base.odex 重编等都是从源码中找到的解决方案;mmap so 可执行段权限不足,也是通过 v8 JIT 确认可行
  2. 最小集合:子进程 Java 代码 dex 化就是我们区分了系统限制的最小集合,将不受限制的部分剥离出来做成 dex 解决包大小和兼容性问题


最后,浏览器内核对稳定性、安全性以及流畅性的追求是无止境的,我们也一直在探索各种业务场景下多进程的利弊平衡。

比如多 Renderer 进程的探索。多 Renderer 进程下可以保证在某一个 Renderer 进程 Crash 时,其他的 Renderer 进程不受影响,它们加载的页面还能正常显示。单 Renderer 进程下一旦 Crash,则所有的页面都会异常,需要全部重新加载才能恢复。同时多 Renderer 进程下就会有多个 Renderer 主线程,每个线程并行处理,页面性能不会相互影响。单 Renderer 则不然,其只有一个 Renderer 主线程,所有页面的解析、排版、绘制、JavaScript 执行都在这个线程上串行处理,一旦阻塞或卡顿,会连带影响所有的页面。但是多 Renderer 也会带来内存膨胀的问题,这在很多 WebView 使用的场景中是不可接受的,因为 WebView 只是应用程序使用的一个组件,该应用程序并不主要服务网页浏览场景,占用过多内存对应用程序的整体体验是有负面影响的。

其他的,比如独立 Browser 进程,普通 View(非 SurfaceView)下的独立 GPU 进程,多进程和 isolated 进程内存持续优化等等都是我们一直在探索的课题。

在QQ浏览器实验室,交流技术创新