前言
ServiceWorker给前端开发者提供了非常强大的缓存操控能力,灵活的请求拦截能力,和高效的消息推送能力。但我们在使用ServiceWorker相关能力编写PWA应用时,偶尔会发现性能并没有预期的那么好,这里面到底有什么玄机呢?
启动流程
我们先来看看ServiceWorker的启动流程,把ServiceWorker线程的整个启动流程划分为五大步骤:
步骤一: 进入启动流程。
一般来说,我们在访问一个含有ServiceWorker的页面主文档时,在发起主文档请求之前,它会先派发一个Fetch事件,这个事件会触发该页面ServiceWorker的启动流程。
content::ServiceWorkerControlleeRequestHandler::MaybeCreateJob // 准备创建主文档的Job
--> content::ServiceWorkerControlleeRequestHandler::PrepareForMainResource
--> content::ServiceWorkerControlleeRequestHandler::DidLookupRegistrationForMainResource
--> content::ServiceWorkerURLRequestJob::StartRequest
--> content::ServiceWorkerFetchDispatcher::DispatchFetchEvent // 从IO线程派发一个Fetch事件
--> content::ServiceWorkerVersion::DispatchFetchEvent
--> ServiceWorkerVersion::StartWorker
--> EmbeddedWorkerInstance::Start // 触发ServiceWorker的启动流程
步骤二:分派进程(多进程模式)/ 线程(单进程模式)。
ServiceWorker启动之前,它必须先向浏览器UI线程申请分派一个线程,再回到IO线程继续执行ServiceWorker线程的启动流程。
content::EmbeddedWorkerInstance::Start
--> content::EmbeddedWorkerInstance::RunProcessAllocated
--> ServiceWorkerProcessManager::AllocateWorkerProcess // from IO thread
--> ServiceWorkerProcessManager::AllocateWorkerProcess // PostTask to UI thread
--> ServiceWorkerProcessManager::AllocateWorkerProcess // from UI thread
--> content::EmbeddedWorkerInstance::ProcessAllocated // from IO thread
--> content::EmbeddedWorkerInstance::RegisterToWorkerDevToolsManager // from IO thread
--> content::EmbeddedWorkerInstance::RegisterToWorkerDevToolsManager // PostTask to UI thread
--> content::EmbeddedWorkerInstance::RegisterToWorkerDevToolsManager // from UI thread
--> content::EmbeddedWorkerInstance::SendStartWorker // from IO thread
--> content::EmbeddedWorkerRegistry::SendStartWorker
--> content::EmbeddedWorkerDispatcher::OnStartWorker
这个过程中,我们可以看到非常多的线程转换,IO --> UI --> IO --> UI --> IO。如果能够减少这些线程转换,是否能提升性能?
步骤三:加载serviceworker.js。
分派了ServiceWorker线程之后,就会继续执行serviceworker.js的加载流程。
content::EmbeddedWorkerDispatcher::OnStartWorker
--> blink::WebEmbeddedWorkerImpl::startWorkerContext
--> blink::WebEmbeddedWorkerImpl::loadShadowPage // 加载一个与serviceworker.js相同URL的空白文档
--> blink::FrameLoader::load // 触发空白文档的加载
--> ... ...
--> blink::WebEmbeddedWorkerImpl::didFinishDocumentLoad
--> blink::WebEmbeddedWorkerImpl::Loader::load // 触发真实serviceworker.js的加载
--> content::ResourceDispatcherHostImpl::BeginRequest
--> content::ServiceWorkerReadFromCacheJob::Start
--> content::ServiceWorkerReadFromCacheJob::OnReadComplete
--> ResourceLoader::didFinishLoading // 完成serviceworker.js的加载
这个过程中,它会先加载一个空白文档,再去加载serviceworker.js,即会走两次完整的加载流程。
步骤四:启动ServiceWorker线程。
serviceworker.js加载完成之后,就会触发ServiceWorker线程的启动流程
blink::ResourceLoader::didFinishLoading
--> blink::WorkerScriptLoader::didFinishLoading
--> blink::WebEmbeddedWorkerImpl::onScriptLoaderFinished
--> blink::WebEmbeddedWorkerImpl::startWorkerThread
--> blink::ServiceWorkerGlobalScopeProxy::create
--> blink::ServiceWorkerThread::create
--> blink::WorkerThread::start // 启动ServiceWorker线程
这个过程中,主要包括创建ServiceWorkerGlobalScope,初始化上下文(WorkerScriptController::initializeContextIfNeeded), 和执行JS代码(WorkerScriptController::evaluate)。
步骤五:回调通知ServiceWorkerVersion启动完成。
ServiceWorker线程启动完成之后,回调通知ServiceWorkerVersion,至此,ServiceWorker线程启动完成。
WebEmbeddedWorkerImpl::startWorkerThread // 启动serviceworker线程
--> new ServiceWorkerThread::ServiceWorkerThread
--> content::ServiceWorkerDispatcherHost::OnWorkerStarted
--> content::EmbeddedWorkerRegistry::OnWorkerStarted
--> content::EmbeddedWorkerInstance::OnStarted
--> content::ServiceWorkerVersion::OnStarted // 启动serviceworker线程完成
启动性能
从上面可以看到,ServiceWorker的启动流程极其复杂,这么复杂的启动流程,会带来怎样的性能消耗呢?
我们在下面详细分析上述五大步骤的性能消耗(测试数据来自Chromium57内核版本):
步骤
|
覆盖安装, 首次启动
|
重启浏览器, 首次启动
|
不退出浏览器, 再次启动
|
保持SW页面不关闭, 锁屏, 开屏, 启动SW
|
---|---|---|---|---|
步骤一: 进入启动流程 | 2ms | 1ms | 1ms | 1ms |
步骤二:分派进程/线程 | 265ms | 151ms | 37ms | 56ms |
步骤三:加载serviceworker.js | 757ms | 37ms | 20ms | 108ms |
步骤四:启动ServiceWorker线程 | 33ms | 29ms | 23ms | 186ms |
步骤五:回调通知启动完成 | 2ms | 2ms | 2ms | 1ms |
1059ms | 220ms | 83ms | 352ms |
说明:上面数据来自本地测试数据,并非线上数据,不能完全代表用户的实际数据,但在各阶段的耗时趋势上,还是可以参考的。
注1:覆盖安装浏览器,第一次启动SW, 分派进程/线程的耗时265ms, 其中UI线程的耗时超过180ms,即UI非常繁忙。加载serviceworker.js的耗时为757ms,主要消耗在创建https连接和等待页面服务响应。
注2:重启浏览器, 第一次启动SW, 分派进程/线程的耗时151ms, 其中UI线程的耗时超过120ms,即UI非常繁忙。加载serviceworker.js的耗时为37ms,因为可以从缓存中读取。
注3:不退出浏览器,第二次启动SW, 分派进程/线程的耗时37ms, UI相对空闲。加载serviceworker.js的耗时为20ms,估计是一部分内容可从内存中读取。
注4:保持SW页面不关闭,锁屏,开屏,启动SW,分派进程/线程的耗时56ms, 加载serviceworker.js的耗时为108ms,启动SW线程的耗时为186ms。
从上述数据可以看到,
- 分派ServiceWorker进程/线程的过程中,有非常多的线程转换,IO --> UI --> IO --> UI --> IO,这个过程如果UI线程非常繁忙,耗时会非常大,甚至可以超过200ms。
- 加载serviceworker.js,首次加载需要创建https连接和等待服务器响应,耗时可以超过700ms,但在非首次的场景下,可以从缓存读取,一般能在50ms以内完成。
- 启动ServiceWorker线程,包括创建ServiceWorkerGlobalScope,初始化上下文(WorkerScriptController::initializeContextIfNeeded), 和执行JS代码(WorkerScriptController::evaluate), 这些过程一般能在50ms内完成。
- 手机锁屏开屏的场景下,浏览器大部分内存都会被清除,会极大的影响缓存读取以及对象创建的时间,比如创建v8 isolate,一般能在10ms完成,但锁屏之后要80ms才能完成。
启动优化
Chromium官方文档 提到, ServiceWorker的启动时间与用户设备条件有关,在PC上一般为50ms,手机上大概为250ms。在极端的场景下,比如在低端手机且CPU压力较大时,可能会超出500ms。Chromium浏览器已尝试使用多种方式来减少ServiceWorker的启动时间, 比如,
- 使用V8 Code Cache(using code-caching in V8)。
- 在没有注册监听fetch事件的页面允许先发网络请求(skipping service workers that don't have a fetch event)。
- 在特定情境下(比如,mouse/touch事件), 预先启动ServiceWorker(launching service workers speculatively)。
- 使用 Navigation Preloads 技术, 允许fetch请求在ServiceWorker还未启动完成时就可发出, 从而减少启动时间对总体性能的影响。
从我们的测试数据来看,ServiceWorker线程的启动耗时一般在100-300ms,与Chromium官方的数据相近。所以,我们能够得出一个大概的推论,ServiceWorker线程的启动是有较大成本的,一般在100-300ms。
那么,我们有没有一些比较有效的办法,尽可能降低ServiceWorker线程的启动耗时呢?
一些可能的办法,
(1)支持Code Cache,可以降低ServiceWorker JS的解析编译时间。(Chromium M53实现)
其中,Chromium M42 支持serviceworker.js的Code Cache。 参考:Support V8 code caching for ServiceWorker script
Chromium M53 支持CacheStorage的Code Cache。参考:Support V8 code caching in CacheStorage
(2)ServiceWorker线程启动不阻塞网络请求,即可以在启动过程中,同时发送网络请求。(Chromium M57实现)
其中,Chromium M57开始支持Navigation Preloads 技术,还在持续完善中。参考:Implement Service Worker navigation preload
(3)减少ServiceWorker线程启动过程的线程抛转。
从前面可以看到,ServiceWorker线程启动的过程有较多的线程抛转,特别是抛转到UI的过程,可能会非常耗时。
值得一提的是,Chromium官方对ServiceWorker启动性能问题也非常重视,他们有了非常全面的优化计划,请参考:Service Worker: performance roadmap, 有Chromium的全力投入,我们相信问题可以得到较好的解决。
参考文档
Improve service worker startup time
Speed up Service Worker with Navigation Preloads
Service Worker: performance roadmap