Concurrency and tasks
Kinmokusei exposes Go's low-level concurrency and adds a local structured task capability for computations whose result must be consumed.
Raw goroutine statement
go refreshCache();Statement position makes this the raw goroutine form; an omitted semicolon does not turn it into a task expression. It starts an ordinary Go goroutine, stores no result, enforces no join, and leaves panic/lifetime behavior unmanaged.
Use it only when the application deliberately owns the goroutine lifetime by another protocol.
Structured task expression
const task: Task<User> = go loadUser(id);
const user: User = await task;In expression position, such as a binding initializer, go call() produces Task<T>. The distinction is the syntax context, not whether the line has a semicolon: go work(); is raw, while const task = go work(); is structured. The task binding must be consumed exactly once by await or detach on every continuing path.
Task<void> is also supported: await task; joins it without an ordinary value. A direct await go work() or detach go work() does not require an intermediate binding. The start expression requires an ordinary callable, not a compiler builtin or type conversion. Ordinary multiple-result calls must first be wrapped in a source function returning one value or a checked Result effect.
The compiler rejects copying, reassigning, capturing, storing, escaping, returning, or multiply consuming a task.
Leaving a task unconsumed is a source error:
function work(): int {
return 42;
}
function main(): void {
const task = go work();
}This also applies when break or continue leaves the scope containing the task. A later await on a different path cannot satisfy the skipped path:
while (running) {
const task = go calculate();
if (stop) { break; } // Error: task is still pending on this exit.
const value = await task;
}Await or explicitly detach the task before leaving its scope. A task declared outside the loop can still be awaited afterwards: breaking the loop does not leave that outer scope. Labeled branches follow the same rule for every scope between the branch and its target.
goto also preserves Task ownership. Forward jumps merge the processing state of each binding at the target label; a backward jump must keep that state unchanged. Jumping over an await, jumping back to consume the same task again, or leaving a pending task's scope is rejected. Jumping back before a task's declaration is allowed after consuming the current task: the declaration then starts a fresh task on the next pass. Merely jumping within the pending phase and awaiting the task afterwards is also allowed.
Eager evaluation and start
const first = go calculate(firstInput());
const second = go calculate(secondInput());
const left = await first;
const right = await second;For each task, the call target and arguments evaluate synchronously once in source order before its worker goroutine starts. Both workers are started before the first await, allowing overlap.
Awaiting failure
A worker panic is retained by the task and re-panicked in the goroutine that executes await. This keeps the failure attached to the required join point.
For an operation result:
const task: Task<Result<User>> = go loadChecked(id);
const user: User = await task?;await first joins and transports panic; ? then propagates the ordinary operation error through the enclosing Result function.
The error path of ? exits the current function just like an explicit return. Consume its other pending tasks before that point; an await written after ? cannot run if propagation returns early. For example:
const background = go calculate();
const computed = await background;
const user = loadChecked(id)?;An enclosing finally runs on both explicit returns and ? propagation, so it may join a task before either exit completes:
import go { New } from "errors"
import go { Println } from "fmt"
function work(): int { return 7 }
function load(ready: boolean): Result<int> {
if (!ready) { return fail(New("missing")) }
return ok(11)
}
function complete(ready: boolean): Result<int> {
const task = go work()
try {
const value = load(ready)?
return ok(value)
} finally {
const completed = await task
Println("joined", completed)
}
}
const main = () => {
const [value, err] = complete(true)
Println(value, err === nil)
const [failed, failure] = complete(false)
Println(failed, failure !== nil)
}The output is joined 7, 11 true, joined 7, 0 true: both success and error wait for the worker before returning to the caller. The finally/Task ownership correction shown here is available since v0.4.5.
Consumption must happen on every checked exit path. An await under an if without consumption on the other branch leaves a pending Task; consuming it on both branches is valid. Awaiting an already consumed task is still an error. A try-local task cannot be consumed by a finally outside its lexical scope.
await task? consumes that task before checking the error path. If several Result-returning tasks are running, explicitly split and await their results before returning an error, instead of propagating the first result while another task is still pending.
A callback has its own task scope. Its return or ? does not exit the outer function, so an unrelated outer task may remain pending until the caller joins it. The callback still cannot capture or consume that outer task.
Without ?, the Result layer has not been consumed:
function load(): Result<int> {
return ok(1);
}
function invalid(): void {
const task = go load();
const value = await task;
}Detach
const task = go refresh();
detach task;Detach is an explicit consumption that discards the result. A detached panic is re-raised by its waiter goroutine and remains process-fatal. Detach does not mean “ignore all failure safely.”
Channels
const messages = goChannel[string](1);
messages <- "ready";
const [message, open] = <-messages;
closeGoChannel(messages);Channel types preserve direction:
GoChannel<T>sends and receives;GoSendChannel<T>sends only;GoReceiveChannel<T>receives only.
Send/receive blocking, nil channels, closed-channel zero values, double close, and send-after-close follow Go behavior. Checked receive distinguishes a delivered zero value from a closed/drained channel.
Since v0.4.2, a generic helper can close channels when every type in its constraint permits sending. Closing does not inspect elements, so their types need not be identical:
import go { Println } from "fmt"
constraint Writable = ~GoChannel<int> | ~GoSendChannel<string>
function finish<C extends Writable>(channel: C): void {
closeGoChannel(channel)
}
function main(): void {
const channel = goChannel<int>(1)
channel <- 7
finish(channel)
const [value, delivered] = <-channel
const [zero, open] = <-channel
Println(value, delivered, zero, open)
}A bound containing even one receive-only channel is rejected:
constraint Readable = ~GoChannel<int> | ~GoReceiveChannel<int>
function finish<C extends Readable>(channel: C): void {
closeGoChannel(channel)
}Generic send/receive expressions and select communication are also supported since v0.4.2. Every type in the constraint must permit the operation and have the same element type, including source nullability:
import go { Println } from "fmt"
constraint Writable<E> = ~GoChannel<E> | ~GoSendChannel<E>
constraint Readable = ~GoChannel<int> | ~GoReceiveChannel<int>
function put<E, C extends Writable<E>>(channel: C, value: E): void {
channel <- value
}
function poll<C extends Readable>(channel: C): int {
select {
case const [value, open] = <-channel {
if (open) { return value }
return -1
}
default { return -2 }
}
}
function main(): void {
const channel = goChannel<int>(1)
put(channel, 42)
Println(poll(channel))
Println(poll(channel))
closeGoChannel(channel)
Println(poll(channel))
}Unlike closing, sending cannot use a bound with different element types:
constraint Writable = ~GoChannel<int> | ~GoSendChannel<string>
function put<C extends Writable>(channel: C): void {
channel <- 42
}These rules follow Go's channel operations. Source/imported constraints and generic class methods preserve class identity and nullable elements. The existing channel-direction and nullable-value checks remain; use explicit type arguments if inference cannot establish one element type.
Range over a channel
for (const value of input) {
consume(value);
}Channel range accepts exactly one binding and continues until the channel is closed and drained. A send-only channel cannot be ranged.
Select
select {
case const value = <-input { consume(value); }
case output <- pending { markSent(); }
default { idle(); }
}Select chooses a ready communication. Multiple ready cases are intentionally nondeterministic. Default makes the operation non-blocking; no default waits. Each case has its own scope.
Every channel operand and send value is evaluated once in source order before selection, including operands of cases that do not win. Their Task consumption and mutations affect all bodies, even default. Receive assignment targets are evaluated only if their case wins; see select evaluation.
Cancellation and deadlines
Automatic task-context inheritance is not implemented. Pass context.Context explicitly:
function load(ctx: context.Context): Result<User> {
// pass ctx into Go HTTP/database operations
}Select on ctx.Done() when coordinating channels directly. The caller owns cancellation creation and must call its cancel function according to the Go API contract.
Shared mutable state
Tasks and goroutines do not make shared state race-free. Classes, slices, maps, pointers, and captured variables may still be shared across workers. Use channels or Go synchronization primitives and verify concurrent programs with the race detector where applicable.
Use the Go interoperability guide when concurrency crosses a package boundary, or browse the runnable concurrency recipes to see task, channel, and select behavior together.