
A long-running computation behaves quite differently inside a Go goroutine and a Rust async task. Go can pause the computation and run another goroutine before it finishes. A typical Rust async executor cannot: once a task enters a CPU-heavy function, that worker is occupied until the function returns or the task yields.
This matters in production. If computations occupy every executor worker, unrelated tasks can stop making progress. A request handler, a timeout, or the task that reports an unhealthy service may all be waiting to run. The operating system still schedules threads normally; it is the executor that cannot schedule its other tasks.
Go can regain control even inside a loop with no function calls. How does it do that? Where does the code that pauses the computation execute, and what must it save?
Let's build a small scheduler in Rust and ARM64 assembly on macOS: save a computation's state, switch stacks, then use a signal to interrupt an assembly loop that never yields. Keeping the interruptible computation small makes its suspension rules explicit. We'll compare the mechanism with Go's runtime.
Run a CPU-bound task alongside a timer on one Tokio worker. Enable Tokio's macros, rt, and time features:
use std::{hint::black_box, time::{Duration, Instant}};
#[tokio::main(flavor = "current_thread")]
async fn main() {
let start = Instant::now();
let compute = tokio::spawn(async {
let mut total = 0u64;
for i in 0u64..1_000_000_000 {
total = total.wrapping_add(black_box(i));
}
println!("computation finished: {total}");
});
tokio::time::sleep(Duration::from_millis(10)).await;
println!("10ms timer resumed after {:?}", start.elapsed());
compute.await.unwrap();
}The timer cannot resume while the loop is running. async changes how a function can suspend; it does not make every instruction interruptible by the executor. Tokio's cooperative scheduling budget cannot interrupt arbitrary computation inside a poll. Even an .await need not yield if the awaited operation completes immediately.
Compare that with Go, limiting execution to one Go processor:
package main
import (
"fmt"
"runtime"
"time"
)
func main() {
runtime.GOMAXPROCS(1)
start := time.Now()
done := make(chan struct{})
go func() {
var total uint64
for i := uint64(0); i < 1_000_000_000; i++ {
total += i
}
fmt.Println("computation finished:", total)
close(done)
}()
time.Sleep(10 * time.Millisecond)
fmt.Println("10ms timer resumed after", time.Since(start))
<-done
}On modern Go with asynchronous preemption enabled, the timer can resume before the computation finishes. In one run on this machine, using Tokio 1.49.0 in release mode and Go 1.25.7:
| Configuration | The 10ms timer resumed after |
|---|---|
| Tokio, one worker | 365ms |
Go, GOMAXPROCS=1 |
15ms |
Go, additionally GODEBUG=asyncpreemptoff=1 |
317ms |
Disable asynchronous preemption and the Go timer waits through the loop too. Function calls after the loop still provide synchronous scheduling opportunities. The elapsed times vary; the distinction is whether another task runs during the computation.
For Rust services, a bounded compute pool keeps this work off the async workers. To understand Go's alternative, start with the state we need to save.
To run B on A's OS thread, leave A's stack and heap data in place and save its register state to memory. B can then use those registers. Restoring A's values resumes A with its existing stack. The saved addresses remain specific to this process.
Start with an explicit call to switch_context. At a call boundary, the compiler already accounts for caller-saved registers being clobbered. Save the callee-saved state, stack pointer, and return address:
#[repr(C, align(16))]
#[derive(Default)]
struct Context {
x: [u64; 12], // x19–x30, including frame pointer and link register
sp: u64,
d: [u64; 8], // lower 64 bits of v8–v15
fpcr: u64,
fpsr: u64,
}#[repr(C, align(16))] fixes the field layout and alignment used by the assembly. The AArch64 calling convention requires preserving the lower 64 bits of v8–v15, even for integer-heavy workloads: the compiler can use those registers too. We also save the floating-point control and status registers, FPCR and FPSR, so tasks can retain distinct FP environments. With alignment padding, this context occupies 192 bytes.
Store the old context through x0 and load the new one through x1:
use std::arch::naked_asm;
#[unsafe(naked)]
unsafe extern "C" fn switch_context(
_old: *mut Context,
_new: *const Context,
) {
naked_asm!(
"stp x19, x20, [x0, #0]",
"stp x21, x22, [x0, #16]",
"stp x23, x24, [x0, #32]",
"stp x25, x26, [x0, #48]",
"stp x27, x28, [x0, #64]",
"stp x29, x30, [x0, #80]",
"mov x2, sp",
"str x2, [x0, #96]",
"stp d8, d9, [x0, #104]",
"stp d10, d11, [x0, #120]",
"stp d12, d13, [x0, #136]",
"stp d14, d15, [x0, #152]",
"mrs x2, fpcr",
"mrs x3, fpsr",
"stp x2, x3, [x0, #168]",
"ldp x19, x20, [x1, #0]",
"ldp x21, x22, [x1, #16]",
"ldp x23, x24, [x1, #32]",
"ldp x25, x26, [x1, #48]",
"ldp x27, x28, [x1, #64]",
"ldp x29, x30, [x1, #80]",
"ldr x2, [x1, #96]",
"mov sp, x2",
"ldp d8, d9, [x1, #104]",
"ldp d10, d11, [x1, #120]",
"ldp d12, d13, [x1, #136]",
"ldp d14, d15, [x1, #152]",
"ldp x2, x3, [x1, #168]",
"msr fpcr, x2",
"msr fpsr, x3",
"ret",
);
}stp and ldp store and load register pairs. A naked function supplies the instruction sequence without a compiler-generated prologue or epilogue, which would otherwise assume an ordinary stack frame.
mov sp, x2 installs B's stack pointer. ret branches to B's saved x30: usually the instruction after its earlier call to switch_context. That call now returns on B's stack.
No second OS thread performs this exchange. The current thread executes the assembly and continues on a different stack. The OS may independently move that thread between physical cores.
For a task that has never run, construct its initial context. GuardedStack maps a 64KB usable stack with an inaccessible guard page at each end. The task owns this allocation until it finishes. Its state lives at a stable address in a Box<UnsafeCell<TaskMemory>>; the following excerpt sets the stack pointer, entry address, and task pointer:
let stack = GuardedStack::new(64 * 1024);
let memory = Box::new(UnsafeCell::new(TaskMemory::default()));
let p = memory.get();
unsafe {
(*p).ctx.sp = stack.top();
(*p).ctx.x[0] = p as u64; // x19: task state
(*p).ctx.x[11] = task_entry as *const () as u64; // x30: entry address
}TaskMemory contains the saved context, workload parameters, results, and the address of the scheduler's context. task_entry is an assembly entry point: it initializes the computation, calls the assembly loop, then records the results and marks the task finished. It switches back to the scheduler rather than returning past the top of its stack.
The scheduler runs on the OS stack. Before each dispatch, it gives the task the address of its own saved context and switches into the task:
unsafe {
(*p).scheduler = scheduler;
switch_context(scheduler, std::ptr::addr_of!((*p).ctx));
// Back on the scheduler's stack: the task suspended or finished.
(*p).scheduler = std::ptr::null_mut();
}UnsafeCell permits the task's state to be modified across the assembly boundary. No Rust reference to its mutable contents survives the switch. The scheduler alone owns the task while it runs.
In cooperative mode, the loop periodically saves its continuation address and enters suspend_task, an assembly trampoline that saves live state and restores the scheduler's context. The scheduler can then select another task. In non-yielding mode, that path is disabled: the loop makes no calls and checks no preemption flag. Without another way to regain control, the worker stays occupied until the computation finishes.
Setting a flag from a timer handler does not solve this. A check_preempt() function could read that flag and yield, but the computation would still have to call it.
Instead, use the signal to redirect execution. Arm a periodic timer with setitimer(ITIMER_REAL, ...) and install a handler with sigaction, SA_SIGINFO, and SA_ONSTACK. The OS runs the handler on the receiving thread, using a separate signal stack, and passes it the saved execution context. Signal return restores that context.
We accept preemption only when the interrupted PC lies between two labels in the computation loop. Inside that range, the code performs arithmetic but makes no Rust calls, acquires no locks, and accesses no thread-local state. The scheduler, library code, and suspension trampoline are outside the range.
The handler saves the interrupted PC in the task's resume_pc field and redirects execution to suspend_task. It leaves the original LR intact. Here is the handler's core, omitting counters; UContext models the native signal context, and the machine-state offsets are specific to macOS ARM64:
unsafe {
let mc = (*uc).mcontext;
let pc = mc.add(272).cast::<u64>();
let value = *pc as usize;
let begin = std::ptr::addr_of!(demo_kernel_begin) as usize;
let end = std::ptr::addr_of!(demo_kernel_end) as usize;
if value < begin || value >= end { return; }
// Within this loop, x19 holds the task-state pointer.
let task = *(mc.add(16 + 19 * 8).cast::<*mut TaskMemory>());
(*task).resume_pc = *pc;
*pc = suspend_task as *const () as u64;
}Signal return leaves the handler and its alternate stack before entering the trampoline on the task's stack. The non-yielding loop contains no call to the trampoline; the signal supplies the transfer. This is asynchronous preemption of an explicitly bounded computation, not arbitrary Rust code.
The handler executes on the receiving thread. A different thread can request preemption, as Go's runtime does, but it does not modify another core's registers directly. This example uses a process-wide timer with one worker; targeting multiple workers requires thread-directed signals.
Go makes that distinction explicit. Its preemptM requests preemption through signalM. On macOS, signalM is just:
func signalM(mp *m, sig int) {
pthread_kill(pthread(mp.procid), uint32(sig))
}m represents an OS thread. Despite its name, pthread_kill sends a signal; SIGURG requests preemption rather than terminating the thread. The target thread handles that request. All Go runtime listings below are also copied verbatim from the Go 1.25.7 source.
Redirecting PC straight into an ordinary Rust function would let that function overwrite live caller-saved registers. The interrupted computation did not make a call and cannot be assumed to have saved them.
The suspension trampoline saves that state before switching to the scheduler. The listing below abbreviates the repetitive register pairs and omits validation bookkeeping. The named field offsets come from offset_of! in the Rust source:
suspend_task:
sub sp, sp, #928 // 800-byte frame + 128-byte red zone
stp x0, x1, [sp, #0]
// ... save x2–x17 at offsets 16–128 ...
str x30, [sp, #144] // original LR
mrs x9, nzcv
ldr x10, [x19, #resume_pc_offset]
stp x9, x10, [sp, #160] // flags and interrupted PC
stp q0, q1, [sp, #192]
// ... save q2–q31 at offsets 224–672 ...
mov x0, x19 // task context is its first field
ldr x1, [x19, #scheduler_offset]
bl switch_context
ldp q0, q1, [sp, #192]
// ... restore q2–q31 ...
ldr x9, [sp, #160]
msr nzcv, x9
// ... restore x0–x15 ...
ldr x17, [sp, #136]
ldr x30, [sp, #144] // restore original LR
ldr x16, [sp, #168] // interrupted PC
add sp, sp, #928
br x16switch_context preserves x19–x29 and the FP environment while the trampoline preserves the remaining live computation state. The computation reserves x16 for the continuation address; its previous value is not restored. Apple's platform register x18 is left untouched. These are explicit rules of this small assembly workload, not a transparent suspension mechanism for arbitrary native code.
A's saved state is split between its 192-byte context and its stack. The same OS thread executes each step:
A's loop → signal handler → signal return → suspend_task
→ switch_context → scheduler → B runs
→ ... scheduler selects A ...
→ switch_context returns → suspend_task restores → A's loopEach part of the saved state has a separate purpose:
| State that must survive | Why it matters |
|---|---|
| Interrupted PC and original LR | The PC identifies where the computation resumes; LR identifies where its function returns. They are different continuations. |
Condition flags (NZCV) |
An interruption between a comparison and a conditional branch must not change the branch's decision. |
| Full SIMD state and FP control/status | A call-boundary save of the lower halves of v8–v15 is not enough for an arbitrary interruption inside the loop. |
| Stack contents and red zone | The injected frame must leave live data below the interrupted SP untouched. |
Register preservation alone does not make library code safe to interrupt. If A is paused inside println!, another task may reenter the same OS thread's borrowed stdout state. If A holds an internal library lock and B blocks the worker waiting for it, A cannot resume to release it. Restricting preemption to our arithmetic loop excludes those operations.
After SIGURG reaches the worker, doSigPreempt decides whether to inject a call:
func doSigPreempt(gp *g, ctxt *sigctxt) {
// Check if this G wants to be preempted and is safe to
// preempt.
if wantAsyncPreempt(gp) {
if ok, newpc := isAsyncSafePoint(gp, ctxt.sigpc(), ctxt.sigsp(), ctxt.siglr()); ok {
// Adjust the PC and inject a call to asyncPreempt.
ctxt.pushCall(abi.FuncPCABI0(asyncPreempt), newpc)
}
}
// Acknowledge the preemption.
gp.m.preemptGen.Add(1)
gp.m.signalPending.Store(0)
if GOOS == "darwin" || GOOS == "ios" {
pendingPreemptSignals.Add(-1)
}
}gp is the goroutine; ctxt wraps the interrupted machine state. isAsyncSafePoint checks runtime state, stack space, and compiler metadata for the interrupted PC. It rejects runtime code and instruction sequences marked unsafe. For restartable sequences, it can adjust the resumption PC. If the checks fail, no call is injected, but the request is still acknowledged and signalPending is cleared. Receiving a signal and switching goroutines are separate decisions.
On ARM64, pushCall preserves the two continuation addresses using Go's injected-call frame layout:
func (c *sigctxt) pushCall(targetPC, resumePC uintptr) {
// Push the LR to stack, as we'll clobber it in order to
// push the call. The function being pushed is responsible
// for restoring the LR and setting the SP back.
// This extra space is known to gentraceback.
sp := c.sp() - 16 // SP needs 16-byte alignment
c.set_sp(sp)
*(*uint64)(unsafe.Pointer(uintptr(sp))) = c.lr()
// Make sure a valid frame pointer is saved on the stack so that the
// frame pointer checks in adjustframe are happy, if they're enabled.
// This is not actually used for unwinding.
*(*uint64)(unsafe.Pointer(uintptr(sp - goarch.PtrSize))) = c.r29()
// Set up PC and LR to pretend the function being signaled
// calls targetPC at resumePC.
c.set_lr(uint64(resumePC))
c.set_pc(uint64(targetPC))
}Our handler stores the interrupted PC in task state and leaves LR for the trampoline to save. Go instead puts the original LR on the stack before setting LR to the resumption PC. It also keeps SP aligned and records frame-pointer state for the runtime's stack checks.
The injected target is asyncPreempt. Its ARM64 assembly saves NZCV and FPSR at lines 27–30, then restores them at lines 64–67. Its register-save set follows Go's own ABI; it is not a drop-in implementation for arbitrary Rust code. Go also retains synchronous preemption checks.
The broader difference is how eligible interruption points are identified. We write and bound a small assembly computation ourselves. Go's compiler supplies metadata that lets its runtime make that decision throughout supported Go code. Adding a signal handler to a Rust executor does not supply equivalent information about arbitrary Rust code.
A suspended computation can resume on another OS worker if that worker can load its context and continue using its stack. In this demo, channels transfer exclusive ownership of the task. Each cooperative yield returns the task to a dispatcher, which sends it to the next worker. Routing it deliberately makes migration observable rather than incidental.
Only assembly frames migrate. Rust scheduler frames stay on their OS threads, and the receiving worker updates the task's scheduler pointer before resuming it. The timer is stopped and pending timer signals are drained before these workers start: migration uses cooperative yields, separate from the single-worker preemption experiment.
OS-thread-local state does not move with the stack. A cached pointer to worker A still points to A after resumption on B. Preserving that old register value would be the wrong behavior. Our computation carries no TLS references, guards, or other thread-affine resources across the transfer.
Preemption does not require migration: A and B can take turns on the same worker.
The program runs the same arithmetic in four modes:
A clean exit alone is not the test. The program checks each task's integer and selected SIMD results, FP control/status, restored condition flags, and a live value in the red zone. A workload too short to demonstrate preemption fails the preemption checks rather than claiming success. Run with --trace to see task progress at scheduling boundaries.
On an M1 Pro, 1,000 fresh processes running the default four-mode demo passed these checks. A separate 50,000-task run on eight workers verified 500,000 migrations.
A Rust async executor normally gets control when a poll returns. Go has another route: an external preemption request can cause the executing thread to enter runtime code at a suitable location, preserve its state, and schedule another goroutine before the computation finishes.
The interrupted thread performs the transfer. Its stack stays allocated; its live register state is saved to memory. Our demo makes the permitted interruption region explicit; Go coordinates the operation with compiler-defined safe points. In an ordinary Rust async service, long-running computations still need explicit scheduling boundaries or a separate compute pool.
I like playing around with highly unsafe code on this low level from time to time. Even if you never were to use this in actual production code, it teaches you the principles of the underlying system. LLMs are also an extremely helpful tool in this kind of exploration. In the pre-LLM era, just getting the code to compile and run to the point that it delivers some results was a lot harder. Now LLMs can help you to get it into a compiling state and you can iterate quickly and explore your ideas in a much easier way. For me this is a much more impressive and helpful use case of LLMs than the one-shot prompting of some slop game that you see everywhere nowadays.
Source code: View or download the Rust experiment. It requires macOS ARM64 (Apple Silicon) and a recent Rust compiler; I tested it on an M1 Pro. Compile it with bare rustc, then run with --trace to see the scheduling:
rustc -O exploring_go_scheduling_in_rust.rs -o goroutines
./goroutines --traceUse ./goroutines --help for workload, repetition, and stress-test options.