diff --git a/src/renderer/State.zig b/src/renderer/State.zig index 22cae8fe5..727fece4c 100644 --- a/src/renderer/State.zig +++ b/src/renderer/State.zig @@ -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 diff --git a/src/renderer/generic.zig b/src/renderer/generic.zig index 5102ad4d4..14b790369 100644 --- a/src/renderer/generic.zig +++ b/src/renderer/generic.zig @@ -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)) { diff --git a/src/termio/Exec.zig b/src/termio/Exec.zig index 39abefd8f..0894f87b1 100644 --- a/src/termio/Exec.zig +++ b/src/termio/Exec.zig @@ -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;