Zsh за 30 мс: быстрый терминал без фреймворков

Обновление: один из читателей прислал обоснованные возражения по поводу того, как я измерял «быстродействие», и по паре моих рекомендаций. Я написал продолжение — о правильном бенчмаркинге оболочек и о том, в чём этот пост не дотягивает.

Практически вся моя работа происходит в терминале. Git, kubectl, tmux, SSH-подключения к серверам — всё это открыто почти целый день. Инструмент, которым пользуешься настолько много, обязан быть быстрым. Любая задержка при открытии новой вкладки, наборе символа или нажатии Tab для автодополнения ощущается сотни раз в день. Это смерть от тысячи порезов.

Моя оболочка запускается примерно за 30 миллисекунд:

$ for i in {1..5}; do /usr/bin/time zsh -i -c exit; done
0.03 real 0.02 user 0.01 sys
0.03 real 0.02 user 0.01 sys
...

Это полноценная интерактивная оболочка с автодополнениями, подсветкой синтаксиса, автоподсказками, fzf и direnv — и всё это укладывается в меньше времени, чем один кадр при 30 fps. Новая вкладка открывается мгновенно. Никакого специального проекта по оптимизации за этим не стоит: я всегда держал конфигурацию оболочки минималистичной и быстрой, и со временем это превратилось в привычку. Вот как я к этому подхожу — всё можно найти в моём репозитории dotfiles.

Никаких фреймворков

Самый большой выигрыш даёт то, чего нет: ни oh-my-zsh, ни prezto, ни менеджера плагинов. Честно говоря, я никогда не понимал привлекательности этих фреймворков. Люди устанавливают oh-my-zsh с его сотнями плагинов и тем, в итоге используют от силы 5% возможностей, а за оставшиеся 95% платят своим временем и вычислительными ресурсами при каждом открытии оболочки. Менеджеры плагинов добавляют к этому ещё собственные накладные расходы.

Я использую ровно три плагина, которые мой установочный скрипт клонирует через git один раз, а .zshrc подключает их через source:

source ~/.zsh/fzf-tab/fzf-tab.plugin.zsh
source ~/.zsh/zsh-autosuggestions/zsh-autosuggestions.zsh
source ~/.zsh/zsh-syntax-highlighting/zsh-syntax-highlighting.zsh

Никакого менеджера плагинов, который разрешал бы зависимости при запуске, — а source файла, уже лежащего на диске, обходится практически бесплатно.

Кэширование автодополнений

compinit — одна из самых затратных операций в типичном .zshrc. По умолчанию при каждом открытии оболочки она проводит аудит безопасности для каждого файла автодополнений. Решение: выполнять полный запуск только в том случае, если кэш (.zcompdump) старше 24 часов, а в остальное время пропускать проверку с флагом -C:

autoload -Uz compinit
if [[ -n ~/.zcompdump(#qNmh-24) ]]; then
compinit -C
else
compinit
fi

Квалификатор глоба (#qNmh-24) означает «файл существует и был изменён в течение последних 24 часов». Таким образом, полный compinit запускается один раз в сутки, а всё остальное время используется кэш.

Отложенная загрузка (lazy loading)

nvm — пожалуй, самый известный убийца скорости запуска оболочки: нетерпеливый (eager) source этого инструмента легко добавляет полсекунды. Но nvm нужен мне не в каждой оболочке — он нужен, когда я набираю nvm. Поэтому я оборачиваю его в функцию, которая при первом вызове заменяет саму себя:

export NVM_DIR="$HOME/.nvm"
nvm() {
unset -f nvm
[ -s "/opt/homebrew/opt/nvm/nvm.sh" ] && \. "/opt/homebrew/opt/nvm/nvm.sh" --no-use
[ -s "/opt/homebrew/opt/nvm/etc/bash_completion.d/nvm" ] && \. "/opt/homebrew/opt/nvm/etc/bash_completion.d/nvm"
nvm "$@"
}

Первый вызов nvm удаляет заглушку, подключает настоящий инструмент (с флагом --no-use, чтобы не разрешать версию Node), и передаёт аргументы дальше.

Та же идея работает для автодополнений kubectl, которые обращаются к бинарнику kubectl, чтобы сгенерировать скрипт автодополнения. Я загружаю их только после того, как впервые запускаю kubectl:

kubectl() {
command kubectl "$@"
local ret=$?
if [[ -z $KUBECTL_COMPLETE ]]; then
source <(command kubectl completion zsh)
KUBECTL_COMPLETE=1
fi
return $ret
}

Этот приём применим ко многим вещам: всё, что предлагает добавить eval "$(tool init zsh)" в .zshrc, — кандидат на отложенную загрузку, потому что каждая такая строка порождает дочерний процесс и выполняет его вывод при старте. direnv и fzf я загружаю нетерпеливо — они быстрые и используются постоянно. Будьте строги в выборе того, чем действительно пользуетесь часто.

Неблокирующее приглашение (prompt)

Приглашение, которое синхронно запускает git status, будет тормозить в любом репозитории приличного размера. Эта задержка ощущается при каждом нажатии Enter — что, пожалуй, хуже медленного запуска оболочки. Я использую pure: он немедленно отрисовывает приглашение, а информацию о git добавляет асинхронно, когда она готова. Я ненадолго пробовал заменить его встроенным vcs_info из zsh, но асинхронное поведение pure просто…​ лучше. Асинхронный git status можно реализовать и в собственном приглашении, но pure решает это достаточно аккуратно для моих задач.

Сам терминал

Скорость запуска оболочки — лишь половина истории: эмулятор терминала вносит собственную задержку ввода. Я использую Ghostty — он GPU-ускоренный и нативный, а вся моя конфигурация умещается в семь строк. В паре с алиасом tmux new -A -s main (сокращённым до t) новое окно терминала сразу возвращает меня в существующую сессию.

Как измерить производительность своей оболочки

Необязательно верить мне на слово — можно самостоятельно измерить, куда уходит время в вашем терминале. Следует искать три вида задержек: время запуска, задержку приглашения и задержку ввода.

Запустите несколько раз (первый запуск всегда медленнее из-за холодного кэша):

time zsh -i -c exit

Считаю, что всё до 100 мс — нормально, до 50 мс — отлично. Если вы видите 500 мс и больше, есть над чем поработать.

Для получения корректной статистики используйте hyperfine:

hyperfine --warmup 3 'zsh -i -c exit'

В Zsh встроен профилировщик. Добавьте в самое начало .zshrc:

zmodload zsh/zprof

а в самый конец:

zprof

Откройте новую оболочку — и получите отсортированную таблицу с точным указанием, куда ушло время. Первые строки обычно занимают compinit, подключение nvm.sh или какой-нибудь eval "$(…​)".. Исправьте первое место, перезапустите, повторяйте. Когда закончите — удалите обе добавленные строки.

Если zprof недостаточно детален, можно трассировать весь запуск с временными метками:

zsh -ixc exit 2>&1 | ts -i '%.s' | sort -rn | head -20

Или задайте PS4='+%D{%s.%6.}: ' и запустите zsh -ixc exit 2> startup.log, затем ищите большие скачки между строками.

Запуск может быть быстрым, а каждая перерисовка приглашения — медленной. Перейдите (cd) в свой самый большой git-репозиторий и нажмите Enter: если перед появлением следующего приглашения есть задержка, значит приглашение выполняет синхронную работу, которая его тормозит. Можно либо перейти на асинхронное приглашение, либо отказаться от отображения информации о git.

Итого

Большинство этих оптимизаций — об исключении лишнего. Речь идёт об осознанном подходе: добавлять только то, чем будешь пользоваться. Каждая из десятков сессий, которые я открываю за день, запускается мгновенно, и терминал ощущается как продолжение мыслей, а не как приложение, которого приходится ждать. Для инструмента, с которым я провожу весь рабочий день, это не обсуждается.

Всё описанное выше живёт в моём репозитории dotfiles — берите, что нужно.

© 2026 meganuke