如何通过预读来加速 Electron 应用启动

启动速度对软件用户体验的影响是显而易见的,实验也表明,启动速度可影响用户对软件的使用时长。

就性能优化策略而言,预读(Prefetching)在提升应用程序启动速度方面有显著的作用。预读是如何运作的?为什么它可以加速应用的启动?如何适当地利用预读技术来优化我们的应用程序?

在本文中,我们将深入研究这些问题,详细介绍预读技术,并解释其如何提升启动速度。

1、缺页对启动速度的影响

在进程启动时,操作系统并不会将 PE 文件的所有内容全部加载到物理内存当中。而是等程序开始执行代码后,才会去加载该代码所在的文件内容。然而,如果这时相关代码所在的地址还没有被加载到内存中,系统就会引发一次缺页(Page Fault)错误。

这将导致系统必须去执行一个IO操作,将对应的代码从磁盘中读出加载到内存中,然后才能继续执行代码。如下图,在 Cpu Usage 视图可以看到 electron-demo.exe 在执行过程中触发了缺页错误,在107us后才恢复执行。在 “Hard Faults” 视图,可以看到此时发生了一个缺页事件,对应的文件名是 electron-demo.exe,该事件耗时118us。在”Disk Usage”视图中,我们代码执行前产生了一个IO 操作,耗时109us。

关于这几个时间为何不完全一致,我猜测可能是 ETW 事件的统计问题。但这并不妨碍我们看到 IO 事件完成后,CPU 才继续执行的事实。

虽然从单个缺页事件来看,它的处理耗时可能并不长,但如发生大量的缺页错误呢?选了一段启动过程(总长549ms),在这段时间产生的缺页事件总和达到了约150ms。虽然不是所有的150ms都会直接阻塞启动过程,但我们可以看出,缺对启动速度确实有着不容忽视的影响。

2、预读如何解决这个问题?
明白了系统并没有将所有代码一次性加载到物理内存后,一个可行的解决方案是尽早地让系统将全部代码加载进去,这种策略称为预读。
你可能会有疑问,预读的 IO 是否会消耗和缺页错误同样多的时间?是否只是将同样的耗时提前?针对这一问题,我们可以从观察缺页事件中得到的两个特点来解答:
a. 缺页事件通常比较零碎。比如,在这些事件中,读取32K(32768)大小的数据耗时大约是0.12ms,而读取4K(4096)大小的数据耗时也大约是0.12ms。也就是说,尽管数据大小为4倍,但并未消耗4倍的时间。显然,进行大块数据的 IO 操作更为划算。

b. 如果我们按时间排序缺页事件,会发现文件偏移(File Offset)经常忽前忽后地跳跃,也就是非顺序读取。这种读取方式会引发磁盘寻址。虽然在 SSD 磁盘上寻址时间可以忽略不计,但在机械硬盘上却可能会导致较长的耗时。

基于以上两个角度,我们如果能实现顺序的大块 IO 预读,就能成功缩短整体的 IO 时间。

3、业界怎么做预读?

chromium 中就有预读的代码,主要代码如下,main_dll_loader_win.cc 的 LoadModuleWithDirectory 会在加载 dll 之前调用 PreReadFile 进行预读。

MainDllLoader::LoadResult MainDllLoader::LoadModuleWithDirectory(
const base::FilePath& module) {
  ::SetCurrentDirectoryW(module.DirName().value().c_str());
  base::PrefetchResultCode prefetch_result_code =
      base::PreReadFile(module, /*is_executable=*/true).code_;
  HMODULE handle = ::LoadLibraryExW(module.value().c_str(), nullptr,
                                    LOAD_WITH_ALTERED_SEARCH_PATH);
return {handle, prefetch_result_code};
}

file_util_win.cc 中的 PreReadFile 会调用 PrefetchVirtualMemory 来预读了整个文件,需要注意的是 PrefetchVirtualMemory 在 win8 以后的系统上才有,在 win7 上 chromium 是直接调用的 ReadFile。

PrefetchResult PreReadFile(const FilePath& file_path,
                           bool is_executable,
                           int64_t max_bytes) {
  DCHECK_GE(max_bytes, 0);
// On Win8 and higher use ::PrefetchVirtualMemory(). This is better  than a
// simple data file read, more from a RAM perspective than CPU. This  is
// because reading the file as data results in double mapping to
// Image/executable pages for all pages of code executed.
static PrefetchVirtualMemoryPtr prefetch_virtual_memory =
      GetPrefetchVirtualMemoryPtr();
if (prefetch_virtual_memory == nullptr)
return internal::PreReadFileSlow(file_path, max_bytes)
               ? PrefetchResult{PrefetchResultCode::kSlowSuccess}
               : PrefetchResult{PrefetchResultCode::kSlowFailed};
if (max_bytes == 0) {
// PrefetchVirtualMemory() fails when asked to read zero bytes.
// base::MemoryMappedFile::Initialize() fails on an empty file.
return PrefetchResult{PrefetchResultCode::kSuccess};
  }
// PrefetchVirtualMemory() fails if the file is opened with write  access.
  MemoryMappedFile::Access access = is_executable
                                        ? MemoryMappedFile::READ_CODE_IMAGE
                                        : MemoryMappedFile::READ_ONLY;
  MemoryMappedFile mapped_file;
if (!mapped_file.Initialize(file_path, access)) {
return internal::PreReadFileSlow(file_path, max_bytes)
               ?  PrefetchResult{PrefetchResultCode::kMemoryMapFailedSlowUsed}
               :  PrefetchResult{PrefetchResultCode::kMemoryMapFailedSlowFailed};
  }
const ::SIZE_T length =
      std::min(base::saturated_cast<::SIZE_T>(max_bytes),
               base::saturated_cast<::SIZE_T>(mapped_file.length()));
  ::_WIN32_MEMORY_RANGE_ENTRY address_range = {mapped_file.data(),  length};
if (!prefetch_virtual_memory(::GetCurrentProcess(),
/*NumberOfEntries=*/1, &address_range,
/*Flags=*/0)) {
return internal::PreReadFileSlow(file_path, max_bytes)
               ? PrefetchResult{PrefetchResultCode::kFastFailedSlowUsed}
               : PrefetchResult{PrefetchResultCode::kFastFailedSlowFailed};
  }
return PrefetchResult{PrefetchResultCode::kSuccess};
}

4、chromium 的预读是否有改进的空间?

a、精细化预读:虽然 chromium 对自己的 DLL 文件进行了预读,但对于 electron 应用来说,可能并不需要所有的功能,因此某些代码根本不会被执行。那么,这些代码所在位置真的需要进行预加载么?如果我们通过捕获 ETW 缺页事件,确定何处会引发缺页,然后根据收集的数据做出精细化的预读,是否能缩短预读的时间呢?

b、预读更多的文件。chrome 只对部分文件做了预读,但是从实际使用上来看,还有一些其他的文件会产生缺页,我们也可以进行预读。

5、electron 怎么做预读?
很不幸,以 electron 目前的构架,不好做预读,electron 自身就是一个大的 exe,如果等到 main 入口后再对自己进行预读,已经有些晚了。要做预读的话,可能需要借助其他的 exe 文件,像是 launcher。这又引入了新的问题,增加 launcher 会增加启动的耗时。更好的方法可能是将 electron 拆分为一个小 exe 和一个大 DLL ,然后在小 exe 中预读大 DLL 文件。事实上,这正是 chrome 的实现方式。从这个角度看,electron 在集成 Chromium 时没学到位。

6、总结
在这篇文章中,我们探讨了如何利用预读技术优化 electron 应用的启动速度。首先理解了缺页对启动速度的影响,并了解Chromium 的预读代码。接着,我们发现了 Chromium 的预读实现还有一些改进的空间,并针对 electron 的情况,提出了一些可能的优化建议。
预读的目标是在代码执行之前,提前加载所需的数据,从而避免造成性能问题的缺页事件。对 electron 来说,受现有架构的影响,实施预读并非易事。
在未来,希望 electron 官方可以借鉴并改进 chromium 的预读,更好地优化其启动性能。

发表回复

您的电子邮箱地址不会被公开。 必填项已用 * 标注

*