renderer: hand off state mutex to avoid starving frames

The renderer state mutex is unfair on all platforms (os_unfair_lock
on macOS, a futex based lock elsewhere). A thread that unlocks and
right away locks again wins over a sleeping waiter, because the
waiter first has to be woken up and scheduled. The termio parse
thread does exactly this under heavy pty output: it relocks the
mutex for every batch and never sleeps in between, so the renderer
can starve in updateFrame for as long as the output lasts.

Fix this by letting the parse thread stay off the lock until the
renderer had its turn. `renderer.State` gets two atomics: a waiter
count (`demand`) and a generation counter (`handoff_gen`). The renderer
takes the mutex through lockDemand/unlockDemand which update these,
and the parse thread calls yieldToDemand between batches. If a
waiter exists it sleeps on a futex until the renderer took and
released the lock, with a 1ms timeout so a lost wake can not stall
IO forever.

All the atomics are monotonic on purpose: they are only a hint for
scheduling, the mutex still protects the terminal state itself.
When the renderer is not waiting the cost for the parse loop is a
single relaxed atomic load per batch.
This commit is contained in:
Uzair Aftab
2026-07-09 19:31:45 +02:00
parent 7cb44fea33
commit d34b54e9b4
3 changed files with 86 additions and 2 deletions

View File

@@ -1,12 +1,15 @@
//! This is the render state that is given to a renderer.
const std = @import("std");
const assert = std.debug.assert;
const Allocator = std.mem.Allocator;
const Inspector = @import("../inspector/main.zig").Inspector;
const terminalpkg = @import("../terminal/main.zig");
const inputpkg = @import("../input.zig");
const renderer = @import("../renderer.zig");
const State = @This();
/// The mutex that must be held while reading any of the data in the
/// members of this state. Note that the state itself is NOT protected
/// by the mutex and is NOT thread-safe, only the members values of the
@@ -30,6 +33,76 @@ preedit: ?Preedit = null,
/// need about the mouse.
mouse: Mouse = .{},
/// The number of threads currently waiting to acquire `mutex` via
/// `lockDemand`. This is not protected by the mutex; it is read by
/// hot lock/unlock loops (the IO parse thread) in `yieldToDemand` to
/// decide whether to hand the mutex off before relocking it.
demand: std.atomic.Value(u32) = .init(0),
/// Handoff generation counter. Incremented (with a futex wake) by
/// `unlockDemand` after a demanding waiter releases the mutex, so that
/// `yieldToDemand` knows the waiter had its turn.
handoff_gen: std.atomic.Value(u32) = .init(0),
/// How long `yieldToDemand` sleeps waiting for a demanding waiter to
/// take its turn before giving up. This bounds how long the IO parse
/// thread can stall if a wake is lost or the waiter is descheduled; a
/// demanding critical section (the renderer's frame snapshot) is
/// microseconds, so one millisecond is generous.
const handoff_timeout_ns = 1 * std.time.ns_per_ms;
/// Acquire `mutex` while signaling demand for it. Use this instead of
/// locking the mutex directly on threads that must not be starved by
/// a hot lock/unlock loop (the renderer's frame snapshot). Must be
/// released with `unlockDemand`; releasing with `mutex.unlock` keeps
/// the data safe but makes parked `yieldToDemand` callers wait out
/// their full timeout.
///
/// Both `std.Thread.Mutex` and os_unfair_lock are unfair: a running
/// thread that unlocks and immediately relocks beats a sleeping
/// waiter every time, because the waiter must first be woken and
/// scheduled. Under sustained pty output the IO parse thread is
/// exactly such a loop, so without this signal the renderer can
/// starve for as long as the output lasts.
pub fn lockDemand(self: *State) void {
_ = self.demand.fetchAdd(1, .monotonic);
self.mutex.lock();
const prev = self.demand.fetchSub(1, .monotonic);
assert(prev > 0);
}
/// Release `mutex` acquired via `lockDemand` and notify hot loops
/// parked in `yieldToDemand` that the demanding waiter had its turn.
pub fn unlockDemand(self: *State) void {
self.mutex.unlock();
_ = self.handoff_gen.fetchAdd(1, .monotonic);
std.Thread.Futex.wake(&self.handoff_gen, 1);
}
/// Called by hot lock/unlock loops between critical sections, with
/// `mutex` NOT held: if a `lockDemand` waiter exists, sleep until it
/// has acquired and released the mutex (or the timeout passes). This
/// is the handoff that unfair mutexes never do on their own.
///
/// The orderings here are all monotonic because these atomics are a
/// scheduling heuristic, not a synchronization boundary: the mutex
/// itself orders the protected data, and the timeout bounds any
/// staleness.
pub fn yieldToDemand(self: *State) void {
if (self.demand.load(.monotonic) == 0) return;
// Snapshot the generation before rechecking demand: if the waiter
// acquires and releases between our check and the wait below, the
// generation no longer matches and timedWait returns immediately.
const gen = self.handoff_gen.load(.monotonic);
if (self.demand.load(.monotonic) == 0) return;
std.Thread.Futex.timedWait(
&self.handoff_gen,
gen,
handoff_timeout_ns,
) catch {};
}
pub const Mouse = struct {
/// The point on the viewport where the mouse currently is. We use
/// viewport points to avoid the complexity of mapping the mouse to

View File

@@ -1170,8 +1170,10 @@ pub fn Renderer(comptime GraphicsAPI: type) type {
// std.log.err("[updateFrame critical time] start={}\tduration={} us", .{ start_micro, end.since(start) / std.time.ns_per_us });
// }
state.mutex.lock();
defer state.mutex.unlock();
// Lock while signaling demand so the IO parse thread
// can't starve us. See renderer.State.lockDemand.
state.lockDemand();
defer state.unlockDemand();
// If we're in a synchronized output state, we pause all rendering.
if (state.terminal.modes.get(.synchronized_output)) {

View File

@@ -1486,6 +1486,10 @@ pub const ReadThread = struct {
_ = posix.write(pipeline.idle_write_fd, "i") catch {};
}
}
// Batch boundary: hand the renderer state mutex off if
// the renderer is waiting. See renderer.State.lockDemand.
io.renderer_state.yieldToDemand();
}
}
@@ -1776,6 +1780,11 @@ pub const ReadThread = struct {
}
@call(.always_inline, termio.Termio.processOutput, .{ io, buf[0..n] });
// See threadMainPosix: hand the renderer state mutex
// off if the renderer is waiting, since this loop
// would otherwise starve it under heavy output.
io.renderer_state.yieldToDemand();
}
var quit_bytes: windows.DWORD = 0;