Skip to content

A goroutine has no handle

Level: 101 · anyone who has started a goroutine and then wanted its answer

One line: go is a statement, not an expression: it hands back nothing to join or ask, and the function's return values are thrown away — so a goroutine's result, and its error, is always a value it sends to whoever is waiting.

In Rust, thread::spawn returns a JoinHandle. In Java and Python, submit returns a Future. Each is an object that stands for the running work: you can wait on it, and it gives you the result or the failure. Go has no such object. The spec's section on Go statements ↗ says the function begins in a new goroutine, the caller does not wait for it, and any return values are discarded when the function completes. The Go FAQ adds that goroutines are deliberately anonymous: no ID, no name, nothing a program can hold ↗.

There is nothing to assign

Verified output of no_handle_go_is_a_statement_sh.sh — regenerated by tools/run_examples.py, never hand-typed.

$ go build total.go
# command-line-arguments
./total.go:14:11: syntax error: unexpected keyword go, expected expression
exit status 1

The compiler refuses before it looks at types: go cannot stand where an expression is expected. A plain go sum(prices) compiles, and the sum is discarded.

no_handle_go_is_a_statement_sh.sh

no_handle_go_is_a_statement_sh.sh in full — pasted here by tools/run_examples.py from the file CI runs.

#!/usr/bin/env bash
# `go` is a statement, not an expression, so there is nothing to assign: the
# line a reader coming from Rust's thread::spawn or Java's submit writes first
# does not compile. The script prints the compiler's message and its status.
set -u
export GOTOOLCHAIN=local

dir=$(mktemp -d)
trap 'rm -rf "$dir"' EXIT

cat >"$dir/total.go" <<'GO'
package main

import "fmt"

func sum(prices []int) int {
    total := 0
    for _, p := range prices {
        total += p
    }
    return total
}

func main() {
    total := go sum([]int{1999, 450, 1200})
    fmt.Println(total)
}
GO

say() { printf '$ %s\n' "$*"; }

cd "$dir" || exit 1
say 'go build total.go'
go build total.go 2>&1
echo "exit status $?"

A value and its error, on one channel

Verified output of no_handle_results_go.go — regenerated by tools/run_examples.py, never hand-typed.

line 1: quantity 12
line 2: quantity 7
line 3: error: strconv.Atoi: parsing "seven": invalid syntax
line 4: quantity 30
line 5: error: strconv.Atoi: parsing "": invalid syntax
total of the lines that parsed: 49

Reading the output

Five goroutines, one per line of input, each calling strconv.Atoi, which returns two values. Written as go strconv.Atoi(text), both would be discarded. Instead each goroutine packs them into a parsed struct, adds the line number it was given, and sends the struct on results. main receives exactly five.

Two choices keep the output identical on every run. The results arrive in whatever order the goroutines finish, so main puts each back into byLine by its line number before printing anything. And no goroutine prints: main does, in the order of the input.

The error is just another field. Lines 3 and 5 failed, and the total covers the three that parsed. A goroutine that can fail sends its error beside its value — the same pair a function would have returned.

Each goroutine's send is on an unbuffered channel, so it waits until main receives it — An unbuffered send waits for a receiver. And each closure uses its own i and text, because since Go 1.22 ↗ every iteration of a for loop creates new variables.

no_handle_results_go.go

no_handle_results_go.go in full — pasted here by tools/run_examples.py from the file CI runs.

// A goroutine has no handle, and whatever its function returns is discarded.
// So each goroutine sends its result -- the value, the error, and which input
// it came from -- and main puts the results back in the order of the inputs.
//
//  go run no_handle_results_go.go
package main

import (
    "fmt"
    "strconv"
)

// parsed is what one goroutine sends back.
type parsed struct {
    line     int
    quantity int
    err      error
}

func main() {
    lines := []string{"12", "7", "seven", "30", ""}

    results := make(chan parsed)
    for i, text := range lines {
        go func() {
            // `go strconv.Atoi(text)` would compile, and throw both results away.
            quantity, err := strconv.Atoi(text)
            results <- parsed{line: i, quantity: quantity, err: err}
        }()
    }

    // Results arrive in the order the goroutines happen to finish.
    // The line number puts each one back where it belongs.
    byLine := make([]parsed, len(lines))
    for range lines {
        r := <-results
        byLine[r.line] = r
    }

    total := 0
    for _, r := range byLine {
        if r.err != nil {
            fmt.Printf("line %d: error: %v\n", r.line+1, r.err)
            continue
        }
        fmt.Printf("line %d: quantity %d\n", r.line+1, r.quantity)
        total += r.quantity
    }
    fmt.Println("total of the lines that parsed:", total)
}

When there is no value: a channel of errors

Verified output of no_handle_errors_go.go — regenerated by tools/run_examples.py, never hand-typed.

2 of 4 orders reserved
A-1002: 1 wanted, 0 in stock
A-1009: no such item

Reading the output

reserve returns only an error, so that is all each goroutine sends: nil for an order that could be reserved. main receives once for every goroutine it started — not until the first error — because a goroutine whose send nobody takes stays blocked, as the last program on this page shows. The failures arrive in the order their goroutines finished, so main sorts them before errors.Join combines them into one error, printed one per line.

If the first failure should stop the other goroutines, that is a different program: First error cancels the rest.

no_handle_errors_go.go

no_handle_errors_go.go in full — pasted here by tools/run_examples.py from the file CI runs.

// When the work has no value to hand back, only success or failure, the
// channel carries the error alone, nil for success. main receives exactly as
// many errors as it started goroutines, and joins the ones that are not nil.
//
//  go run no_handle_errors_go.go
package main

import (
    "errors"
    "fmt"
    "slices"
    "strings"
)

type order struct {
    sku      string
    quantity int
}

// stock is only read, never written, so every goroutine may look at it.
var stock = map[string]int{"A-1001": 5, "A-1002": 0, "A-1003": 2}

func reserve(o order) error {
    have, ok := stock[o.sku]
    switch {
    case !ok:
        return fmt.Errorf("%s: no such item", o.sku)
    case have < o.quantity:
        return fmt.Errorf("%s: %d wanted, %d in stock", o.sku, o.quantity, have)
    }
    return nil
}

func main() {
    orders := []order{{"A-1001", 3}, {"A-1002", 1}, {"A-1009", 1}, {"A-1003", 2}}

    errs := make(chan error)
    for _, o := range orders {
        go func() { errs <- reserve(o) }()
    }

    var failures []error
    for range orders {
        if err := <-errs; err != nil {
            failures = append(failures, err)
        }
    }

    // Failures arrive in the order the goroutines finished; sort them so the
    // report is the same on every run.
    slices.SortFunc(failures, func(a, b error) int {
        return strings.Compare(a.Error(), b.Error())
    })
    fmt.Printf("%d of %d orders reserved\n", len(orders)-len(failures), len(orders))
    fmt.Println(errors.Join(failures...))
}

A handle you build: a channel with room for one

Verified output of no_handle_future_go.go — regenerated by tools/run_examples.py, never hand-typed.

quantity: 0, err: strconv.Atoi: parsing "three": invalid syntax
price: 19.99, err: <nil>
goroutines after a buffered send nobody receives:   1
goroutines after an unbuffered send nobody receives: 2

Reading the output

start is the handle Go does not provide. It starts the goroutine and returns a receive-only channel that the result will arrive on — what C++, Java and Python call a future. Each call gets its own channel, so main took quantity before price, the reverse of the order it started them in, and each result brought its own error.

The buffer of one is the part that matters. The third call's result is never received, yet the next line shows every goroutine except main has ended: a send on a buffered channel with room can proceed at once ↗, so the goroutine put its value in the buffer and returned. The last line is the same send on an unbuffered channel, which can proceed only when a receiver is ready. Nobody will ever receive, so that goroutine is still there two seconds later, and will be until the program exits. A leaked goroutine never ends is about finding and preventing exactly that.

settle polls runtime.NumGoroutine until only main is left, or two seconds have passed. The first count gets to 1 as soon as the goroutines have ended; the second can never get there, so the two seconds are a margin, not a race.

no_handle_future_go.go

no_handle_future_go.go in full — pasted here by tools/run_examples.py from the file CI runs.

// If you want a handle, build one: a function that starts the goroutine and
// returns a channel with room for exactly one result. The room matters -- it
// lets the goroutine send and end even if nobody ever receives.
//
//  go run no_handle_future_go.go
package main

import (
    "fmt"
    "runtime"
    "strconv"
    "time"
)

// result is a value and the error that came with it.
type result[T any] struct {
    value T
    err   error
}

// start runs f in a new goroutine and returns the channel its result will arrive on.
func start[T any](f func() (T, error)) <-chan result[T] {
    ch := make(chan result[T], 1)
    go func() {
        value, err := f()
        ch <- result[T]{value, err}
    }()
    return ch
}

// settle waits until main is the only goroutine left, or two seconds have
// passed, and returns how many goroutines there are.
func settle() int {
    deadline := time.Now().Add(2 * time.Second)
    for runtime.NumGoroutine() > 1 && time.Now().Before(deadline) {
        runtime.Gosched()
    }
    return runtime.NumGoroutine()
}

func main() {
    price := start(func() (float64, error) { return strconv.ParseFloat("19.99", 64) })
    quantity := start(func() (int, error) { return strconv.Atoi("three") })

    // Each result has its own channel, so main takes them in the order it likes.
    q := <-quantity
    p := <-price
    fmt.Printf("quantity: %d, err: %v\n", q.value, q.err)
    fmt.Printf("price: %.2f, err: %v\n", p.value, p.err)

    // A result nobody receives: the buffer holds it, and the goroutine ends.
    start(func() (int, error) { return strconv.Atoi("42") })
    fmt.Println("goroutines after a buffered send nobody receives:  ", settle())

    // The same send on an unbuffered channel waits for a receiver forever.
    unbuffered := make(chan int)
    go func() { unbuffered <- 42 }()
    fmt.Println("goroutines after an unbuffered send nobody receives:", settle())
}

What to do

  • Give every goroutine that computes something a channel to send it on, and receive exactly once for every goroutine you started.
  • Send the error with the value — a struct holding both, or a channel of error when there is no value.
  • Restore the order of the inputs, or sort, before printing. Never let the goroutines print when the order matters.
  • When a function starts a goroutine on its caller's behalf, return a channel with a buffer of one, so a caller that walks away does not leave the goroutine blocked forever.
  • When a group should stop at its first error, the errgroup package does it — outside the standard library, in golang.org/x/sync — and First error cancels the rest builds the same thing from the standard library.

In other languages

  • The Concurrency library's Getting a result back ↗: only Rust's join and C's pthread_join hand back what the thread returned. C++, Java and Python return a future, which is the object start imitates — but their futures also carry an exception back to the waiting thread, and a Go panic cannot travel on a channel (A panic ends the whole program).
  • The Rust library's Spawning a thread ↗: JoinHandle<T> is the handle Go lacks, and join is the only way to get the T back. Its Channels ↗ page is Rust's version of the rest of this page — a value sent from one thread to another — with the difference that Rust moves ownership along with the value and its compiler stops the sender from touching it afterwards. Go's compiler checks nothing of the kind; a goroutine that keeps using what it sent is for The race detector to find.

Sources