Skip to content

Commit 39b53d5

Browse files
committed
executor: add owner-thread executor module
1 parent a66aa5b commit 39b53d5

29 files changed

Lines changed: 2955 additions & 0 deletions

vlib/x/executor/README.md

Lines changed: 251 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,251 @@
1+
# x.executor
2+
3+
`x.executor` is a small owner-loop executor for V programs.
4+
It lets callers submit short callbacks to the thread or loop that owns a
5+
resource, without exposing scheduler internals or starting a hidden runtime.
6+
7+
The module is intentionally narrow:
8+
9+
- one explicit executor;
10+
- one explicit bounded queue;
11+
- explicit admission and backpressure;
12+
- explicit owner pumping through `run()`, `run_one()` or `drain_pending()`;
13+
- explicit shutdown through `stop()` and `wait()`;
14+
- no GUI, render, audio, network, or scheduler dependency.
15+
16+
`x.executor` is a sibling of `x.async`, not a layer on top of it. This module
17+
does not import `x.async`, and the examples in this directory use only
18+
synthetic local work, `spawn`, channels, `context`, and `time`.
19+
20+
## Why
21+
22+
Some resources must be touched only by one owner thread or loop:
23+
24+
- UI state owned by a main thread;
25+
- render-context state owned by a render loop;
26+
- FFI handles with same-thread requirements;
27+
- single-owner game, plugin, scripting, cache, or test state.
28+
29+
The safe abstraction is not "move scheduler work to another processor". The
30+
safe abstraction is a bounded queue that the owner loop drains deliberately.
31+
If a callback is slow, it still blocks the owner loop while it runs. Applications
32+
remain responsible for keeping callbacks short.
33+
34+
## Quick Start
35+
36+
```v
37+
import x.executor
38+
39+
fn main() {
40+
mut ex := executor.new(queue_size: 8)!
41+
updates := chan string{cap: 1}
42+
43+
ex.try_post(fn [updates] () ! {
44+
updates <- 'owner mutation ran'
45+
})!
46+
47+
ran := ex.run_one()!
48+
assert ran
49+
assert (<-updates) == 'owner mutation ran'
50+
51+
ex.stop()
52+
assert !ex.run_one()!
53+
ex.wait()!
54+
}
55+
```
56+
57+
## API
58+
59+
### JobFn
60+
61+
```v ignore
62+
pub type JobFn = fn () !
63+
```
64+
65+
Jobs are ordinary V functions. They receive no hidden context. If a caller needs
66+
to stop waiting for admission, it should use `post_with_context()` or
67+
`post_with_timeout()`. Once accepted, a job is not preemptively killed.
68+
69+
### Construction
70+
71+
```v ignore
72+
mut ex := executor.new(queue_size: 128)!
73+
```
74+
75+
`queue_size` must be positive and is fixed for the executor lifetime. The queue
76+
is bounded; a full queue returns an explicit backpressure error.
77+
78+
`new()` does not capture owner identity. Owner identity exists only while a
79+
thread is actively pumping callbacks through `run()`, `run_one()`, or
80+
`drain_pending()`.
81+
82+
### Submission
83+
84+
```v ignore
85+
ex.try_post(fn () ! {
86+
// owner-thread work
87+
})!
88+
89+
ex.post_with_context(parent_ctx, fn () ! {
90+
// accepted only while parent_ctx remains active
91+
})!
92+
93+
ex.post_with_timeout(50 * time.millisecond, fn () ! {
94+
// accepted only before the timeout expires
95+
})!
96+
```
97+
98+
`try_post()` never waits for queue capacity. If capacity is unavailable, it
99+
returns `executor: queue is full`.
100+
101+
`post_with_context()` and `post_with_timeout()` bound admission only. If the job
102+
is accepted and later waits in the executor queue, context cancellation or
103+
timeout does not cancel that accepted job.
104+
Active owner-thread submissions are accepted while capacity is available. If
105+
they would need to wait for capacity, they fail with
106+
`executor: owner thread cannot wait for queue capacity`.
107+
108+
There is deliberately no unbounded blocking `post()`.
109+
110+
### Owner Pumping
111+
112+
```v ignore
113+
ex.run()!
114+
ran := ex.run_one()!
115+
count := ex.drain_pending(16)!
116+
```
117+
118+
`run()` blocks and drains accepted jobs until shutdown reaches a terminal state.
119+
120+
`run_one()` executes at most one pending job and returns whether a job ran. It is
121+
useful for tests or host loops that already own their own frame/event pump. It
122+
does not drain the whole accepted queue in one call.
123+
124+
`drain_pending(max_jobs)` executes up to `max_jobs` pending jobs and returns the
125+
number of jobs executed. `max_jobs` must be positive. If `max_jobs` is reached,
126+
accepted jobs may remain queued for a later owner pump.
127+
128+
FIFO ordering is guaranteed for jobs submitted by one producer. For concurrent
129+
producers, order is the order in which submissions are accepted by the executor,
130+
not wall-clock order.
131+
132+
### Shutdown
133+
134+
```v ignore
135+
ex.stop()
136+
// If this executor is driven by run_one() or drain_pending(), pump once more.
137+
assert !ex.run_one()!
138+
ex.wait()!
139+
```
140+
141+
`stop()` is idempotent and non-blocking. It closes admission, wakes callers that
142+
are waiting to submit, and lets already accepted jobs drain. When the executor is
143+
driven by `run_one()` or `drain_pending()`, the owner loop should pump once more
144+
after `stop()` so the executor can observe the closed admission state and publish
145+
its terminal result before `wait()`.
146+
147+
`wait()` returns only after the executor has reached a terminal state. Calling
148+
`wait()` before any owner pump can reach a terminal state returns a stable error
149+
instead of blocking forever. That precondition error, and the owner-callback
150+
precondition error, do not consume the one valid wait. A valid `wait()` remains
151+
one-shot, and a second valid call returns a stable error.
152+
153+
### Job Errors
154+
155+
The first job error closes admission and is stored once. `run()` keeps pumping
156+
until already accepted jobs have drained, then returns the first job error.
157+
Manual pump APIs are bounded: `run_one()` can return a job error after one job,
158+
and `drain_pending(max_jobs)` can return the stored error after reaching
159+
`max_jobs` even when accepted jobs remain queued. Callers that use manual pumps
160+
must keep pumping until the terminal state is reached, or use `run()` when they
161+
want automatic draining. `wait()` returns the stored first job error after the
162+
terminal state is reached. Job errors are not silently discarded.
163+
164+
### Synchronous Calls
165+
166+
```v ignore
167+
ex.run_sync(fn () ! {
168+
// owner-thread work; caller waits for completion
169+
})!
170+
```
171+
172+
`run_sync()` from a foreign thread submits a wrapper job and waits for that job
173+
to complete. Its job error is returned to the caller.
174+
175+
`run_sync()` from the active owner thread runs inline to avoid deadlock. This
176+
inline execution still requires open admission and is outside executor FIFO
177+
ordering. If the inline call returns an error, that error becomes the executor's
178+
first job error even when the outer callback catches it.
179+
180+
Context-bound or timeout-bound synchronous calls are not part of this first API.
181+
182+
### Owner Checks
183+
184+
```v ignore
185+
if ex.is_owner_thread() {
186+
// currently pumping callbacks on the owner thread
187+
}
188+
189+
ex.assert_owner_thread()!
190+
```
191+
192+
`is_owner_thread()` is true only while the current thread is actively pumping
193+
callbacks for that executor. Owner identity is cleared before the pump returns.
194+
195+
## Safety Notes
196+
197+
`x.executor` is about owner-affinity control flow, not sandboxing.
198+
199+
- It does not recover panics.
200+
- It does not kill a running callback.
201+
- It does not make slow owner callbacks safe for GUI or render loops.
202+
- It does not provide priorities, work stealing, promises, or a scheduler.
203+
- It does not depend on real GUI, OpenGL, audio, or network resources.
204+
205+
All public module-owned errors use the `executor:` prefix. User job errors are
206+
returned unchanged.
207+
208+
## Examples
209+
210+
Small runnable examples live in `vlib/x/executor/examples/`:
211+
212+
- `basic_post.v`: submit one owner-loop mutation and pump it with `run_one()`.
213+
- `bounded_admission.v`: show queue backpressure and timeout-bounded admission.
214+
- `owner_loop_pump.v`: drain a synthetic frame loop with a per-frame job limit.
215+
- `run_sync.v`: wait for owner-loop execution from a foreign caller.
216+
- `shutdown.v`: stop from an owner callback and reject later submissions.
217+
- `synthetic_render_upload.v`: synthetic render-resource mutation.
218+
- `synthetic_ffi_owner_state.v`: synthetic same-thread FFI handle mutation.
219+
220+
Each example is local and synthetic. None starts a server, opens a window,
221+
touches graphics/audio APIs, or imports `x.async`.
222+
223+
## Tests
224+
225+
The targeted test suite lives next to the module:
226+
227+
```sh
228+
v test vlib/x/executor
229+
v -prod test vlib/x/executor
230+
```
231+
232+
For guarded local validation, prefer:
233+
234+
```sh
235+
sh vlib/x/executor/tools/validate.sh
236+
```
237+
238+
The validation script runs the dependency guard, formatting verification,
239+
examples, dev tests, and `-prod` tests serially with isolated `VTMP` and
240+
`VCACHE`.
241+
242+
## Benchmarks
243+
244+
Small local benchmarks live in `vlib/x/executor/benchmarks/`:
245+
246+
```sh
247+
sh vlib/x/executor/benchmarks/run_executor_benchmark.sh
248+
```
249+
250+
Benchmarks are observation tools, not correctness tests or portable performance
251+
claims. Defaults are intentionally modest and environment overrides are clamped.

0 commit comments

Comments
 (0)