Goroutines in Go: How go func() Enables Concurrency

Goroutines in Go: How go func() Enables Concurrency

Concurrency in Go starts with goroutines. A goroutine is a lightweight thread of execution managed by the Go runtime. You create one by prefixing a function call with the go keyword:

go func() {
    fmt.Println("Running in a goroutine")
}()

That's it. No explicit thread creation, no pthread API, no manual stack management. The runtime handles scheduling, stack allocation, and cleanup.

How Goroutines Differ From Threads

Operating system threads are expensive. Creating a thread allocates a fixed stack (often 1-2 MB), involves kernel scheduling, and carries context-switching overhead. You don't casually spin up thousands of OS threads.

Goroutines are different. They start with a small stack (a few kilobytes) that grows and shrinks as needed. The Go runtime schedules goroutines onto a smaller number of OS threads using a technique called M:N scheduling—many goroutines run on fewer threads.

This means you can create thousands or even millions of goroutines without overwhelming the system. The cost of spawning a goroutine is closer to allocating an object than creating a thread.

for i := 0; i < 100000; i++ {
    go func(n int) {
        // Work happens here
    }(i)
}

This is normal Go code. Try the equivalent with OS threads in most languages and you'll exhaust system resources.

The go Keyword

The go keyword starts a new goroutine. You can use it with named functions or anonymous functions:

func worker(id int) {
    fmt.Println("Worker", id)
}

// Named function
go worker(1)

// Anonymous function
go func(id int) {
    fmt.Println("Worker", id)
}(2)

The function executes concurrently with the caller. The caller doesn't wait for it to finish unless you explicitly synchronize.

Synchronization

Goroutines run independently. If your main function exits, the program terminates—even if goroutines are still running. You need to wait for them:

var wg sync.WaitGroup

for i := 0; i < 5; i++ {
    wg.Add(1)
    go func(n int) {
        defer wg.Done()
        fmt.Println("Worker", n)
    }(i)
}

wg.Wait()  // Wait for all goroutines to finish

sync.WaitGroup is a counter. You increment it with Add, decrement it with Done, and block until it reaches zero with Wait.

Communication via Channels

WaitGroups handle synchronization, but channels handle communication. Instead of sharing memory and using locks, goroutines send data to each other through channels:

ch := make(chan int)

go func() {
    ch <- 42  // Send
}()

value := <-ch  // Receive
fmt.Println(value)

This is cleaner than mutexes for most cases. The channel becomes the synchronization point—the receiver blocks until a value is sent.

Common Mistakes

Goroutine leaks: If a goroutine blocks forever (waiting on a channel that never sends, for example), it never exits. This leaks memory. Make sure goroutines have a way to terminate.

// Bad: goroutine leaks if no one sends to ch
go func() {
    value := <-ch  // Blocks forever if ch is never written to
    fmt.Println(value)
}()

Use contexts or timeouts to prevent indefinite blocking.

Closure variable capture: A common mistake when spawning goroutines in loops:

for i := 0; i < 5; i++ {
    go func() {
        fmt.Println(i)  // Wrong: captures loop variable
    }()
}

All goroutines share the same i variable. By the time they run, i is likely 5. The fix is to pass i as a parameter:

for i := 0; i < 5; i++ {
    go func(n int) {
        fmt.Println(n)  // Correct: each goroutine gets its own copy
    }(i)
}

Race conditions: Goroutines accessing shared state without synchronization cause races. Use the race detector during development:

go run -race main.go

It catches most data races at runtime. Fix them with channels, mutexes, or atomic operations.

The Runtime Scheduler

Go's runtime uses a scheduler that multiplexes goroutines onto OS threads. The scheduler is cooperative—goroutines yield control at specific points (channel operations, function calls, blocking system calls).

The number of OS threads is controlled by GOMAXPROCS, which defaults to the number of CPU cores. You can set it explicitly:

runtime.GOMAXPROCS(4)  // Use 4 OS threads

Most programs don't need to touch this. The default is fine.

When to Use Goroutines

Use goroutines for:

  • Concurrent I/O (handling multiple network requests, reading multiple files)
  • Background tasks (periodic cleanup, monitoring, logging)
  • Parallelizing CPU-bound work (processing chunks of data independently)

Don't bother with goroutines for:

  • Code that's already fast enough sequentially
  • Situations where the coordination overhead outweighs the concurrency benefit

Concurrency adds complexity. Use it when it solves a real problem, not because it's available.

Goroutines vs Async/Await

Languages like JavaScript and Rust use async/await for concurrency. Go's model is different. Goroutines are simpler to write—no async keyword, no special function types, no manual polling of futures.

The tradeoff is that goroutines have runtime overhead. Each goroutine has a stack and scheduling metadata. Async/await models can be more memory-efficient for very high concurrency (millions of tasks).

In practice, Go's approach scales well enough for most use cases. The simplicity is worth the overhead.

Practical Example: Web Server

Go's standard library HTTP server spawns a goroutine per request:

http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
    // Each request runs in its own goroutine
    fmt.Fprintf(w, "Hello, World")
})

http.ListenAndServe(":8080", nil)

You don't see the goroutines explicitly, but they're there. This is why Go web servers handle thousands of concurrent connections with minimal configuration.

Context for Cancellation

Long-running goroutines often need a way to be cancelled. The context package handles this:

ctx, cancel := context.WithCancel(context.Background())

go func() {
    for {
        select {
        case <-ctx.Done():
            fmt.Println("Goroutine cancelled")
            return
        default:
            // Do work
        }
    }
}()

time.Sleep(time.Second)
cancel()  // Signal goroutine to stop

Contexts propagate cancellation across goroutines. Use them for request-scoped work, timeouts, and graceful shutdowns.

Further Reading

The Go blog's post on concurrency patterns demonstrates pipelines, fan-out, and cancellation.

Effective Go's section on goroutines covers the basics and common patterns.

For deeper understanding of the runtime and scheduler, see the Go scheduler design document.

Goroutines are one of Go's defining features. If go func() is a pattern you use regularly and appreciate for making concurrency manageable, our goroutine tee is a quiet recognition of that simplicity.

0 comments

Leave a comment

Please note, comments need to be approved before they are published.