Skip to content
Home
Go Concurrency: Goroutines, Channels, and Select

Go Concurrency: Goroutines, Channels, and Select

Go Go 8 min read 1530 words Beginner ExcellentWiki Editorial Team

Concurrency is built into Go at the language level. Goroutines make it easy to run functions concurrently, channels provide safe communication between goroutines, and the select statement lets you wait on multiple channel operations. Unlike threads in other languages, goroutines are lightweight (starting at ~4 KB of stack) and multiplexed onto OS threads by the Go runtime.

Goroutines

A goroutine is a function that runs concurrently with other functions. You start one with the go keyword:

func sayHello() {
    fmt.Println("Hello from goroutine")
---

func main() {
    go sayHello()        // Starts in a new goroutine
    fmt.Println("Hello from main")
    time.Sleep(time.Second) // Wait for goroutine to finish
---

In practice, you don’t use time.Sleep to wait. You use channels, sync.WaitGroup, or other synchronization primitives.

Anonymous Goroutines

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

WaitGroup

sync.WaitGroup waits for a collection of goroutines to finish:

var wg sync.WaitGroup

for i := 0; i < 5; i++ {
    wg.Add(1)
    go func(id int) {
        defer wg.Done()
        fmt.Printf("Worker %d done\n", id)
    }(i)
---

wg.Wait()  // Blocks until all 5 workers finish

Channels

Channels are typed conduits that let goroutines send and receive values. Create them with make:

ch := make(chan int)     // Unbuffered channel
ch := make(chan string, 10) // Buffered channel, capacity 10

Send and Receive

ch <- 42    // Send 42 into channel
value := <-ch  // Receive from channel
close(ch)   // Close channel (sender only)

Unbuffered Channels

An unbuffered channel blocks the sender until another goroutine receives, and blocks the receiver until another goroutine sends. This provides synchronization — the send and receive happen at the same time.

func main() {
    ch := make(chan string)

    go func() {
        ch <- "ping"  // Blocks until main receives
    }()

    msg := <-ch  // Blocks until goroutine sends
    fmt.Println(msg) // "ping"
---

Buffered Channels

A buffered channel blocks the sender only when the buffer is full, and blocks the receiver only when the buffer is empty.

ch := make(chan int, 3)
ch <- 1
ch <- 2
ch <- 3
// ch <- 4  // Would block (buffer full)

fmt.Println(<-ch) // 1
fmt.Println(<-ch) // 2
fmt.Println(<-ch) // 3

Range Over Channels

You can use for range to receive values until the channel is closed:

ch := make(chan int)

go func() {
    for i := 0; i < 5; i++ {
        ch <- i
    }
    close(ch)  // Must close, or range will deadlock
---()

for value := range ch {
    fmt.Println(value) // 0, 1, 2, 3, 4
---

Directional Channels

You can restrict channel directions in function parameters:

func producer(ch chan<- int) {  // Send-only
    for i := 0; i < 10; i++ {
        ch <- i
    }
    close(ch)
---

func consumer(ch <-chan int) {  // Receive-only
    for v := range ch {
        fmt.Println(v)
    }
---

Select Statement

select lets a goroutine wait on multiple channel operations simultaneously:

func main() {
    ch1 := make(chan string)
    ch2 := make(chan string)

    go func() {
        time.Sleep(1 * time.Second)
        ch1 <- "one"
    }()

    go func() {
        time.Sleep(2 * time.Second)
        ch2 <- "two"
    }()

    select {
    case msg1 := <-ch1:
        fmt.Println("Received from ch1:", msg1)
    case msg2 := <-ch2:
        fmt.Println("Received from ch2:", msg2)
    case <-time.After(3 * time.Second):
        fmt.Println("Timeout")
    default:
        fmt.Println("No channels ready")
    }
---

Key properties:

  • select blocks until one of its cases can proceed
  • If multiple cases are ready, one is chosen at random
  • A default case makes it non-blocking
  • A nil channel is never ready, useful for disabling cases

Timeout Pattern

select {
case result := <-ch:
    fmt.Println(result)
case <-time.After(2 * time.Second):
    fmt.Println("Operation timed out")
---

Mutexes

When multiple goroutines access shared data, use sync.Mutex to prevent race conditions:

type Counter struct {
    mu    sync.Mutex
    value int
---

func (c *Counter) Increment() {
    c.mu.Lock()
    defer c.mu.Unlock()
    c.value++
---

func (c *Counter) Value() int {
    c.mu.Lock()
    defer c.mu.Unlock()
    return c.value
---

For read-heavy workloads, use sync.RWMutex:

type Cache struct {
    mu    sync.RWMutex
    data  map[string]string
---

func (c *Cache) Get(key string) (string, bool) {
    c.mu.RLock()
    defer c.mu.RUnlock()
    v, ok := c.data[key]
    return v, ok
---

func (c *Cache) Set(key, value string) {
    c.mu.Lock()
    defer c.mu.Unlock()
    c.data[key] = value
---

Worker Pool Pattern

A worker pool limits concurrency and distributes work across a fixed number of goroutines:

func worker(id int, jobs <-chan int, results chan<- int) {
    for job := range jobs {
        fmt.Printf("Worker %d processing job %d\n", id, job)
        time.Sleep(time.Second)  // Simulate work
        results <- job * 2
    }
---

func main() {
    const numJobs = 10
    const numWorkers = 3

    jobs := make(chan int, numJobs)
    results := make(chan int, numJobs)

    // Start workers
    for w := 0; w < numWorkers; w++ {
        go worker(w, jobs, results)
    }

    // Send jobs
    for j := 0; j < numJobs; j++ {
        jobs <- j
    }
    close(jobs)

    // Collect results
    for r := 0; r < numJobs; r++ {
        <-results
    }
---

Common Concurrency Patterns

Fan-Out / Fan-In

Distribute work across multiple goroutines (fan-out) and collect results (fan-in):

func fanOut(input <-chan int, workers int) []<-chan int {
    channels := make([]<-chan int, workers)
    for i := 0; i < workers; i++ {
        channels[i] = worker(input)
    }
    return channels
---

func fanIn(channels ...<-chan int) <-chan int {
    out := make(chan int)
    var wg sync.WaitGroup
    for _, ch := range channels {
        wg.Add(1)
        go func(c <-chan int) {
            defer wg.Done()
            for v := range c {
                out <- v
            }
        }(ch)
    }
    go func() {
        wg.Wait()
        close(out)
    }()
    return out
---

Pipeline

Connect stages with channels:

func gen(nums ...int) <-chan int {
    out := make(chan int)
    go func() {
        for _, n := range nums {
            out <- n
        }
        close(out)
    }()
    return out
---

func sq(in <-chan int) <-chan int {
    out := make(chan int)
    go func() {
        for n := range in {
            out <- n * n
        }
        close(out)
    }()
    return out
---

// Usage: pipeline := sq(sq(gen(1, 2, 3, 4)))

Best Practices

  • Do not communicate by sharing memory; share memory by communicating. Prefer channels over shared variables with mutexes.
  • Close channels from the sender side, never from the receiver. Sending on a closed channel panics.
  • Use buffered channels when producers and consumers have different rates, but unbuffered channels provide stronger synchronization guarantees.
  • Always use go run -race during development to detect race conditions.
  • Limit goroutine creation — use worker pools for controlled concurrency.
  • Make goroutines exit when the main function returns — a running program exits when main returns, killing all goroutines.

Context Package

The context package is essential for managing cancellation, timeouts, and request-scoped values across goroutines:

func worker(ctx context.Context, id int, jobs <-chan int) {
    for {
        select {
        case <-ctx.Done():
            fmt.Printf("Worker %d shutting down\n", id)
            return
        case job, ok := <-jobs:
            if !ok {
                return
            }
            process(job)
        }
    }
---

func main() {
    ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
    defer cancel()

    jobs := make(chan int)
    go worker(ctx, 1, jobs)

    for i := 0; i < 10; i++ {
        jobs <- i
    }
    close(jobs)
---

Pass ctx as the first argument to any function that makes I/O calls or spawns goroutines. This creates a consistent pattern for cancellation across your codebase.

Common Concurrency Pitfalls

  1. Goroutine leaks — A goroutine blocked on a channel send that nobody reads will live forever. Use buffered channels or context cancellation to prevent this.
  2. Closing channels from the receiver — Only the sender should close a channel. Closing from the receiver causes a panic.
  3. Using time.Sleep for synchronization — This is fragile and slow. Use channels, WaitGroups, or context for proper synchronization.
  4. Capturing loop variables — Goroutine closures that reference loop variables capture the variable reference, not its value. Pass loop variables as arguments: go func(v int) { ... }(v).
  5. Not using the race detector — Run go test -race during development. It catches data races that might not manifest until production under load.

Conclusion

Go’s concurrency model — goroutines, channels, and select — makes concurrent programming safer and more approachable than traditional threading. Start goroutines with go, communicate with channels, coordinate with select, and protect shared state with mutexes when channels are inappropriate. Always test with the race detector enabled.

Related: See our Go REST API guide and Go testing guide.

FAQ

Q: How many goroutines can I run simultaneously? A: Thousands to millions. Goroutines are extremely lightweight (~4KB stack) and multiplexed onto OS threads by the Go runtime.

Q: When should I use channels vs mutexes? A: Use channels for passing data ownership between goroutines (communicate by sharing memory). Use mutexes for protecting shared state that multiple goroutines access concurrently.

Q: What is the difference between buffered and unbuffered channels? A: Unbuffered channels synchronize sender and receiver — both must be ready. Buffered channels decouple them up to the buffer size. Unbuffered channels provide stronger synchronization guarantees.

Q: How do I stop a goroutine from outside? A: Use a context with cancellation or a dedicated done channel. The goroutine should check for cancellation via select and return cleanly.

Q: What does go test -race do? A: It enables the race detector, which instruments your program to detect concurrent memory access conflicts. Always run it during development and in CI.

Q: Can I use goroutines for CPU-bound work? A: Yes, but the default goroutine limit matches the number of CPU cores. For CPU-intensive work, use runtime.GOMAXPROCS to control parallelism.

Section: Go 1530 words 8 min read Beginner 756 articles in section Report inaccuracy Back to top