DEV Community

puffball1567
puffball1567

Posted on

Go Concurrency Safety: Prevent Goroutine Leaks and Data Races

Goroutines make it easy to begin concurrent work. Making that work stop reliably and share state safely takes a little more design. This follow-up explains two problems that often appear after a first worker-pool or channel implementation: a goroutine that never exits, and shared memory that two goroutines access at the same time.

If you have not read the first article, start with Go Concurrency: How to Run Parallel Tasks with Goroutines, Channels, and Context. It introduces goroutines, channels, sync.WaitGroup, bounded worker pools, and context.

What is a goroutine leak in Go?

A goroutine leak is a goroutine that is still alive even though the work it was created for is no longer useful. It is not necessarily a memory leak in the usual sense. The goroutine may be blocked while waiting to send to a channel, receive from a channel, acquire a lock, or complete an operation that nobody will ever observe.

One leaked goroutine may not be noticeable. A leak on every HTTP request, reconnect, failed job, or page refresh eventually consumes memory, file descriptors, connections, and scheduler time. The practical rule is simple: every goroutine needs a clear completion path and a clear cancellation path.

A common Go channel leak: the consumer stops early

In this example, the producer sends a sequence of values, but the consumer only wants the first one.

func numbers(out chan<- int) {
    defer close(out)

    for number := 1; number <= 3; number++ {
        out <- number // The producer waits until somebody receives.
    }
}

func firstNumber() int {
    out := make(chan int)
    go numbers(out)

    return <-out // Return after receiving only the first value.
}
Enter fullscreen mode Exit fullscreen mode

firstNumber returns after receiving 1. The producer then tries to send 2, but no receiver remains. Because out is unbuffered, that send blocks forever. defer close(out) does not help: the producer cannot reach its return statement while blocked on the send.

This pattern can be less obvious in real code. A caller may return after an HTTP timeout, a user may navigate away, or a worker may stop after seeing the first acceptable result. In all of those cases, the upstream goroutine needs to learn that its output is no longer wanted.

Use context cancellation so a producer can stop

context.Context gives a goroutine a cancellation signal that can be selected alongside a channel operation.

package main

import (
    "context"
    "fmt"
)

func numbers(ctx context.Context, out chan<- int) {
    defer close(out)

    for number := 1; number <= 3; number++ {
        select {
        case out <- number:
            // The consumer accepted this value.
        case <-ctx.Done():
            // The consumer no longer needs more values.
            return
        }
    }
}

func firstNumber() int {
    ctx, cancel := context.WithCancel(context.Background())
    defer cancel() // Also cancel if this function returns through another path.

    out := make(chan int)
    go numbers(ctx, out)

    first := <-out
    cancel() // Tell the producer not to send the remaining values.
    return first
}

func main() {
    fmt.Println(firstNumber())
}
Enter fullscreen mode Exit fullscreen mode

The important part is the select. A plain out <- number can only wait for a receiver. The two-case select can instead stop when ctx.Done() becomes ready. Cancellation is cooperative: it does not forcibly kill a goroutine. Every blocking point that matters must check the cancellation signal or use an API, such as http.NewRequestWithContext, that observes it.

Avoid blocked result sends in a Go worker pool

The same issue appears when workers send results. A worker should not wait forever to send a result after its caller has timed out.

select {
case results <- result:
    // The collector accepted the completed result.
case <-ctx.Done():
    // The caller left, so do not remain blocked trying to report it.
    return
}
Enter fullscreen mode Exit fullscreen mode

This is why the earlier worker-pool example checks ctx.Done() both while accepting jobs and while sending results. A buffered channel can reduce waiting, but it does not replace cancellation: once a buffer fills, a sender can still block. More importantly, a buffer does not tell a producer that its remaining work is no longer needed.

Channel ownership: decide who closes a channel

Closing a channel means “no more values will be sent.” The goroutine that produces those values is usually the right owner of close.

func produce(ctx context.Context, jobs chan<- string) {
    defer close(jobs) // This producer knows when there will be no more jobs.

    for _, job := range []string{"one", "two"} {
        select {
        case jobs <- job:
        case <-ctx.Done():
            return
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

Receivers normally do not close a channel, because they cannot know whether another sender is still working. Closing the same channel twice panics, and sending on a closed channel also panics. When multiple workers produce results, use a coordinator such as sync.WaitGroup: wait for every worker to finish, then close the shared results channel exactly once.

What is a data race in Go?

A data race happens when two goroutines access the same memory concurrently, at least one access writes, and the accesses are not correctly synchronized. The result may look correct during a local test and fail under a different load or CPU schedule.

Incrementing an integer is a classic example. total++ looks like one operation in source code, but it is a read, a calculation, and a write. Two goroutines can read the same old value and both write back the same new value.

package main

import (
    "fmt"
    "sync"
)

func main() {
    var total int
    var wg sync.WaitGroup

    for i := 0; i < 1_000; i++ {
        wg.Add(1)
        go func() {
            defer wg.Done()
            total++ // Unsafe: several goroutines write total together.
        }()
    }

    wg.Wait()
    fmt.Println(total)
}
Enter fullscreen mode Exit fullscreen mode

The program may print 1000, or it may print a smaller number. Either outcome is not proof of safety. Run it with the race detector:

go run -race main.go
go test -race ./...
Enter fullscreen mode Exit fullscreen mode

The detector instruments the program and reports many unsynchronized concurrent accesses. It is an excellent safety net, but it only reports races exercised by the test or program run. Keep tests concurrent enough to execute the paths you care about.

Fix shared counters with sync.Mutex

Use a sync.Mutex when several goroutines need to modify one shared, mutable value.

package main

import "sync"

type Counter struct {
    mu    sync.Mutex
    value int
}

func (counter *Counter) Increment() {
    counter.mu.Lock()
    defer counter.mu.Unlock()

    counter.value++
}

func (counter *Counter) Value() int {
    counter.mu.Lock()
    defer counter.mu.Unlock()

    return counter.value
}
Enter fullscreen mode Exit fullscreen mode

The mutex protects the invariant: while one goroutine reads or changes value, another cannot enter a method protected by the same mutex. Keep the locked section small, and do not hold a mutex while making a slow network call or waiting for an unbounded external operation.

For simple counters, sync/atomic can also be appropriate. For a map plus related counts, a multi-field state change, or a rule that must be checked and updated together, a mutex often expresses the intent more clearly.

An alternative to a mutex: one goroutine owns the state

Channels are especially useful when one goroutine can own a piece of mutable state and other goroutines send it requests. That owner becomes the only code that touches the state directly.

type increment struct {
    reply chan int
}

func runCounter(requests <-chan increment) {
    total := 0 // Only this goroutine reads or writes total.

    for request := range requests {
        total++
        request.reply <- total
    }
}

func incrementCounter(requests chan<- increment) int {
    reply := make(chan int, 1)
    requests <- increment{reply: reply}
    return <-reply
}
Enter fullscreen mode Exit fullscreen mode

This design avoids shared writes to total, but it is not automatically better than a mutex. It introduces a request channel, a lifecycle for the owner goroutine, and possible backpressure. Choose it when serial ownership matches the domain, such as a connection manager, an in-memory session loop, or a stateful actor-like component. Use a mutex when protected shared state is simpler.

Context cancellation is not durable job delivery

Cancellation is for work whose result is no longer needed. It is not a promise that work will finish later. If a request context is cancelled, a worker pool may stop and discard unfinished in-memory jobs by design.

For payment processing, required notifications, inventory changes, or other state-changing work that must eventually complete, persist the job and its status in a database or durable queue. Use idempotency keys so a retry can be safe even when the earlier attempt may have partly completed. This is a reliability boundary, not something a goroutine or a channel can provide by itself.

Go concurrency safety checklist

  • Give every goroutine a normal completion path and a cancellation path.
  • Use select with ctx.Done() around channel operations that could otherwise wait forever.
  • Decide which producer owns each close(channel) call.
  • Do not assume a channel buffer prevents leaks or proves completion.
  • Protect shared mutable state with a mutex, atomics, or single-goroutine ownership.
  • Run go test -race ./... in local development and CI.
  • Use a durable store and idempotency for work that must survive a process or request ending.

The goal is not to avoid goroutines or channels. It is to make their ownership, stopping behavior, and shared-state rules explicit. The official Go context documentation, race detector guide, and Go pipelines article are useful next references.

Kinmokusei

Kinmokusei is a programming language with TypeScript-inspired syntax that compiles to readable Go. It is intended for writing web backends and Go libraries while using the normal Go toolchain and package ecosystem directly.

Top comments (0)