x.executor #
x.executor
x.executor is a small owner-loop executor for V programs. It lets callers submit short callbacks to the thread or loop that owns a resource, without exposing scheduler internals or starting a hidden runtime.
The module is intentionally narrow:
- one explicit executor;
- one explicit bounded queue;
- explicit admission and backpressure;
- explicit owner pumping through
run(),run_one()ordrain_pending(); - explicit shutdown through
stop()andwait(); - no GUI, render, audio, network, or scheduler dependency.
x.executor is a sibling of x.async, not a layer on top of it. This module does not import x.async, and the examples in this directory use only synthetic local work, spawn, channels, context, and time.
Why
Some resources must be touched only by one owner thread or loop:
- UI state owned by a main thread;
- render-context state owned by a render loop;
- FFI handles with same-thread requirements;
- single-owner game, plugin, scripting, cache, or test state.
The safe abstraction is not "move scheduler work to another processor". The safe abstraction is a bounded queue that the owner loop drains deliberately. If a callback is slow, it still blocks the owner loop while it runs. Applications remain responsible for keeping callbacks short.
Quick Start
import x.executor
fn main() {
mut ex := executor.new(queue_size: 8)!
updates := chan string{cap: 1}
ex.try_post(fn [updates] () ! {
updates <- 'owner mutation ran'
})!
ran := ex.run_one()!
assert ran
assert (<-updates) == 'owner mutation ran'
ex.stop()
assert !ex.run_one()!
ex.wait()!
}
API
JobFn
pub type JobFn = fn () !
Jobs are ordinary V functions. They receive no hidden context. If a caller needs to stop waiting for admission, it should use post_with_context() or post_with_timeout(). Once accepted, a job is not preemptively killed.
Construction
mut ex := executor.new(queue_size: 128)!
queue_size must be positive and is fixed for the executor lifetime. The queue is bounded; a full queue returns an explicit backpressure error.
new() does not capture owner identity. Owner identity exists only while a thread is actively pumping callbacks through run(), run_one(), or drain_pending().
Submission
ex.try_post(fn () ! {
// owner-thread work
})!
ex.post_with_context(parent_ctx, fn () ! {
// accepted only while parent_ctx remains active
})!
ex.post_with_timeout(50 * time.millisecond, fn () ! {
// accepted only before the timeout expires
})!
try_post() never waits for queue capacity. If capacity is unavailable, it returns executor: queue is full.
post_with_context() and post_with_timeout() bound admission only. If the job is accepted and later waits in the executor queue, context cancellation or timeout does not cancel that accepted job. Active owner-thread submissions are accepted while capacity is available. If they would need to wait for capacity, they fail with executor: owner thread cannot wait for queue capacity.
There is deliberately no unbounded blocking post().
Owner Pumping
ex.run()!
ran := ex.run_one()!
count := ex.drain_pending(16)!
run() blocks and drains accepted jobs until shutdown reaches a terminal state.
run_one() executes at most one pending job and returns whether a job ran. It is useful for tests or host loops that already own their own frame/event pump. It does not drain the whole accepted queue in one call.
drain_pending(max_jobs) executes up to max_jobs pending jobs and returns the number of jobs executed. max_jobs must be positive. If max_jobs is reached, accepted jobs may remain queued for a later owner pump.
FIFO ordering is guaranteed for jobs submitted by one producer. For concurrent producers, order is the order in which submissions are accepted by the executor, not wall-clock order.
Shutdown
ex.stop()
// If this executor is driven by run_one() or drain_pending(), pump once more.
assert !ex.run_one()!
ex.wait()!
stop() is idempotent and non-blocking. It closes admission, wakes callers that are waiting to submit, and lets already accepted jobs drain. When the executor is driven by run_one() or drain_pending(), the owner loop should pump once more after stop() so the executor can observe the closed admission state and publish its terminal result before wait().
wait() returns only after the executor has reached a terminal state. Calling wait() before any owner pump can reach a terminal state returns a stable error instead of blocking forever. That precondition error, and the owner-callback precondition error, do not consume the one valid wait. A valid wait() remains one-shot, and a second valid call returns a stable error.
Job Errors
The first job error closes admission and is stored once. run() keeps pumping until already accepted jobs have drained, then returns the first job error. Manual pump APIs are bounded: run_one() can return a job error after one job, and drain_pending(max_jobs) can return the stored error after reaching max_jobs even when accepted jobs remain queued. Callers that use manual pumps must keep pumping until the terminal state is reached, or use run() when they want automatic draining. wait() returns the stored first job error after the terminal state is reached. Job errors are not silently discarded.
Synchronous Calls
ex.run_sync(fn () ! {
// owner-thread work; caller waits for completion
})!
run_sync() from a foreign thread submits a wrapper job and waits for that job to complete. Its job error is returned to the caller.
run_sync() from the active owner thread runs inline to avoid deadlock. This inline execution still requires open admission and is outside executor FIFO ordering. If the inline call returns an error, that error becomes the executor's first job error even when the outer callback catches it.
Context-bound or timeout-bound synchronous calls are not part of this first API.
Owner Checks
if ex.is_owner_thread() {
// currently pumping callbacks on the owner thread
}
ex.assert_owner_thread()!
is_owner_thread() is true only while the current thread is actively pumping callbacks for that executor. Owner identity is cleared before the pump returns.
Safety Notes
x.executor is about owner-affinity control flow, not sandboxing.
- It does not recover panics.
- It does not kill a running callback.
- It does not make slow owner callbacks safe for GUI or render loops.
- It does not provide priorities, work stealing, promises, or a scheduler.
- It does not depend on real GUI, OpenGL, audio, or network resources.
All public module-owned errors use the executor: prefix. User job errors are returned unchanged.
Examples
Small runnable examples live in vlib/x/executor/examples/:
basic_post.v: submit one owner-loop mutation and pump it withrun_one().bounded_admission.v: show queue backpressure and timeout-bounded admission.owner_loop_pump.v: drain a synthetic frame loop with a per-frame job limit.run_sync.v: wait for owner-loop execution from a foreign caller.shutdown.v: stop from an owner callback and reject later submissions.synthetic_render_upload.v: synthetic render-resource mutation.synthetic_ffi_owner_state.v: synthetic same-thread FFI handle mutation.
Each example is local and synthetic. None starts a server, opens a window, touches graphics/audio APIs, or imports x.async.
Tests
The targeted test suite lives next to the module:
v test vlib/x/executor
v -prod test vlib/x/executor
For guarded local validation, prefer:
sh vlib/x/executor/tools/validate.sh
The validation script runs the dependency guard, formatting verification, examples, dev tests, and -prod tests serially with isolated VTMP and VCACHE.
Benchmarks
Small local benchmarks live in vlib/x/executor/benchmarks/:
sh vlib/x/executor/benchmarks/run_executor_benchmark.sh
Benchmarks are observation tools, not correctness tests or portable performance claims. Defaults are intentionally modest and environment overrides are clamped.
fn new #
fn new(config ExecutorConfig) !&Executor
new creates an Executor with a fixed bounded queue.
The owner thread is not captured at construction time. It is recorded only while an owner pump method is actively running.
type JobFn #
type JobFn = fn () !
JobFn is the function signature executed by an Executor on the owner thread.
struct Executor #
struct Executor {
mut:
queue_size int
queue []ExecutorJob
active int
mutex &sync.Mutex = sync.new_mutex()
job_ready chan int
submit_ready chan int
done chan int
accepting bool = true
stopped bool
waited bool
pumping bool
run_active bool
owner_active bool
owner_thread_id u64
first_err IError = none
}
Executor serializes accepted jobs on whichever thread actively pumps it.
The executor does not own a thread. The owner identity is recorded only while run(), run_one(), or drain_pending() is actively pumping callbacks.
fn (Executor) assert_owner_thread #
fn (e &Executor) assert_owner_thread() !
assert_owner_thread returns an error when called outside the active owner pump.
fn (Executor) drain_pending #
fn (mut e Executor) drain_pending(max_jobs int) !int
drain_pending executes up to max_jobs jobs that are already accepted.
fn (Executor) is_owner_thread #
fn (e &Executor) is_owner_thread() bool
is_owner_thread reports whether the caller is currently inside an active owner pump.
fn (Executor) post_with_context #
fn (mut e Executor) post_with_context(parent context.Context, f JobFn) !
post_with_context waits until f can be admitted, parent is canceled, or the executor closes.
The parent context bounds admission only. Once accepted, f will run on a future owner pump unless the application never pumps the executor. Active-owner submissions are accepted while capacity is available. If they would need to wait for capacity, they fail with executor: owner thread cannot wait for queue capacity.
fn (Executor) post_with_timeout #
fn (mut e Executor) post_with_timeout(timeout time.Duration, f JobFn) !
post_with_timeout waits up to timeout for f to be admitted.
The timeout bounds admission only and does not preempt a running callback. Active-owner submissions are accepted while capacity is available. If they would need to wait for capacity, they fail with executor: owner thread cannot wait for queue capacity.
fn (Executor) run #
fn (mut e Executor) run() !
run blocks on the caller thread and executes accepted jobs serially.
It exits after stop() or the first job error closes admission and all accepted jobs have been drained.
fn (Executor) run_one #
fn (mut e Executor) run_one() !bool
run_one executes at most one accepted job and reports whether a job ran.
fn (Executor) run_sync #
fn (mut e Executor) run_sync(f JobFn) !
run_sync runs f on the owner thread and waits until it finishes.
Calls from the active owner pump execute inline, outside executor mutexes, to avoid deadlock. Inline calls still require open admission; an inline error is stored as the executor's first job error even if the outer callback catches it. That inline execution is outside FIFO queue ordering.
fn (Executor) stop #
fn (mut e Executor) stop()
stop closes admission and wakes any blocked submitters or owner run loop.
It is idempotent and non-blocking. Accepted jobs are drained by the owner pump; stop() itself never executes user callbacks.
fn (Executor) try_post #
fn (mut e Executor) try_post(f JobFn) !
try_post accepts f if the executor is open and queue space is available.
It never waits for queue capacity. A full queue returns executor: queue is full, making backpressure explicit.
fn (Executor) wait #
fn (mut e Executor) wait() !
wait waits for a running owner pump to reach terminal state.
A valid wait is one-shot. Calling it from the owner callback, or before a terminal pump is active, returns a stable precondition error without consuming that one allowed wait.
struct ExecutorConfig #
struct ExecutorConfig {
pub:
queue_size int
}
ExecutorConfig configures a bounded owner-thread executor.