go-lib
Go-style concurrency for Rust — goroutines, channels, select!, WaitGroup, Cond, and a context package — built on a rust-native direct port of the Go M:N scheduler.
run;
No async, no Tokio, no executor. Every goroutine starts with a 64 KiB stack that grows automatically on demand (up to 1 GiB). The runtime is a work-stealing M:N scheduler ported verbatim from src/runtime/ in the Go GitHub repository.
Contents
- Features
- Quick start
- API reference
- Examples
- Testing & CI
- Architecture
- Go → Rust mapping
- Known limitations
Features
| Capability | Status |
|---|---|
| M:N goroutine scheduler (G/M/P) | ✅ |
| Unbuffered channels | ✅ |
| Buffered channels | ✅ |
| Channel close + drain | ✅ |
select! with recv, send, default |
✅ |
WaitGroup |
✅ |
Cond — goroutine-aware condition variable |
✅ |
context — cancellation and deadline propagation |
✅ |
sleep(Duration) |
✅ |
gosched() cooperative yield |
✅ |
with_syscall — P hand-off during blocking calls |
✅ |
| Work-stealing across Ps | ✅ |
GOMAXPROCS env var + runtime adjustment |
✅ |
| Goroutine panic handler (process does not abort) | ✅ |
| Dynamic goroutine stack growth (8 KiB → 1 GiB) | ✅ v0.2.0 |
Async preemption via SIGURG |
✅ v0.2.0 |
Netpoll — epoll/kqueue/IOCP I/O integration |
✅ v0.3.0 |
net::TcpListener / net::TcpStream |
✅ v0.2.0 |
| Loom concurrency model checker integration | ✅ v0.2.0 |
| CI — standard + loom jobs on every push/PR | ✅ v0.2.0 |
G state machine — casgstatus, GSYSCALL, GCOPYSTACK, GPREEMPTED, GSCAN |
✅ v0.3.1 |
systemstack — run closure on M's g0 stack (naked-asm RSP/SP switch) |
✅ v0.3.1 |
scope — scoped goroutines with safe short-lived borrows |
✅ v0.4.0 |
Quick start
Add to Cargo.toml:
[]
= { = "…" } # local path until crates.io publication
Every program entry point wraps its body in go_lib::run.
Use the #[go_lib::run] attribute macro to do it automatically:
use ;
Or call go_lib::run explicitly when you need more control:
Run the bundled examples:
API reference
Entry point
There are two equivalent ways to start the scheduler.
Attribute macro (recommended)
The macro expands the function body into go_lib::run(move || [-> ReturnType] { … }).
It works on any function, not only main, and captures parameters via move.
An async function is rejected at compile time with a clear error message.
Direct call
Initialises the scheduler (one M-thread per logical CPU, or the value of the GOMAXPROCS environment variable), runs f as the first goroutine, and blocks until f returns — propagating f's return value directly to the caller. The scheduler threads remain alive in the background; subsequent calls to run reuse them.
Parameters are passed via move closures:
let threshold = 42_i32;
run;
Return values propagate back to the caller:
let n: i32 = run;
let ok: bool = run;
If the first goroutine panics before returning, run re-panics on the calling thread with a clear message.
Goroutines
go!
Spawns closure as a new goroutine. Must be called from inside run. Equivalent to Go's go f().
run;
scope — safe short-lived borrows
scope
Scoped goroutines work exactly like std::thread::scope: goroutines spawned inside the closure can borrow data from the enclosing stack frame because scope guarantees all spawned goroutines complete before it returns. No Arc, no channels, and no .clone() are needed for read-only shared data.
run;
s.go(f) returns a ScopedJoinHandle<'scope, R>:
| Method | Description |
|---|---|
h.join() |
Block until the goroutine finishes; returns std::thread::Result<R> — Ok(R) on success, Err(payload) if the goroutine panicked |
The Err payload from a panicking goroutine is the same Box<dyn Any + Send> you would receive from std::panic::catch_unwind. Dropping a ScopedJoinHandle without calling join is safe — the goroutine still runs to completion; its result is simply discarded.
When to prefer scope over go! + channel:
| Pattern | Use when |
|---|---|
scope |
Goroutines are short-lived helpers that read (or write exclusively to) local data |
go! + channel |
Long-running goroutines, or when results need to be streamed/merged as they arrive |
WaitGroup |
Fire-and-forget goroutines that do side effects but produce no return value |
Channels
use chan;
let = ; // capacity=0 → unbuffered
| Operation | Blocks when… |
|---|---|
tx.send(val) |
buffer full / no receiver (unbuffered) |
rx.recv() -> Option<T> |
buffer empty / no sender; returns None on close |
tx.try_send(val) -> bool |
never blocks; returns false if would block |
rx.try_recv() -> Option<T> |
never blocks; returns None if empty |
tx.close() |
— wakes all blocked receivers with None |
Sender<T> and Receiver<T> are both Clone. Closing happens automatically when the last Sender clone is dropped, or explicitly via tx.close(). Sending on a closed channel panics (matching Go semantics).
run;
select!
Multiplexes channel operations, picking the first ready case at random (Go's fairness guarantee). Without default it blocks until a case fires; with default it is non-blocking.
Syntax:
select!
- Recv arms:
visSome(T)on success,Noneif the channel is closed. - Send arms: the expression is evaluated once; consumed on win, dropped on loss.
- Up to 4 recv arms + 2 send arms per invocation.
- Arms may appear in any order.
run;
Nonblocking poll:
select!
WaitGroup
use WaitGroup;
use Arc;
run;
WaitGroup is reusable: add / done / wait may be called in multiple rounds on the same instance. Calling done when the counter is already 0 panics (matching Go semantics).
Cond
A goroutine-aware condition variable. wait parks the calling goroutine via the scheduler (instead of blocking an OS thread), so other goroutines sharing the same M continue to run while waiting.
use Cond;
use ;
run;
| Method | Description |
|---|---|
Cond::new() |
Create a new condition variable |
cnd.wait(mu, guard) |
Release guard, park goroutine, re-acquire on wakeup; returns new guard |
cnd.notify_one() |
Wake one waiting goroutine |
cnd.notify_all() |
Wake all waiting goroutines |
Always re-check the predicate in a loop — spurious wakeups are possible.
context
A port of Go's context package. A Context carries a cancellation signal and optional deadline. Cancellation propagates from parent to all descendants.
use context;
use Duration;
run;
| Constructor | Description |
|---|---|
context::background() |
Root context; never cancelled |
context::with_cancel(parent) |
Returns (Context, CancelFn); cancel.cancel() cancels |
context::with_deadline(parent, instant) |
Auto-cancels at instant; also returns CancelFn |
context::with_timeout(parent, duration) |
Sugar over with_deadline |
| Method | Description |
|---|---|
ctx.done() |
&Receiver<()> — fires (None) when cancelled; use in select! |
ctx.err() |
Option<ContextError> — None, Cancelled, or DeadlineExceeded |
ctx.deadline() |
Option<Instant> |
ctx.is_done() |
bool — shorthand for ctx.err().is_some() |
cancel.cancel() |
Cancel the context; idempotent, safe to call multiple times |
Context and CancelFn are both Clone. with_deadline / with_timeout spawn a timer goroutine and must be called from within run.
sleep and gosched
sleep; // park goroutine; let others run
gosched; // cooperative yield to scheduler
sleep parks the calling goroutine and inserts a timer; the background timer thread calls goready when the duration elapses. gosched is the equivalent of Go's runtime.Gosched().
Both must be called from inside run.
with_syscall
run;
Wraps a potentially-blocking operation so the scheduler can hand the current M's P to another M while the OS thread is blocked. Use this around any call that may park an OS thread (file I/O, blocking network, std::thread::sleep, etc.).
net — async TCP
go_lib::net provides goroutine-aware TCP sockets integrated with the scheduler on all supported platforms:
| Platform | Backend | I/O model |
|---|---|---|
| Linux | epoll |
readiness-based (EAGAIN → park → fd ready → resume) |
| macOS | kqueue |
readiness-based |
| Windows | IOCP | completion-based (WSARecv/WSASend → park → operation done → resume) |
use ;
run;
| Type | Method | Description |
|---|---|---|
TcpListener |
bind(addr) |
Bind a server socket |
TcpListener |
accept() |
Accept; parks goroutine until a connection arrives |
TcpStream |
connect(addr) |
Connect; parks until the connection is established |
TcpStream |
read(&mut buf) |
Read; parks if no data available |
TcpStream |
write(&buf) |
Write; parks if send buffer full |
The park/resume flow is integrated into the scheduler: findrunnable checks netpoll_wait(0) on every scheduling iteration, and sysmon polls it during idle periods.
GOMAXPROCS
The number of logical processors defaults to available_parallelism() but can be overridden:
Environment variable — set before starting the process:
GOMAXPROCS=4
Runtime adjustment — from inside run:
let old = set_gomaxprocs;
println!;
Increasing GOMAXPROCS immediately spawns new Ps and M-threads. Decreasing updates the counter; surplus Ms park on their next idle cycle.
Panic handler
By default, a goroutine panic prints the payload to stderr and the scheduler continues — the process does not abort. Install a custom handler to log, record metrics, or recover state:
set_panic_handler;
run;
Examples
attr_main — #[go_lib::run] attribute
The attribute macro is the most concise entry point. It rewrites the
function body in-place, so the three main signatures work without any
boilerplate:
// examples/attr_run.rs (illustrative — see the real file for the full driver)
// 1. Plain — no return value
// 2. main() -> ExitCode
// 3. main() -> Result<(), E> (? works inside the closure)
The macro expands each of these into go_lib::run(move || [-> R] { … }) —
identical to writing it by hand, but without the wrapping ceremony.
hello — goroutines and channels
// examples/hello.rs
use ;
hello from goroutine 2
hello from goroutine 0
hello from goroutine 4
hello from goroutine 1
hello from goroutine 3
pipeline — three-stage concurrent pipeline
// examples/pipeline.rs (generate → square → print)
use ;
select_fanin — fan-in with select!
Two producers at different rates merged into one consumer:
// examples/select_fanin.rs
use ;
use Duration;
cond — bounded producer/consumer queue
// examples/cond.rs (abridged)
use ;
use ;
use VecDeque;
scope — parallel reduction with safe borrows
// examples/scope.rs
The goroutines borrow slices of data directly from the enclosing goroutine's stack frame — no Arc or channel needed.
main_exitcode — main() -> ExitCode
go_lib::scope runs the tasks concurrently and collects their (id, bool) results through join handles — no Arc, WaitGroup, or channel required:
// examples/main_exitcode.rs
use ExitCode;
task 0: ok
task 1: FAIL
task 2: ok
task 3: FAIL
task 4: ok
3/5 tasks passed
Exit code: 1
main_result — main() -> Result<(), E>
The closure can return Result; main returns it verbatim. Rust's
Termination trait prints the error and sets exit code 1 on Err.
go_lib::scope lets each goroutine borrow its &str directly — no channel needed:
// examples/main_result.rs
use ParseIntError;
sum = 23
If one of the strings were not a valid integer (e.g. "abc"), main would
print Error: invalid digit found in string and exit with code 1.
Testing & CI
Standard tests
Runs 106 unit tests, 15 integration tests, and 23 doc tests. All tests use
std::sync primitives and the real go-lib scheduler.
Loom concurrency model checker
loom explores every possible thread interleaving of a concurrent program and checks for data races, deadlocks, and broken invariants.
RUSTFLAGS="--cfg loom" LOOM_MAX_PERMUTATIONS=10000
Under --cfg loom the loom_shim module swaps std::sync::Mutex and
std::sync::Condvar for loom::sync equivalents in the data structures under
test (GlobalRunQueue, WaitGroup). Every test wrapped in loom::model(|| { … }) is exercised across all valid interleavings.
Tests that are coupled to the live scheduler (chan, select, context,
sync::Cond, and the scheduler itself) are excluded from the loom run via
#[cfg(all(test, not(loom)))] because the scheduler uses assembly-level
primitives (gopark, goready, gogo, mcall) that loom cannot model.
To increase the search depth for local exploration:
# Unlimited permutations (slow but exhaustive for small models)
RUSTFLAGS="--cfg loom" LOOM_MAX_PERMUTATIONS=0
CI
The GitHub Actions workflow (.github/workflows/ci.yml) runs two jobs on every
push and pull request targeting main:
| Job | Command | Checks |
|---|---|---|
test |
cargo test |
Build, all unit/integration/doc tests |
loom |
RUSTFLAGS="--cfg loom" cargo test -- --test-threads 1 |
Concurrent data structure correctness |
Architecture
go_lib::run(f)
│
├─ schedinit() create GOMAXPROCS Ps; spawn one M per P
│ install SIGSEGV + SIGURG handlers
│ start sysmon thread; start timer thread
│
├─ spawn_goroutine(f) allocate 8 KiB stack + G; push to global run queue
│
└─ thread::park() calling thread sleeps until f() returns
Each M-thread (M::start → schedule → findrunnable → execute → goexit0 → schedule):
M::start()
pthread_id = pthread_self() capture for SIGURG delivery
setup_sigaltstack() per-thread 64 KiB alternate signal stack
findrunnable()
1. local P run queue (256-slot lock-free ring, no lock on get)
2. global run queue (Mutex-protected linked list)
3. work-steal (up to 4 attempts, random victim P)
4. netpoll_wait(0) non-blocking poll; goready() each ready/completed G
(epoll on Linux, kqueue on macOS, IOCP on Windows)
5. stopm() park M on idle list; wake on goready/startm
execute(gp)
grow_stack_if_needed(gp) checkpoint: proactively double stack if nearly full
gogo(gp) context switch onto goroutine's stack (naked asm)
goexit0(gp)
→ schedule() re-enter scheduler loop
Stack growth (Step 3):
goroutine touches guard page → SIGSEGV
sigsegv_handler identify guard-page fault
newstack(gp) double stack; copystack brackets copy with
casgstatus(GRUNNING→GCOPYSTACK→GRUNNING)
update_sp_in_context(ucontext, delta) redirect faulting instruction
Async preemption (Step 4):
sysmon: goroutine running > 10 ms
gp.preempt = true; gp.stackguard0 = STACK_PREEMPT
pthread_kill(m.pthread_id, SIGURG) → sigurg_handler
sigurg_handler
redirect_to_async_preempt(gp, ucontext)
push original RIP → [RSP]; set RIP = async_preempt_trampoline
async_preempt_trampoline (naked asm)
save all GPRs + XMM/FP regs → goroutine stack
call async_preempt2()
async_preempt2()
mcall(preemptm):
casgstatus(GRUNNING→GPREEMPTED→GRUNNABLE) [two-step Go 1.14+ protocol]
schedule() [G re-queued; resumes later via gogo]
(returns after gogo re-schedules this G)
async_preempt_trampoline (resumed)
restore all regs; ret → original interrupted PC
Netpoll (Step 5):
Unix:
TcpStream::read: EAGAIN → netpoll_arm(fd, POLL_READ, gp) → gopark
sysmon/findrunnable: netpoll_wait(0) → goready(gp) for each ready fd
Windows (IOCP):
TcpStream::read: alloc IocpOp{gp} → WSARecv(overlapped) → gopark
sysmon/findrunnable: netpoll_wait(0) → GetQueuedCompletionStatusEx
→ fill IocpOp.{bytes,ntstatus} → goready(gp)
Source map
| Rust module | Go source | Purpose |
|---|---|---|
runtime::g |
runtime/runtime2.go |
G struct, goroutine status constants |
runtime::m |
runtime/runtime2.go |
M struct, Note park/unpark primitive |
runtime::p |
runtime/runtime2.go, proc.go |
P struct, 256-slot run queue |
runtime::sched |
runtime/proc.go, runtime/preempt.go |
schedule, findrunnable, execute, goexit0, async_preempt2, SIGURG handler, GOMAXPROCS |
runtime::park |
runtime/proc.go |
gopark, goready |
runtime::stack |
runtime/stack.go, runtime/signal_unix.go |
8 KiB→1 GiB dynamic stack allocator, newstack, copystack, SIGSEGV handler |
runtime::netpoll |
runtime/netpoll_epoll.go, runtime/netpoll_kqueue.go, runtime/netpoll_windows.go |
epoll (Linux) / kqueue (macOS) / IOCP (Windows) |
runtime::sudog |
runtime/runtime2.go |
Sudog waiter records + per-P pool |
runtime::syscall |
runtime/proc.go |
entersyscall, exitsyscall, handoffp |
runtime::sysmon |
runtime/proc.go |
sysmon, retake, async preemption via pthread_kill(SIGURG) |
runtime::time |
runtime/time.go |
4-ary min-heap timer, goroutine_sleep |
runtime::asm_amd64 |
runtime/asm_amd64.s, runtime/preempt_amd64.s |
gogo, mcall, systemstack, async_preempt_trampoline (AMD64) |
runtime::asm_arm64 |
runtime/asm_arm64.s, runtime/preempt_arm64.s |
gogo, mcall, systemstack, async_preempt_trampoline (AArch64) |
net |
net/tcpsock.go, net/fd_*.go |
TcpListener, TcpStream — goroutine-aware non-blocking TCP |
chan |
runtime/chan.go |
hchan, chansend, chanrecv, closechan |
select |
runtime/select.go |
selectgo, type-erased vtable |
scope |
(new — mirrors std::thread::scope) |
Scoped goroutines; safe short-lived borrows; ScopedJoinHandle |
sync::waitgroup |
sync/waitgroup.go |
WaitGroup |
sync::cond |
sync/cond.go |
Cond — goroutine-aware condition variable |
context |
context/context.go |
background, with_cancel, with_deadline, with_timeout |
loom_shim |
(new) | Conditional re-export: loom::sync under --cfg loom, std::sync otherwise |
Go → Rust mapping
| Go | Rust |
|---|---|
go func() { … } |
go!(closure) |
make(chan T) |
chan::chan::<T>(0) |
make(chan T, n) |
chan::chan::<T>(n) |
close(ch) |
tx.close() (or drop last Sender) |
select { case … } |
select! { … } |
sync.WaitGroup |
sync::WaitGroup |
sync.Cond |
sync::Cond |
context.Background() |
context::background() |
context.WithCancel(ctx) |
context::with_cancel(&ctx) |
context.WithDeadline(ctx, t) |
context::with_deadline(&ctx, t) |
context.WithTimeout(ctx, d) |
context::with_timeout(&ctx, d) |
runtime.Gosched() |
go_lib::gosched() |
time.Sleep(d) |
go_lib::sleep(d) |
runtime.GOMAXPROCS(n) |
go_lib::set_gomaxprocs(n) |
errgroup.Group / golang.org/x/sync/errgroup |
go_lib::scope (with h.join().unwrap()?) |
Known limitations
No defer/recover across goroutine boundaries — Goroutine panics are caught and routed to the panic handler; the process does not abort. However, Go's recover() (intercepting a panic mid-stack and returning a value to the caller) has no direct equivalent. Use std::panic::catch_unwind inside the goroutine body for fine-grained recovery.
GOMAXPROCS decrease is best-effort — Increasing GOMAXPROCS immediately adds capacity. Decreasing updates the counter but does not forcibly retire excess Ms; they park on their next idle cycle and are re-recruited if GOMAXPROCS rises again.
No race detector — The Go race detector is a compiler/runtime feature with no Rust equivalent in this crate. Use the loom model checker (cargo test --cfg loom) for systematic concurrency testing of the data structures that are within loom's boundary. Scheduler-level primitives (goroutine stacks, context switches) are outside loom's scope.
Conservative copystack pointer scan — Stack growth copies the live stack region and adjusts every pointer-sized word that falls within the old stack bounds. Values that coincidentally equal a stack address (e.g. integer constants, heap pointers above the stack range) are not adjusted. Return addresses (code segment pointers) are far outside the stack range and are never touched. In practice, Rust's borrow checker makes stack-address escapes nearly impossible, so false adjustments are vanishingly rare.