Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Exploring Go-style tasks with Plenty’s ownership rules

Status: Not implemented; exploratory proposal with a standalone native spike. Lightweight tasks are neither adopted nor scheduled for Plenty. The backlog records the investigation; the concurrency reference describes the existing native workers. The production compiler, runtime and tutorial are unchanged. Keeping the current concurrency model is a valid outcome.

The question and provisional answer

Can ordinary functions suspend on communication, retain their local borrows, and clean up synchronously, without an exposed pinning API or async destructors? Scoped tasks on immovable, fixed-size stacks look like a plausible smaller model. The spike exercises actual stack switching, nested calls with stack-local references, bounded communication, cancellation and fallible admission. It does not establish a production implementation or the scalability normally associated with Go.

This is simpler at the function boundary than movable async frames: the call stack retains ordinary locals and nested calls; a callee can keep borrowing its caller’s local across a suspension. The simplification costs a stack reservation per task, fatal exhaustion unless another design is chosen, suspension effects that must still be checked, and a scheduler-aware I/O boundary. Full Go-style growable-stack M:N execution is a much larger commitment. The runtime choices separate these options.

What to borrow from Go

Go starts ordinary function calls concurrently, and its channels provide typed communication. Arguments are evaluated in the starting goroutine. The call’s return values are discarded by a go statement. These are language semantics, not a promise about which operating-system thread runs the function. Source: Go specification.

For Plenty, retain ordinary function bodies and explicit communication, while using a fallible library operation that returns a scoped result handle. Spawning must preserve ownership on failure. Starting a task cannot silently succeed when there is no stack or queue capacity; detached tasks would also conflict with the initial lifetime/cleanup contract. No go expression, unbounded scheduler queue, automatic GC, implicit channel allocation or process-wide hidden executor follows from this proposal.

Go’s scheduler distributes goroutines over worker threads, with separate goroutine, worker and processor state. Its runtime includes parking, syscall transitions and work distribution. That implementation is evidence of the work behind the surface, not a minimum dependency for a single-worker experiment. Source: runtime scheduler.

Candidate surface

Prefer library APIs and familiar with, explicit captures, Result/?, concrete jobs and consuming result handles. This sketch is proposed API spelling; neither std.task nor these error types exist:

import std.task as task

def answer(value: i64) -> Result[i64, task.Cancelled]:
    task.checkpoint()?
    Ok(value + 1)

def compute() -> Result[i64, task.ScopeError | task.StartError[()] | task.Cancelled]:
    with task.scope(max_tasks=8, stack_bytes=65536)? as scope:
        with scope.spawn(def once () -> Result[i64, task.Cancelled]: answer(41)).drop_job()? as work:
            return Ok(work.join()?)

The sketch omits typed channel construction; it is not a proposed rewrite of the existing std.thread or std.sync APIs. Names and whether scope/handle contexts should be nested remain open. Keep task types distinct until their ownership and wait behavior actually satisfy the native-thread contracts.

The parent evaluates captures and arguments first. scope.spawn(job) attempts admission, commits ownership only on success and schedules the body for later execution. Failure returns the unstarted concrete job, following consuming native jobs. An allocation-free .drop_job() conversion would discard that job deliberately. A scope reserves its task table and ready/wait bookkeeping fallibly. Capacity exhaustion is a separate Full(job) result; spawn_nowait is the initial policy. Waiting for capacity while holding a dependency can deadlock, so blocking admission needs an explicit later contract. A completed slot may be reused only after its result/handle lifetime is resolved; the spike does not implement reuse.

Channels have explicit positive capacity, fallible construction and owned messages. A successful send commits ownership exactly once. Full/nonblocking, closed and cancelled send errors return the unsent value without allocating; a parked send retains its value in its task until it commits or returns it. Wait nodes are reserved with admission, and park/wake/cancel never grow a queue. Dropping the last sender wakes receivers to observe closure; closing the receiver side wakes senders with their values. Cancellation is checked before a new transfer commits; an already committed transfer is not rolled back. Returning a cancelled unsent value must still respect linear obligations. Rendezvous and general selection are outside the first candidate. These are proposed contracts, not behaviors proved by the integer-only spike.

join() drives or yields to the owning scheduler until completion, then moves the result once. Returning a Result is ordinary task output, not a request to cancel siblings. Scope teardown joins all admitted children on every normal exit, including ?; unclaimed results are dropped once. Parent errors cannot erase child ownership or silently discard unresolved linear obligations. Existing linear integration remains a separate proposed contract.

Loans, suspension and migration

An ordinary stack remains at the same virtual address throughout task execution. Task-local loans and loans down its synchronous call chain may survive a yield without becoming movable self-referential frame fields. No public pin wrapper is needed. The runtime still promises address stability internally; hiding the promise does not remove its implementation obligations.

Initially accept wholly owned concrete jobs/results and prohibit references in messages, escaping callbacks and stored task handles. Within one task, preserve ordinary lifetime, exclusive-loan, owner-move and container-invalidation checks. Interleaving on one OS thread never authorizes two exclusive loans. Adding parent loans later should reuse scoped-worker reasoning: join before the owner dies, prevent conflicting parent accesses throughout the scope, and reject cycles. Do not infer those rules from the C experiment, which has no borrow checker.

Start with one scheduler bound to one native thread. Cross-thread task creation, handle transfer and migration are excluded. Owned inputs must still satisfy a defined task effect/eligibility check; existing native-worker certification does not prove that an operation is safe to suspend. A future M:N mode needs structural thread eligibility for captures, every live local, references, callbacks and destructors, and a contract for thread-local state and foreign handles. Merely making the initial closure transferable is insufficient.

Ordinary syntax can hide significant suspension. Infer a separate transitive may suspend effect for task operations and their callers; validate scheduler context at every reachable entry. A task-only helper must be rejected from an ordinary non-task entry. Calls through stored callbacks require evidence too. Reject suspension while a mutex guard, an uncertified foreign call, or a destructor is active. A pure helper can be called in either context. These checks may be a substantial compiler addition even without async/await keywords.

Cancellation and deterministic cleanup

Cancellation is a request, observed at task-aware waits and explicit checkpoints. It wakes parked channel/I/O waits and returns a typed cancellation error. The function then returns through ordinary cleanup and ? paths. Cleanup runs synchronously on that task’s retained stack before join releases the stack. Destructors cannot suspend, recursively drive the scheduler or allocate hidden task storage. Explicit fallible finishing operations handle protocol shutdown before ordinary destruction. A linear obligation cannot be abandoned merely because cancellation was requested.

Do not free a suspended live stack, asynchronously throw into arbitrary native code, use longjmp across destructors, or treat signal-handler unwinding as safe cancellation. A running task may ignore cancellation; scope exit can consequently wait forever. Fatal traps retain Plenty’s existing no-unwind behavior. Synchronous destruction also does not imply nonblocking destruction: a blocking close/drop path can stall this worker. Real adapters need that audit.

Go’s pipeline guidance explicitly teaches cancellation when downstream consumers stop early; convenient spawning and channels do not arrange application lifetime automatically. Plenty should make the scope boundary explicit and preserve its existing endpoint/join deadlock checks. Those checks are not a deadlock proof. Source: Go pipelines and cancellation.

Comparison with the async exploration

The independent async/await exploration describes restricted movable frames, with its ownership contract and native spike evidence. Both experiments are available for comparison; neither adds a production feature or depends on the other’s implementation.

QuestionRestricted movable async frames in that explorationFixed-stack task candidate here
Suspension syntaxExplicit await inside async functionsOrdinary calls; inferred suspension effects
References across suspensionInitially excluded, including hidden child borrowsTask-local call-chain borrows can remain at stable addresses
StorageConcrete frames inline; explicit fallible root admissionExplicit fallible task record and a whole stack reservation
DestructionSynchronous cleanup of initialized frame fieldsOrdinary synchronous stack cleanup after cooperative return
CancellationPending operation must support safe abandonmentTask must resume, observe cancellation and return
Existing blocking functionsNeed an owned nonblocking adapter or blocking poolAlso stall the worker unless adapted or offloaded
Runtime portabilityState machines can use ordinary native calling conventionsStack switching requires ABI/tooling/platform integration

Both candidates avoid exposing pinning by narrowing their contracts. Neither establishes general async-drop, cancellation latency, linear resolution, real I/O suitability or a concurrency feature ready for adoption. The async spike’s small frame layouts and this spike’s fixed stacks are observations of different workloads, not a fair performance comparison. A full Go emulation is not an established simpler alternative to either restricted candidate.

Decision still open

The experiment makes fixed-stack tasks worth comparing with the async candidate, especially when borrowed-buffer helper chains matter. Adoption needs the same bounded TCP/HTTP workload, cancellation and failed-start tests, a realistic stack budget, I/O registration-lifetime checks and compiler effect/loan experiments. Measure throughput, p95 latency, committed/resident/virtual memory, admission failure and cleanup work under equal limits. Those acceptance gates are recorded only in the backlog. Keeping native workers, adopting a narrow task library, choosing restricted async, or adopting neither all remain possible.