zshrc 启动速度分析和优化

由于常年不科学的使用和随便塞东西,我的 .zshrc 里有太多太多的各类语言、SDK 的启动逻辑,因而它逐渐变得不堪重负起来。今天终于受不了了,我决定对它进行整理,移除部分太慢的代码,并且将部分不需要实时加载的东西懒加载。

速度优化的前期准备

要开始优化,首先需要有科学的评估速度的方法,这将使我们能够找到速度的瓶颈。

测算总启动速度

使用 time 命令,可以测算 zsh 的启动时间:

$ \time zsh -i -c exit
        6.62 real         3.23 user         2.29 sys

可以看到,我的 zsh 启动时间需要 6.62 秒,可以说是十分糟糕的速度了。作为对比,不加载任何启动命令脚本的 zsh 启动速度飞快,甚至只需要 0.01 秒:

$ \time zsh --no-rcs -i -c exit
        0.01 real         0.00 user         0.00 sys

设定优化目标

一个合理而可以达到的目标对速度优化也至关重要。今天,我的目标是不修改第三方代码的情况下,将加载时间优化到一秒以内,而尽量不损失任何功能。我们评估时间所用的方法就如同上文所述,以 time 测算的 real 时间为准。

找到速度瓶颈

找到速度瓶颈的方法通常是运行“性能评估”,也就是 Profile。由于 zshrc 是一个 bash 脚本,我需要寻找一些 zsh 脚本的 profile 方法。

一番查找之后,找到了 Profiling zsh shell scripts 这篇文章。按照文章中描述的方法,我们在 .zshrc 的最前面加入:

PS4=$'\\\011%D{%s%6.}\011%x\011%I\011%N\011%e\011'
exec 3>&2 2>/tmp/zshstart.$$.log
setopt xtrace prompt_subst

在最后面加入:

unsetopt xtrace
exec 2>&3 3>&-

然后执行 zsh -i -c exit 让 zsh 运行一遍初始化。执行完成后,你应该可以在 /tmp 下看到输出的结果:

$ ls -l /tmp/zshstart*.log
-rw-r--r-- 1 oott123 wheel 470745 Jun  9 16:26 /tmp/zshstart.8854.log

这个日志文件已经可以看了,只不过,人类似乎很难阅读……万幸的是,文章的作者也给出了一个工具,用于把输出的文件转换为 KCachegrind 可读的 callgrind 文件。然而,它是 OCaml 写的,也没有提供预编译二进制文件,我们必须先安装 OCaml 来编译运行它。为了速度,那就装吧!

由于我们在 macOS 下工作,通过 brew 可以很轻松的安装 OCaml、opam和 QCacheGrind(这里使用 QCacheGrind,效果和 KCachegrind 应该一样):

brew install ocaml
brew install opam
brew install qcachegrind --with-graphviz

然后使用作者提供的工具 zshprof 将日志文件生成 callgrind 文件,并使用 QCacheGrind 打开它:

git clone https://github.com/raboof/zshprof.git
cd zshprof
opam init # 完了,我们还没开始优化,又有一个程序往 .zshrc 里写东西了……这就是为什么它越来越卡
eval `opam config env`
opam install ocamlfind

# ocamlfind ocamlopt -linkpkg -thread -package str ZshXtraceToCallgrind.ml
ocamlfind ocamlopt -linkpkg Callgrind.ml -linkpkg ZshXtrace.ml -thread -package str ZshXtraceToCallgrind.ml
./a.out < /tmp/zshstart.8854.log > zsh.callgrind
qcachegrind zsh.callgrind

记住这个艰辛的过程,我们在优化途中会经常运行它,以检查优化效果。

打开 QCacheGrind,找到你的 zshrc 文件的源码,点击按 micros 排序:

QCacheGrind 主界面,其中显示了每行源码的耗时。

(由于我个人将自己的所有 rc 代码都写到了一个叫 .shellrc 的文件里,因此这里的截图是 .shellrc 这个文件的。)

很显然,大量类似 dvmphp-version 这样的版本管理工具初始化命令和他们的补全函数消耗了绝大多数时间。我们就从这些函数入手,优化我们的 shell 加载速度。

移除不必要的进程创建

不看不知道,一看吓一跳。就拿下面两句来看:

[[ -s "$(brew --prefix dvm)/dvm.sh" ]] && source "$(brew --prefix dvm)/dvm.sh"
[[ -s "$(brew --prefix dvm)/bash_completion" ]] && source "$(brew --prefix dvm)/bash_completion"

光这两句,就执行了 4 次 brew --prefix dvm,而这个命令在我的机器上执行一次需要 0.7 秒:

$ \time brew --prefix dvm > /dev/null
        0.71 real         0.37 user         0.19 sys

为了加载 dvm 而执行的 brew 就用掉了 4*0.71=2.84 秒,这还不算 dvm 本身的加载时间!而对于我而言,brew --prefix dvm 的结果执行一万次也不会改变,一定是 /usr/local/opt/dvm ——因为我知道我不会移动默认的 prefix 位置。那么,将 brew --prefix 命令都替换为字符串:

[[ -s "/usr/local/opt/dvm/dvm.sh" ]] && source "/usr/local/opt/dvm/dvm.sh"
[[ -s "/usr/local/opt/dvm/bash_completion" ]] && source "/usr/local/opt/dvm/bash_completion"

再跑个分试试:

$ \time zsh -i -c exit
        1.62 real         0.93 user         0.69 sys

哇,真是成效显著,一下就从 6.62 秒缩短到了 1.62 秒,整整减少了 5 秒钟!

移除 brew 之后 QCacheGrind 的样子

重新跑一次 profile,也可以看到,那些明显拖慢启动速度的项目已经没有了。顺便吐槽下:Ruby 和 brew 启动也太慢了吧……

现在,我们还剩下一些别的命令初始化和命令补全的耗时。这些命令看起来很好,只是他们的内部耗时相对较大。

懒加载命令和补全功能

为了不影响功能,又不修改第三方代码内部实现,我们无法很好的优化那些非常缓慢的命令初始化过程。但是,大多数情况下,我不会用到其中大多数命令。因此,我希望实现“懒加载”功能,在我首次使用这些功能的时候,zsh 帮我初始化他们。

懒加载的好处是启动快,缺点是运行的时候会比较慢。但这个慢是分散的,不会全部都卡在启动的那几秒钟里,所以我认为效果损失是可以接受的。那么,怎样懒加载他们呢?

一个很简单的方法是,把命令换成一个占位函数,然后在这个函数中再去执行真正的二进制文件。比如:

dvm() {
    # 移除占位
    unfunction "dvm"
    # 加载真正的 dvm
    source "$(brew --prefix dvm)/dvm.sh"
    # 加载 dvm 的补全
    source "$(brew --prefix dvm)/bash_completion"
    # 执行真正的 dvm 命令
    dvm "$@"
}

(上述代码实现参考自:Speed up initial zsh startup with lazy-loading,基于 CC-BY-SA 使用)

但这样,第一次使用 dvm 的时候,就没有命令补全了!于是,命令补全也用同样的方法做懒加载:

__lazycomp_dvm() {
    # 移除占位
    compdef -d dvm
    unfunction dvm
    source "$(brew --prefix dvm)/bash_completion"
}
compdef __lazycomp_dvm dvm

这样,在第一次按 Tab 的时候,就会加载 dvm 的补全。由于不同命令的补全逻辑不一样,所以还是没法第一次执行补全命令,但多按一次 Tab 键至少比多执行一次命令好多了。

把这两个懒加载方法写成函数:

my_lazyload_add_command() {
    local command_name=$1
    eval "${command_name}() { \
        unfunction ${command_name}; \
        _my_lazyload_command_${command_name}; \
        return ${command_name} \"\$@\"; \
    }"
}
my_lazyload_add_comp() {
    local command_name=$1
    local comp_name="_my_lazyload__compfunc_${command_name}"
    eval "${comp_name}() { \
        compdef -d ${comp_name}; \
        unfunction ${comp_name}; \
        _my_lazyload_comp_${command_name}; \
    }"
    compdef $comp_name $command_name
}

再把 dvm 等不太常用的命令初始化逻辑用这两个函数写出来,比如说这样:

_my_lazyload_command_dvm() {
    source "/usr/local/opt/dvm/dvm.sh"
}
_my_lazyload_comp_dvm() {
    source "/usr/local/opt/dvm/bash_completion"
}
my_lazyload_add_command dvm
my_lazyload_add_comp dvm

搞完之后再跑个分试试:

$ \time zsh -i -c exit
        0.37 real         0.24 user         0.18 sys

哇,已经只有 0.37 秒了。再看看 Profile,大头已经到了 oh-my-zsh 里:

最后的 Profile 结果

打开 zsh 一看,确实感觉快多了。试试那些被我们 lazy load 的命令,用起来感觉也没有什么延迟。优化初见成效,收工。

对了,完成之后记得把一开始我们添加到 .zshrc 中的几行 Profile 代码干掉。否则,它会创建一堆临时文件,并且它本身也会影响一点点速度。

如何让你的 .zshrc 更高效

这次优化,也让我看到平时写 shell rc 文件的一些常见误区,这些问题可能导致 shell 启动慢到爆表。总结说来,主要是这几点:

  • 尽量避免在初始化脚本中调用外部进程,特别是脚本语言的解释器,比如 node, ruby。他们的冷启动时间非常长,长到你怀疑人生。
  • 避免重复执行语句,特别是调用外部进程的语句。
  • 避免增加不必要的初始化代码。比如调用 nvm 初始化 node.js 版本。应当利用懒加载或者 avn 等方式来做这个事情,或者干脆写死环境变量。
  • 尽量多使用懒加载,避免加载不必要的函数。

其实今天本来还想顺便把 .zshrc 给模块化,并加入版本控制的,但调优它本身花掉了太多时间,这篇文章也足够长了,就下次再弄吧。