Why my tmux was laggy: the status bar was forking a 1.2 GB server 24 times a second
My tmux had started to feel sluggish. Nothing was broken, it just felt heavy. It turned out the tmux server was using about a third of a CPU core all the time, and nearly all of that went on the status bar. Part of the cost came from the clock workaround in my previous post.
This post covers how I tracked it down, including two wrong guesses, and the fix, which brought the server from ~35% CPU to ~4% without restarting it.
The setup
This is a tmux server that had been running for 23 days:
- tmux 3.5a
- 26 sessions, 167 panes, many of them long-lived ssh sessions
status-interval 1, so the clock ticks every second- A status bar full of widgets: CPU, memory, pomodoro timer, API usage limits, a market quote and a clock
The status bar had already been optimized once. An earlier version ran 13 #()
shell jobs every second, about 260 process spawns a second. A background daemon
(status-daemon.sh) replaced that. It computes the widget values out of band
and writes them to small files under ~/.cache/tmux-status/, which the status
bar reads back with #(cat ...).
So I didn’t expect the status bar to be the problem.
Step 1: is tmux actually busy?
ps reports a process’s lifetime average CPU, which can be misleading, so
sample the live value with top:
|
|
|
|
It was busy all the time, and resident memory was 1.2 GB. That memory figure turned out to matter later.
Step 2: suspects that didn’t pan out
Suspect 1: pane output
In tmux, every byte a pane prints is parsed by the server, even in windows you
aren’t looking at. I compared capture-pane snapshots taken 3 seconds apart:
66 of 167 panes were changing. That looked like the answer.
Then I measured the actual bytes. My first strace traced only read, but
libevent reads ptys with readv, so trace both:
|
|
Total pane input came to about 40 KB/s, spread at roughly 1 KB/s per pane. That’s nothing. Output volume wasn’t the problem.
Suspect 2: an 8 KB automatic-rename-format
strace also showed tmux opening /proc/<pid>/cmdline about 220 times a
second. That’s tmux resolving #{pane_current_command}. My
window-icons.conf sets an automatic-rename-format that maps command names to
Nerd Font icons. It’s a single 8 KB nested ternary that references
#{pane_current_command} about 200 times, and it gets re-evaluated whenever a
window produces output.
It looked guilty, so I tested it directly:
| automatic-rename setting | server CPU |
|---|---|
| fancy 8 KB format (baseline) | 34% |
plain #{pane_current_command} |
37% |
| automatic-rename off | 32% |
No difference. Counting syscalls tells you what a process does, not what it spends its CPU on. For that you need a profiler.
Step 3: profile it
perf wasn’t installed, but eu-stack (from elfutils) was, and the tmux binary
wasn’t stripped. A basic sampling profiler is just a loop:
|
|
Samples whose top frame is __poll are tmux sitting idle in its event loop. Of
120 samples, 44 were busy, which fits the ~35% CPU. Nearly all of those 44 had
the same shape:
|
|
About 95% of the server’s busy time was in job_run, the function that runs
a status-bar #() command.
Don’t run strace and eu-stack at the same time. Both use ptrace, only one
can attach to a process, and the other gets nothing back.
Why #() is expensive here
Each #(...) in a status format makes the tmux server:
- fork itself, then
- build an environment for the child and free it again in the parent.
Neither step is normally a big deal, but this process had a 1.2 GB heap after
23 days of uptime. Forking it means copying page tables for all of that memory.
After the fork, the parent’s pages are shared copy-on-write with the child, so
the parent’s next writes, such as the free() calls in environ_free, can
trigger page faults. That fits the profile: the time shows up in _int_free and
environ_RB_REMOVE, which are not normally expensive functions.
The cost of a job comes almost entirely from forking the server, not from the
command being run. #(cat file) costs about the same as a heavy script.
This is what the live status bar was running each second, for each attached client:
|
|
With two clients attached, plus the daemon forcing refreshes with
refresh-client -S, strace counted about 24 forks a second.
To confirm, I replaced both status formats with job-free versions for a few seconds:
| status line | server CPU |
|---|---|
with #() jobs |
~35% |
no #() jobs |
~3% |
(My first attempt at this test also showed ~38% with no jobs. I never worked out why, but a clean rerun and the profiler agreed with each other, so I trusted those.)
The timezone workaround was part of the cost
In the previous post,
the system timezone changed to Asia/Tokyo after the tmux server had started,
and the server kept formatting %H:%M:%S in the old timezone:
|
|
The workaround swapped the clock for #(TZ=Asia/Tokyo date ...). It fixed the
time but added two more forks of the server per second per client. It was
also only a runtime setting: .tmux.conf still used the built-in %H:%M:%S, so
a prefix r reload would have brought the wrong time back.
The fix: one job per side
Every #() costs one fork of the big server, no matter what it runs. But the
shell inside the job can fork as often as it likes, because it’s a small
process. So merge each side of the status bar into a single job that runs a
script.
~/.tmux/scripts/status-left.sh:
|
|
~/.tmux/scripts/status-right.sh (Nerd Font icons left out here):
|
|
And in .tmux.conf:
|
|
A few details that matter:
-
tmux expands formats inside the job command before running it.
#{q:session_name}passes the session name, shell-quoted, and#{E:#{@quote_cond}}passes the quote condition as1or0. Anything that depends on the client or session goes in as an argument. -
Job output is not re-expanded as a format. Writing
#Sin the output wouldn’t work, which is why the session name comes in as an argument.#[...]style tags in the output are honored, so the colors still work. -
No
%%escaping needed any more. tmux runs strftime over status formats before running the jobs, which is why the old inline version had to writedate +%%H:%%M:%%S. Inside the script,dategets plain%H:%M:%S. -
The clock comes from
date, not tmux’s%H:%M:%S.datereads the system timezone each time it runs, so the time is right on this old server and on any fresh one. The timezone workaround is no longer needed. -
Check for invisible characters. My first edit to the config failed because the old
status-rightline held two Nerd Font glyphs that didn’t show in the terminal. This shows them:1 2grep '^set -g status-right' ~/.tmux.conf \ | perl -CSD -pe 's/([^\x00-\x7F])/sprintf("<U+%04X>",ord($1))/ge'Without that check, the calendar and clock icons would have silently disappeared from the bar.
Before switching, I compared the script output byte for byte with the old status
line. Then I applied just those two options to the running server with
tmux set -g, with no restart and no full config reload.
Results
| before | after | |
|---|---|---|
#() jobs per client per refresh |
5–6 | 2 |
| server forks per second | ~24 | ~4 |
| tmux server CPU | ~35% | ~4% |
| clock | correct only via a runtime override | correct, and survives a reload |
Takeaways
- Measure CPU, don’t infer it from syscalls. Both of my early suspects
looked convincing in strace and made no measurable difference. A 30-second
sampling loop with
eu-stackfound the real cause straight away. - In tmux, every
#()is a fork of the server. On a server that’s been up for weeks with lots of scrollback, that’s expensive. Count your#()s and merge them. One job running a script is much cheaper than five jobs each runningcat. - A long-running tmux server keeps the timezone it started with. Get the
time from
dateinside a job you already run, rather than adding new jobs. - Restarting fixes both. A fresh server would shrink back to a small heap,
which makes forks cheap, and pick up the current timezone.
tmux kill-serveralso ends every session, though. With 167 panes of live ssh sessions I didn’t want to do that, and the fix above works without it.
References
相关内容
- Linux 时区、系统时间与 tmux 状态栏时间不一致的排查
- (转)Linux 网络大流量传输优化方法
- tmux: error while loading shared libraries: libevent_core 2.1.so.6
- tmux 设置默认 zsh
- Linux 设置 cpu 高性能performance模式
支付宝
微信

william