tmux Batches Terminal Output: One Write() Per Event Loop Iteration
tmux funnels every escape sequence through one per-client buffer, flushes it with a single write() per event loop iteration, caches the terminal's attribute state, and throws output away when the terminal can't keep up.
TL;DR: Every byte tmux sends to a terminal goes through one function that appends to a per-client buffer, and that buffer is flushed with a single
write()per event loop iteration. If the terminal can’t keep up, tmux throws the output away and repaints the screen from scratch later — a deliberate trade of bytes for never blocking.
Every byte tmux sends to a terminal goes through one function. That function
appends to a buffer, the buffer is written out with a single write() per
event loop iteration, and if the terminal cannot keep up, the buffer is
thrown away and the screen is redrawn from scratch later.
That is the whole output pipeline. And it’s a good example of a design where the priority is “the terminal must never be the thing that makes tmux slow” rather than “never lose a byte”.
One buffer per client
Each attached client has a struct tty, and each tty has an evbuffer
called out. Everything goes through tty_add() (tty.c):
static void
tty_add(struct tty *tty, const char *buf, size_t len)
{
struct client *c = tty->client;
if (tty->flags & TTY_BLOCK) {
tty->discarded += len;
return;
}
evbuffer_add(tty->out, buf, len);
log_debug("%s: %.*s", c->name, (int)len, buf);
c->written += len;
if (tty_log_fd != -1)
write(tty_log_fd, buf, len);
if ((tty->flags & TTY_STARTED) &&
!event_pending(&tty->event_out, EV_WRITE, NULL))
event_add(&tty->event_out, NULL);
}Two details matter more than the rest.
- The
TTY_BLOCKcheck comes first. If the client is blocked, the bytes are counted intty->discardedand dropped on the floor. They are not buffered, not queued, not anywhere. - The
event_pending()check. Without it, every single escape sequence would try to arm the write event. Checking whether it is already armed turns “arm the event” from an operation per escape sequence into an operation per event loop iteration.
That second one is the kind of thing you only notice when you go looking.
It’s also the difference between thousands of redundant event_add() calls
per second and a handful.
One write() per iteration
The buffer is drained by a libevent callback:
static void
tty_write_callback(__unused int fd, __unused short events, void *data)
{
struct tty *tty = data;
struct client *c = tty->client;
size_t size = EVBUFFER_LENGTH(tty->out);
int nwrite;
nwrite = evbuffer_write(tty->out, c->fd);
if (nwrite == -1)
return;
log_debug("%s: wrote %d bytes (of %zu)", c->name, nwrite, size);
if (c->redraw > 0) {
if ((size_t)nwrite >= c->redraw)
c->redraw = 0;
else
c->redraw -= nwrite;
log_debug("%s: waiting for redraw, %zu bytes left", c->name,
c->redraw);
} else if (tty_block_maybe(tty))
return;
if (EVBUFFER_LENGTH(tty->out) != 0)
event_add(&tty->event_out, NULL);
}evbuffer_write() drains as much as the kernel will take in one write()
call. If the buffer is not empty afterwards, the event is re-armed and we go
around again.
Here’s the payoff: redrawing a 200x50 window with four panes produces one
write() syscall. Not one per cell, not one per run, not one per escape
sequence. On a local terminal that is the difference between a smooth redraw
and a visible stall.
There’s a second, subtler reason for buffering: ordering. tmux sends
cursor moves, SGR changes, erases and text, and those have to arrive in
order. If they were separate writes, a terminal that processed them out of
order — or a write() that returned a partial count — would produce a
garbled screen. One buffer, one write, one order.
The slow terminal problem
Now the part that makes this design interesting.
A terminal on a slow link (SSH over a bad connection, a terminal emulator busy doing something else) can be slower than the program producing output. If tmux simply buffered everything, the buffer would grow without bound and tmux would eat all the memory.
tmux’s answer is to throw the output away:
#define TTY_BLOCK_INTERVAL (100000 /* 100 milliseconds */)
#define TTY_BLOCK_START(tty) (1 + ((tty)->sx * (tty)->sy) * 8)
#define TTY_BLOCK_STOP(tty) (1 + ((tty)->sx * (tty)->sy) / 8)The threshold is eight screenfuls of pending output. Once the buffer
exceeds that, tty_block_maybe() drains the entire buffer:
static int
tty_block_maybe(struct tty *tty)
{
struct client *c = tty->client;
size_t size = EVBUFFER_LENGTH(tty->out);
struct timeval tv = { .tv_usec = TTY_BLOCK_INTERVAL };
if (size == 0)
tty->flags &= ~TTY_NOBLOCK;
else if (tty->flags & TTY_NOBLOCK)
return (0);
if (size < TTY_BLOCK_START(tty))
return (0);
if (tty->flags & TTY_BLOCK)
return (1);
tty->flags |= TTY_BLOCK;
log_debug("%s: can't keep up, %zu discarded", c->name, size);
evbuffer_drain(tty->out, size);
c->discarded += size;
...The client is now flagged TTY_BLOCK, so every subsequent tty_add() is
dropped. A timer runs every 100 ms and, once the buffer has drained below
the stop threshold, the client is flagged for a full redraw:
#define TTY_BLOCK_STOP(tty) (1 + ((tty)->sx * (tty)->sy) / 8)So the recovery is: lose the intermediate output, but resynchronise the terminal with a complete repaint.
This is a deliberate trade. The alternative — buffering without limit — turns a slow network into a slow tmux and eventually into an OOM kill. The cost is that a user on a slow link sees the screen jump rather than scroll smoothly, and any program whose output matters (a log tail, an interactive REPL) loses the text that was produced while tmux was blocked.
The CHANGES file records the thinking:
“Handle slow terminals and fast output better: when the amount of data outstanding gets too large, discard output until it is drained and we are able to do a full redraw.”
Deferring redraws until the buffer drains
Related to the above: tmux does not redraw on top of pending output. If there’s still data in the buffer, the redraw is deferred:
/* Ignore output queued within the current synchronized frame. */
n = EVBUFFER_LENGTH(tty->out);
if ((tty->flags & TTY_SYNCING) && n > tty->sync_offset)
n = tty->sync_offset;
/* Defer until output drains, preserving damage in client flags. */
if (n != 0 || (tty->flags & TTY_BLOCK)) {
if (n != 0)
log_debug("%s: redraw deferred (%zu left)", c->name, n);
else
log_debug("%s: redraw deferred (blocked)", c->name);
if (!evtimer_initialized(&ev))
evtimer_set(&ev, server_client_redraw_timer, NULL);
if (!evtimer_pending(&ev, NULL)) {
log_debug("redraw timer started");
evtimer_add(&ev, &tv);
}
if (damaged) {
c->flags |= CLIENT_REDRAWWINDOW;
return;
}This is in server_client_check_redraw(). A 1 ms timer is armed, and the
damage information is preserved in the client’s flags so the deferred
redraw still knows what changed. The damage is not thrown away and
recomputed — it is carried across the deferral.
The reason is not just tidiness. Redrawing on top of pending output means the redraw bytes get interleaved with the pending bytes in the wrong order relative to what the terminal has already been told. You get tearing. Deferring keeps the byte stream consistent.
Caching the terminal’s attribute state
The second big source of wasted bytes is SGR. Every change of colour,
attribute or underline style is an escape sequence, and a screen full of
coloured text would otherwise be an enormous stream of ESC[...m.
tmux keeps the terminal’s current style in tty->cell and tty->last_cell,
and refuses to emit a redundant change:
/* Ignore cell if it is the same as the last one. */
if (gc2.attr == tty->last_cell.attr &&
gc2.fg == tty->last_cell.fg &&
gc2.bg == tty->last_cell.bg &&
gc2.us == tty->last_cell.us &&
gc2.link == tty->last_cell.link)
return;Same idea for the cursor, the scroll region and the terminal modes:
/* Move cursor to absolute position. */
void
tty_cursor(struct tty *tty, u_int cx, u_int cy)
{
...
/* No change. */
if (cx == thisx && cy == thisy)
return;/* Set region at absolute position. */
static void
tty_region(struct tty *tty, u_int rupper, u_int rlower)
{
if (tty->rlower == rlower && tty->rupper == rupper)
return;
if (!tty_term_has(tty->term, TTYC_CSR))
return;The cursor case has an extra wrinkle — the “automargin space”, the phantom column at the right edge of the line:
/*
* If in the automargin space, and want to be there, do not move.
* Otherwise, force the cursor to be in range (and complain).
*/
if (cx == thisx && cy == thisy && cx == tty->sx)
return;
if (cx > tty->sx - 1) {
log_debug("%s: x too big %u > %u", __func__, cx, tty->sx - 1);
cx = tty->sx - 1;
}The cursor’s logical position can be one past the last column (the terminal is about to wrap), and tmux has to model that state to avoid emitting a cursor move that would reset the terminal’s wrap flag.
void
tty_update_mode(struct tty *tty, int mode)
{
...
if (tty->mode == mode)
return;The mode function also has a fix-up that shows how much terminal-specific knowledge is baked in:
/*
* If the mouse modes have changed, clear then all and apply
* again. There are differences in how terminals track the
* various bits.
*/
tty_puts(tty, "\033[?1006l\033[?1000l\033[?1002l\033[?1003l");Rather than trying to unset one mouse mode and set another, tmux clears all of them and re-applies the ones it wants. That’s more bytes in the common case, and fewer in the pathological case where a terminal tracks the bits differently.
Colours get the same treatment, with a diff:
changed = gc2.attr & ~tc->attr;so only the attribute bits that are actually new get emitted.
Terminfo strings are resolved once
Escape sequences come from terminfo, and terminfo strings contain parameters. tmux resolves them at startup rather than per use, and it prefers the allocating variant of the parameter expander:
const char *
tty_term_string_i(struct tty_term *term, enum tty_code_code code, int a)
{
const char *x = tty_term_string(term, code), *s;
#if defined(HAVE_TIPARM_S)
s = tiparm_s(1, 0, x, a);
#elif defined(HAVE_TIPARM)
s = tiparm(x, a);
#else
s = tparm((char *)x, a, 0, 0, 0, 0, 0, 0, 0, 0);
#endiftiparm_s is preferred because tiparm() and tparm() use a static
buffer, which is both a thread-safety hazard and a source of surprises when
two results are alive at once.
Terminfo padding ($<...>) is stripped at parse time, not at use time:
/*
* Ignore strings with no padding.
*/The raw mode handshake
Before any of this happens, the server takes the client’s terminal out of
cooked mode. This is tty_start_tty() in tty.c, and it is a single
tcsetattr():
tio.c_iflag &= ~(IXON|IXOFF|ICRNL|INLCR|IGNCR|IMAXBEL|ISTRIP);
tio.c_oflag &= ~(OPOST|ONLCR|OCRNL|ONLRET);
tio.c_lflag &= ~(IEXTEN|ICANON|ECHO|ECHOE|ECHONL|ECHOCTL|ECHOPRT|ECHOKE|ISIG);Two details matter for performance and correctness:
IXON/IXOFFare cleared, so Ctrl-S and Ctrl-Q reach the application instead of stopping the terminal’s output.OPOSTis cleared, so output is not translated — no\n→\r\nconversion, which means tmux does not have to think about the terminal eating a newline.
The fd is set non-blocking, so the event loop never blocks on a write:
setblocking(c->fd, 0);Putting the numbers together
For a typical frame — say, a status line update and a few changed cells in one pane — the output is:
1 × cursor position
1 × SGR change (maybe)
n × short text runs
1 × cursor positionall in one buffer, flushed with one write(). For a full-screen redraw of
four panes it’s one buffer, one write(), and the byte count is
proportional to the number of changed cells rather than the number of
cells.
Wrapping up
The interesting decision here is not the buffering — that’s standard. It’s the willingness to discard output. tmux treats the terminal as a device that may be arbitrarily slow and decides that falling behind means resynchronise, not queue.
That decision only works because of the other half of the design: tmux keeps a complete model of what the screen should look like, so it can always rebuild it. Without that model, discarding output would mean losing the screen. With it, discarding output costs one full redraw.
Worth internalizing: “never lose a byte” and “never stall” are different goals, and a multiplexer that owns the screen model can safely pick the second one.