Correct runtime architecture: batched input, config channel, frame receiver logic

- Update block diagram to show correct channel/mutex flow
- Frame receiver now calls w.Invalidate() inside the mutex lock
- Logic goroutine now waits on batched []InputEvent and configChan
- Main loop batches events and sends outside the lock
- Remove select-with-default batching from logic

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
This commit is contained in:
Greg Pomerantz 2026-05-08 10:56:34 -04:00
parent f8f81b1fec
commit 8bd9376fa9

View File

@ -9,26 +9,26 @@ Pad runs three concurrent components coordinated by channels and a single mutex:
```
┌─────────────────────────────────────────────────────────────┐
│ Main goroutine (Gioui event loop) │
│ - Waits for app events │
│ - Receives frames from frame receiver goroutine │
│ - Locks mutex, draws frame, calls e.Frame(), unlocks │
│ - Sends user input to logic goroutine │
│ - Waits for app events (w.Event()) │
│ - On FrameEvent: Lock → Read Frame → Draw → e.Frame → Unlock│
│ - Batches user input into []InputEvent │
│ - Sends batch to logic goroutine via inputChan │
│ - Sends window changes via configChan │
└─────────────────────────────────────────────────────────────┘
↑ frameChan (read) ↑ inputChan (send)
│ │
↑ w.Invalidate() │ inputChan / configChan
│ (via Frame Receiver) │ (send)
│ ↓
┌─────────────────────────────────────────────────────────────┐
│ Frame receiver goroutine │
│ - Tight loop: read frameChan → lock → store → unlock │
│ - Calls w.Invalidate() after storing each frame │
│ - Ensures logic's send on frameChan never blocks │
│ - Tight loop: read frameChan │
│ - Lock → Store Frame → w.Invalidate() → Unlock │
└─────────────────────────────────────────────────────────────┘
↓ frameChan (send) ↓ inputChan (receive)
↑ frameChan (read)
┌─────────────────────────────────────────────────────────────┐
│ Logic goroutine │
│ - select on: inputChan, worker results, async I/O │
│ - Drains inputChan completely (batching) before layout │
│ - Processes input: state mutation + layout computation │
│ - select on: inputChan, configChan, resultChan │
│ - Processes input batches: loops through []InputEvent │
│ - Dispatches work to priority-aware worker pool │
│ - Sends computed frames on frameChan │
│ - Guarantees: process + layout < 16 ms
@ -46,9 +46,9 @@ Pad runs three concurrent components coordinated by channels and a single mutex:
### Key Invariants
1. **The mutex is never held while touching a channel.** All channel sends and receives happen outside the critical section.
2. **The frame receiver goroutine runs a tight loop.** It reads from `frameChan`, acquires the mutex, stores the frame, releases the mutex, and calls `w.Invalidate()`. This ensures the logic goroutine's send on `frameChan` never blocks unless the receiver is actively storing a frame (which is fast — a pointer assignment).
3. **The main goroutine holds the mutex only during draw + `e.Frame()`.** After releasing the mutex, it sends user input to the logic goroutine. This send happens outside the lock.
4. **The logic goroutine guarantees sub-16ms processing.** It processes input, mutates state, computes layout, and sends a frame — all within the display refresh budget. This ensures `inputChan` never fills up and the main goroutine's send to logic never blocks.
2. **The frame receiver goroutine runs a tight loop.** It reads from `frameChan`, acquires the mutex, stores the frame, calls `w.Invalidate()`, and releases the mutex. This ensures the logic goroutine's send on `frameChan` never blocks unless the receiver is actively storing a frame.
3. **The main goroutine holds the mutex only during draw + `e.Frame()`.** After releasing the mutex, it sends the batched input to the logic goroutine. This send happens outside the lock.
4. **The logic goroutine guarantees sub-16ms processing.** It processes input, mutates state, computes layout, and sends a frame — all within the display refresh budget.
## 2. Goroutine Responsibilities
@ -56,27 +56,29 @@ Pad runs three concurrent components coordinated by channels and a single mutex:
Owns the `*app.Window` and runs the event loop. Responsibilities:
- **Event dispatch**: calls `w.Event()` to receive `app.FrameEvent`, `app.DestroyEvent`, `app.WindowEvent`
- **Event dispatch**: calls `w.Event()` to receive `app.FrameEvent`, `app.DestroyEvent`, `app.ConfigEvent`
- **Frame rendering**: on `app.FrameEvent`, acquires the mutex, reads the current frame, draws it via the renderer, calls `e.Frame(&ops)`, releases the mutex
- **Input collection**: extracts user input (taps, key events, scroll) from the Gioui context after rendering
- **Input delivery**: sends collected input to the logic goroutine on `inputChan`
- **Input batching**: collects all user input (taps, key events, scroll) received during the event cycle into a `[]InputEvent`
- **Input delivery**: sends the batch to the logic goroutine on `inputChan`
- **Config delivery**: sends window size or metric changes (`app.ConfigEvent`) to the logic goroutine on `configChan`
### 2.2 Frame Receiver Goroutine
A dedicated goroutine that bridges the logic goroutine and the main goroutine. Responsibilities:
- **Frame consumption**: reads from `frameChan` in a tight loop
- **Frame storage**: acquires the mutex, stores the received frame, releases the mutex
- **Frame invalidation**: calls `w.Invalidate()` to request Gioui to invoke a `FrameEvent`
- **Frame storage**: acquires the mutex, stores the received frame
- **Frame invalidation**: calls `w.Invalidate()` while holding the mutex to request Gioui to invoke a `FrameEvent`
- **Lock release**: releases the mutex after invalidation
This goroutine exists so that the logic goroutine can send frames without blocking. The receiver is always ready to consume, and the mutex hold is minimal (store a slice header).
This goroutine exists so that the logic goroutine can send frames without blocking. The receiver is always ready to consume, and the mutex hold is minimal.
### 2.3 Logic Goroutine
Owns all application state. Runs a `select` loop over multiple channels. Responsibilities:
- **Input batching**: drains `inputChan` completely before proceeding to state mutation and layout. This ensures that multiple rapid input events (e.g., fast typing or scroll momentum) are processed together, preventing "input lag" where the UI stays one frame behind the user.
- **Input processing**: mutates state based on the batched input, computes new layout.
- **Input processing**: receives a batch of input events from `inputChan`. It loops through the `[]InputEvent` and updates state accordingly.
- **Config processing**: receives window/metric changes from `configChan` and updates layout parameters.
- **Result handling**: receives results from workers on `resultChan`, applies completed async tasks to state.
- **Work dispatch**: identifies long-running tasks and dispatches them to the worker pool via the appropriate priority channel (`highWorkChan` or `lowWorkChan`).
- **Frame production**: computes `[]Element` from current state, sends on `frameChan`.
@ -84,22 +86,26 @@ Owns all application state. Runs a `select` loop over multiple channels. Respons
The logic goroutine is the sole owner of mutable state. No other goroutine reads or writes state directly.
#### Input Batching Implementation
#### Logic Loop Implementation
The logic goroutine uses a looping non-blocking `select` to drain the input channel:
The logic goroutine uses a `select` to wait for work:
```go
for {
select {
case input := <-inputChan:
state.Apply(input)
default:
// Channel is empty, proceed to layout
goto layout
case inputs := <-inputChan:
for _, in := range inputs {
// handle input (e.g., update buffer, move cursor)
}
sendFrame()
case cfg := <-configChan:
// update window size / metrics
sendFrame()
case res := <-resultChan:
// handle worker result
sendFrame()
}
}
layout:
// Compute frame...
```
### 2.4 Worker Pool
@ -137,29 +143,28 @@ for {
}
```
Workers post results on `resultChan`. The channel is sized to the worker pool size so sends never block.
Workers post results on `resultChan`.
## 3. Channel Topology
| Channel | Direction | Purpose | Blocking behavior |
|---|---|---|---|
| `frameChan` | Logic → Frame receiver | Computed frames (`[]Element`) | Unbuffered. Receiver runs tight loop, so logic's send only blocks while receiver holds mutex (fast). |
| `inputChan` | Main → Logic | User input events (taps, keys, scroll) | Unbuffered. Logic drains via batching loop, main sends after releasing mutex. |
| `inputChan` | Main → Logic | Batched user input (`[]InputEvent`) | Unbuffered. Logic drains via `select`, main sends after releasing mutex. |
| `configChan` | Main → Logic | Window config changes (`app.ConfigEvent`) | Unbuffered. Logic drains via `select`, main sends after releasing mutex. |
| `resultChan` | Workers → Logic | Completed async task results | Unbuffered. Workers post and return to pool. Logic drains via `select`. |
| `highWorkChan` | Logic → Workers | UI-critical async work | Buffered to pool size. |
| `lowWorkChan` | Logic → Workers | Background async work | Buffered to pool size. |
### Why Unbuffered Channels Work
Buffered channels were initially considered for deadlock avoidance. They are not necessary because:
1. **`frameChan`**: The frame receiver goroutine runs a tight loop. It only holds the mutex during the store + invalidate operation. The logic goroutine's send blocks only during this brief window.
1. **`frameChan`**: The frame receiver goroutine runs a tight loop. It only holds the mutex during the store operation (pointer assignment + unlock). The logic goroutine's send blocks only during this brief window, which is sub-millisecond.
2. **`inputChan` / `configChan`**: The main goroutine sends after releasing the mutex. The logic goroutine processes input in < 16ms. The main goroutine produces at most one batch per frame event. The logic's processing pace exceeds production, so the channel never backs up.
2. **`inputChan`**: The main goroutine sends input after releasing the mutex. The logic goroutine processes input in < 16ms and drains the channel completely before layout. The main goroutine produces at most one input event per frame (Gioui event loop pace). The logic goroutine's processing pace exceeds the main goroutine's production pace, so the channel never backs up.
3. **`resultChan`**: The worker pool is bounded. Results are posted as workers complete. The logic goroutine drains results in its `select` loop.
3. **`resultChan`**: The worker pool is bounded (fixed goroutine count). Results are posted as workers complete. The logic goroutine drains results in its `select` loop before processing the next input event. The bounded pool ensures the result rate is bounded.
4. **Work Channels**: Buffered to pool size. If all workers are busy, logic's dispatch blocks briefly until a worker frees up. This is acceptable because dispatching work is not on the critical input→frame path — logic can send a frame before dispatching. Priority ensures critical tasks jump the queue.
4. **Work Channels**: Buffered to pool size. If all workers are busy, logic's dispatch blocks briefly until a worker frees up. Priority ensures critical tasks jump the queue.
## 4. Synchronous/Asynchronous Partitioning
@ -277,19 +282,19 @@ The persistence function checks an atomic boolean flag (`sync/atomic.Bool`). If
```
Logic: frameChan <- elems // blocks if receiver is not reading
Receiver: lock → store → unlock → w.Invalidate() // fast, sub-ms
Receiver: lock → store → w.Invalidate() → unlock // fast, sub-ms
```
**Resolution**: The frame receiver runs a tight loop. It only holds the mutex during the store (pointer assignment). Logic's send blocks for at most the duration of this store — sub-millisecond. No deadlock.
**Resolution**: The frame receiver runs a tight loop. It only holds the mutex during the store + invalidate. Logic's send blocks for at most the duration of this store — sub-millisecond. No deadlock.
### Scenario: Main sends input, logic is processing previous input
```
Main: inputChan <- input // blocks if logic is not reading
Logic: input := <-inputChan // select loop, drains before next frame
Main: inputChan <- inputs // blocks if logic is not reading
Logic: inputs := <-inputChan // select loop, drains before next frame
```
**Resolution**: Logic's 16ms guarantee means it processes input faster than main produces it (one per frame event). Main sends input after releasing the mutex, so it never holds the lock while blocked on a channel send. No deadlock.
**Resolution**: Logic's 16ms guarantee means it processes input faster than main produces it. Main sends input after releasing the mutex, so it never holds the lock while blocked on a channel send. No deadlock.
### Scenario: Worker posts result, logic is busy
@ -298,28 +303,28 @@ Worker: resultChan <- result // blocks if logic is not reading
Logic: select { case r := <-resultChan: ... }
```
**Resolution**: Worker pool is bounded (fixed goroutine count). Workers post results and return to the pool. If logic is briefly busy processing input, the worker's send blocks for at most the duration of logic's current iteration (input process + frame send — < 16ms). The worker is not holding any lock during this send. No deadlock.
**Resolution**: Worker pool is bounded. Workers post results and return to the pool. If logic is briefly busy processing input, the worker's send blocks for at most the duration of logic's current iteration. The worker is not holding any lock during this send. No deadlock.
### Scenario: Logic dispatches work, all workers busy
```
Logic: workChan <- workItem // blocks if workChan is full
Workers: <-workChan // drain at their own pace
Logic: highWorkChan <- workItem // blocks if highWorkChan is full
Workers: <-highWorkChan // drain at their own pace
```
**Resolution**: `workChan` is buffered to pool size. If all workers are busy, logic's dispatch blocks until a worker frees up. This is acceptable because dispatching work is not on the critical input→frame path — logic sends a frame before dispatching, so the user sees the loading state immediately. No deadlock.
**Resolution**: Work channels are buffered to pool size. If all workers are busy, logic's dispatch blocks briefly until a worker frees up. This is acceptable because dispatching work is not on the critical input→frame path. No deadlock.
## 7. Go Concurrency Features Used
| Feature | Where | Why |
|---|---|---|
| **Goroutines** | Main, frame receiver, logic, workers | Natural fit for concurrent, channel-coordinated components |
| **Unbuffered channels** | `frameChan`, `inputChan`, `resultChan` | Synchronous handoff ensures no message is lost; timing guarantees prevent blocking |
| **Buffered channel** | `workChan` (size = pool size) | Allows logic to dispatch without waiting for a free worker |
| **Unbuffered channels** | `frameChan`, `inputChan`, `configChan`, `resultChan` | Synchronous handoff ensures no message is lost; timing guarantees prevent blocking |
| **Buffered channels** | `highWorkChan`, `lowWorkChan` (size = pool size) | Allows logic to dispatch without waiting for a free worker |
| **`sync.Mutex`** | Protects current frame | Single critical section (store or read frame), sub-millisecond hold time |
| **`sync/atomic.Bool`** | Persistence strategy flag | Lock-free read in logic goroutine, written by profiler goroutine |
| **`context.Context`** | Worker task cancellation | Cancel in-flight loads if user navigates away or file is closed |
| **`select`** | Logic goroutine's main loop | Multiplex over input, results, and async I/O without polling |
| **`select`** | Logic goroutine's main loop | Multiplex over input, config, and results without polling |
### Features Deliberately Avoided
@ -338,11 +343,10 @@ A frame (computed `[]Element`) follows this lifecycle:
1. **Logic computes**: state mutation + layout → `[]Element`
2. **Logic sends**: `frameChan <- elems` (blocks only while receiver holds mutex)
3. **Receiver stores**: `lock → currentElems = elems → unlock`
4. **Receiver invalidates**: `w.Invalidate()` queues a `FrameEvent`
5. **Main receives `FrameEvent`**: `lock → renderer.Draw(gtx, currentElems) → e.Frame(&ops) → unlock`
6. **Frame is on screen**: Gioui has submitted the frame to the display
7. **Next frame**: logic computes a new `[]Element`, repeats from step 2
3. **Receiver stores**: `lock → currentElems = elems → w.Invalidate() → unlock`
4. **Main receives `FrameEvent`**: `lock → renderer.Draw(gtx, currentElems) → e.Frame(&ops) → unlock`
5. **Frame is on screen**: Gioui has submitted the frame to the display
6. **Next frame**: logic computes a new `[]Element`, repeats from step 2
At no point are two frames rendered simultaneously. The mutex ensures the main goroutine never reads a frame while the receiver is writing it. The frame receiver calls `w.Invalidate()` only after storing, so Gioui never requests a draw for a frame that hasn't been stored yet.