Skip to content

← all backend comparisons

concurrency

Graceful shutdown: WaitGroup vs JoinHandle + select

A periodic background worker that advances paid orders through the fulfillment chain. SIGTERM must stop the loop without aborting an in-flight transition. Go uses context.Context + sync.WaitGroup; Rust uses tokio::select! on a oneshot signal plus an awaited JoinHandle.

Go (chi · sqlc · pgx)
Worker: ticker, select on ctx.Done, decoupled per-tick context
go/internal/workers/orders.go
// shop-two-backends not found at build time
main wires the WaitGroup and waits *after* srv.Shutdown
go/cmd/server/main.go
// shop-two-backends not found at build time
Rust (axum · sqlx · tokio)
Worker: tokio::select! between ticker and oneshot::Receiver
rust/src/workers/orders.rs
// shop-two-backends not found at build time
main awaits the JoinHandle after axum::serve returns
rust/src/main.rs
// shop-two-backends not found at build time

What to take away

The shape of graceful shutdown is the same on both sides: stop accepting new work, finish the work in flight, then exit. The plumbing is where they diverge.

Go threads context.Context as the cancellation signal — the same context the HTTP layer uses, cancelled by signal.NotifyContext on SIGTERM. The worker selects on ctx.Done() at the loop boundary. For the actual DB work inside a tick, the worker uses a *separate* context.Background() with its own timeout so an arriving signal mid-transaction doesn't roll back a partial commit. sync.WaitGroup is the join: main Wait()s after srv.Shutdown returns.

Rust doesn't have a "context" abstraction. The pattern is tokio::select! on the cancellation future (&mut shutdown_rx) versus the work future (ticker.tick()). The work future is awaited to completion inside its branch — a shutdown that arrives during a tick is observed at the *next* select boundary, after the current transaction commits. JoinHandle::await is the join: main awaits the worker after axum::serve returns.

Two practical notes:

  • Decouple the signal from the work context. On both sides. Cancellation should stop the *loop*, not abort the database transaction the loop kicked off. The Go side does this with a derived context.Background() per tick; the Rust side does it implicitly because select! only fires on branch boundaries, not in the middle of an awaited future.
  • Order: drain HTTP, then the worker. Reverse it and you'll have the worker writing to an order the HTTP layer is still racing to update. Both main.rs and main.go here serve the HTTP shutdown first, then signal the worker.

Verdict: this is one of the few pages where the two languages land at roughly equal weight. Go's context-passing is uniform; Rust's select! is a direct, allocation-free way to fan in cancellation. Pick whichever idiom your team finds easier to review.