lang::cohort¶
Structured concurrency: cohort { } owns the goroutines spawned inside it, joins them on every exit path, and reports the first failure as its Result.
A cohort block owns the goroutines started inside it. The block cannot
be left until every one of them has finished, so a background task cannot
outlive the code that started it, and a failure in one of them cannot
vanish unread.
use std::errors
fn fetch(url: String) -> Result<String, errors::Error> {
// ... network work ...
Ok(url)
}
fn gather() -> Result<(), errors::Error> {
cohort {
let a = spawn(|| fetch("one"))
let b = spawn(|| fetch("two"))
println!("{} {}", a.join()??, b.join()??)
}
}
fn main() {
println!("{:?}", gather())
}
The block is an expression. Its value is Result<(), errors::Error>, so
it binds like any other fallible call and composes with ?:
fn stage() -> Result<(), errors::Error> {
cohort {
let _a = spawn(|| fetch("one"))
let _b = spawn(|| fetch("two"))
}?
Ok(())
}
Two ways to start a goroutine¶
Gossamer has both, and they mean different things.
spawn(f) inside a cohort |
go f() |
|
|---|---|---|
| Lifetime | bounded by the block | unbounded |
| On failure | cancels siblings, becomes the block's Err |
lost |
| Joined | by the block, always | never |
| Handle | JoinHandle<T> for the value |
none |
// Structured: the block waits, and reports what went wrong.
let outcome = cohort {
let _a = spawn(|| index_shard(0))
let _b = spawn(|| index_shard(1))
}
// Detached: fire-and-forget, for work with no relationship to the
// code that started it.
go metrics_reporter()
Reach for cohort by default. go is right when a goroutine genuinely
should outlive the block that started it - a background reporter, a
supervisor loop - and nothing is waiting on its result. A go written
inside a cohort is almost always a mistake, and gos lint reports it
as GL0053.
main is already a cohort¶
Every program's main runs inside an implicit root cohort, so every
spawn belongs to one even without a block written around it. Two
things follow:
- No goroutine outlives the program.
- A spawned goroutine that fails, and whose handle nobody joins, is reported on stderr at exit instead of disappearing:
The root cohort's policy is collect-all, so one failing goroutine never
cancels another's work. Writing cohort { } explicitly is how you ask
for a tighter boundary than "the whole program".
Failure and cancellation¶
A child fails by panicking or by answering Err. By default the first
failure cancels its siblings and becomes the block's error:
let outcome = cohort {
let _a = spawn(|| work(1))
let _b = spawn(|| work(2)) // returns Err
let _c = spawn(|| work(3)) // cancelled
}
// outcome is Err(..) once every child has stopped
The reported failure is the one with the lowest spawn index, never whichever child happened to finish first, so the answer is the same on every run and on every execution tier.
Cancellation is cooperative, and nothing is killed. A cancelled child
sees it as an operation's ordinary "nothing more is coming" answer: a
recv reports None, exactly as a closed channel does, and a sleep
returns early. The child then leaves through its own normal exit path,
so its defer frames and destructors run in order:
fn worker(rx: Receiver<Job>) -> Result<(), errors::Error> {
defer println!("worker stopped")
// Ends on cancellation the same way it ends on a closed channel.
while let Some(job) = rx.recv() {
handle(job)?
}
Ok(())
}
Pure computation is not a cancellation point. A CPU-bound child decides where it is willing to stop:
use std::runtime
fn search(space: Vec<Board>) -> Result<i64, errors::Error> {
let mut best = 0
for board in space {
if runtime::cohort_cancelled() {
break
}
best = max(best, evaluate(board))
}
Ok(best)
}
Scheduling and preemption¶
Scheduling is cooperative. A goroutine hands its worker back at a
safepoint - a channel or select operation, a mutex, time::sleep, a
scheduler-aware read, and every function call and scheduler step - and a
watchdog asks a long-running worker to yield at the next one. Nothing
relocates a running goroutine off its worker asynchronously, as Go's signal
handler does.
That distinction shows up in exactly one shape: a CPU-bound loop that calls
nothing at all. The bytecode VM still yields on such a loop's back-edges, but a
gos build binary does not - the compiled backends leave loop back-edges
un-polled so the optimizers can keep numeric loops tight. Such a loop holds one
worker until it finishes; its peers keep running on the others.
The fix is the same one cancellation already asks for: give a long computation
a point where it is willing to stop. The runtime::cohort_cancelled() check
above is one, and so is any ordinary call on an outer iteration.
Settings¶
cohort(policy: Policy::CollectAll) { ... }
cohort(timeout: 500) { ... }
cohort(context: Context::Isolated) { ... }
policy:-Policy::FailFast(the default) stops at the first failure.Policy::CollectAllruns every child and reports all their failures.Policy::Racestops at the first success; the losers are cancelled, and work they already committed is not undone, soraceis not a transaction.timeout:- milliseconds. The cohort is cancelled when the deadline passes, and the block reports the timeout as its error.context:-Context::Isolatedgives each child a dedicated OS thread for its whole life. That is what a synchronous Rust FFI call or never-yielding CPU-bound work needs: neither can be interrupted, and on a shared carrier they would stall every goroutine that carrier is running. Stdlib blocking calls need no such treatment - the runtime already moves them off the carrier. Channels work across contexts unchanged.
Nesting¶
Cohorts nest, and a nested one is joined before the block containing it continues. Cancelling the outer cohort cancels the inner one through the same chain a child's own check walks.
let outcome = cohort {
let _top = spawn(|| stage_one())
cohort {
let _a = spawn(|| stage_two(0))
let _b = spawn(|| stage_two(1))
}?
}
What a cohort does not do¶
- It does not kill anything. A child that performs no cancellation point and never returns will keep the block waiting - the same property Go, Kotlin, and Java's virtual threads have.
- It does not roll anything back.
Policy::Racecancels the losers; it does not undo the writes they already made. - It does not make a child's value appear in the block's result. Values
come back through
JoinHandle::joinor a channel; the cohort's own value reports success or the failure that stopped it.