A logo showing the text blog.marcnuri.com
Español
Home»Go»Go Concurrency Patterns: Goroutines and Channels

Recent Posts

  • Fabric8 Kubernetes Client 7.4 is now available!
  • Kubernetes MCP Server Joins the Containers Organization!
  • MCP Tool Annotations: Adding Metadata and Context to Your AI Tools
  • Fabric8 Kubernetes Client 7.2 is now available!
  • Connecting to an MCP Server from JavaScript using AI SDK

Categories

  • Artificial Intelligence
  • Front-end
  • Go
  • Industry and business
  • Java
  • JavaScript
  • Legacy
  • Operations
  • Personal
  • Pet projects
  • Tools

Archives

  • September 2025
  • July 2025
  • May 2025
  • April 2025
  • March 2025
  • February 2025
  • January 2025
  • December 2024
  • November 2024
  • August 2024
  • June 2024
  • May 2024
  • April 2024
  • March 2024
  • February 2024
  • January 2024
  • December 2023
  • November 2023
  • October 2023
  • September 2023
  • August 2023
  • July 2023
  • June 2023
  • May 2023
  • April 2023
  • March 2023
  • February 2023
  • January 2023
  • December 2022
  • November 2022
  • October 2022
  • September 2022
  • August 2022
  • July 2022
  • June 2022
  • May 2022
  • March 2022
  • February 2022
  • January 2022
  • December 2021
  • November 2021
  • October 2021
  • September 2021
  • August 2021
  • July 2021
  • January 2021
  • December 2020
  • November 2020
  • October 2020
  • September 2020
  • August 2020
  • July 2020
  • June 2020
  • May 2020
  • February 2020
  • January 2020
  • December 2019
  • October 2019
  • September 2019
  • July 2019
  • March 2019
  • November 2018
  • July 2018
  • June 2018
  • May 2018
  • April 2018
  • March 2018
  • February 2018
  • December 2017
  • October 2017
  • August 2017
  • July 2017
  • January 2017
  • December 2015
  • November 2015
  • December 2014
  • March 2014
  • February 2011
  • November 2008
  • June 2008
  • May 2008
  • April 2008
  • January 2008
  • November 2007
  • September 2007
  • August 2007
  • July 2007
  • June 2007
  • May 2007
  • April 2007
  • March 2007

Go Concurrency Patterns: Goroutines and Channels

2020-02-29 in Go tagged Concurrency / Go by Marc Nuri | Last updated: 2025-09-13
Versión en Español

Introduction

Concurrency is one of Go's most distinctive features. The language was intentionally designed to make writing concurrent code practical and efficient, with goroutines and channels forming its core. Instead of adopting heavy operating system threads, Go provides an approachable model that lets you scale programs to thousands of concurrent operations with minimal overhead.

In this guide, we'll explore goroutines, channels, and several concurrency patterns with a mix of simple examples and best practices. Along the way, I’ll highlight pitfalls, trade-offs, and some practical lessons from real-world usage. By the end, you’ll be equipped to design responsive applications, whether they’re web servers, data pipelines, or background services.

Understanding Goroutines

Goroutines are lightweight tasks scheduled by the Go runtime rather than the operating system. They start with a small stack (about 2KB) and grow dynamically, which makes it reasonable to create thousands in a single process without exhausting memory.

Creating goroutines

Starting a goroutine only requires the go keyword:

basic_goroutine.go
package main

import (
    "fmt"
    "sync"
    "time"
)

func sayHelloInsistently(name string, wg *sync.WaitGroup) {
    defer wg.Done()
    for i := 0; i < 3; i++ {
        fmt.Printf("Hello, %s! (%d)\n", name, i)
        time.Sleep(100 * time.Millisecond)
    }
}

func main() {
    var wg sync.WaitGroup

    wg.Add(2)
    go sayHelloInsistently("Marc", &wg)
    go sayHelloInsistently("Alex", &wg)

    // Main goroutine participates too
    sayHelloInsistently("Main", &wg)

    wg.Wait()
}

Note

Many examples use time.Sleep to keep the main goroutine alive long enough for others to complete. This is fine for simple demos but not recommended in production code.

This example uses a sync.WaitGroup instead. It waits for all goroutines to finish before allowing the main function to exit, which is a more robust approach.

In this example, we crete two goroutines that run concurrently with the main goroutine. Each goroutine prints a greeting multiple times, demonstrating how they interleave their output. The sync.WaitGroup ensures the main function waits for all goroutines to complete before exiting.

Anonymous functions as goroutines

Anonymous functions can be launched as goroutines. Be cautious when capturing loop variables:

anonymous_goroutine.go
package main

import (
    "fmt"
    "time"
)

func main() {
    message := "Hello from closure"

    go func() {
        fmt.Println(message)
    }()

    for i := 0; i < 3; i++ {
        // Safe: explicitly pass loop variable
        go func(val int) {
            fmt.Printf("Value: %d\n", val)
        }(i)
    }

    time.Sleep(100 * time.Millisecond)
}

Warning

Forgetting to pass loop variables correctly often leads to subtle bugs where every goroutine prints the same final value.

This example, declares an anonymous goroutine function that captures the message variable from its surrounding scope. Additionally, it declares multiple goroutines inside a loop, passing the loop variable i as an argument to avoid capturing its final value.

Introduction to Channels

Channels are typed conduits for communication between goroutines. They allow values to be passed around safely without explicit locks.

In Go, the mantra is:

"Don't communicate by sharing memory; share memory by communicating."

Rob Pike

Creating and using channels

In the following code snippet, I create a channel to send and receive string messages between goroutines:

basic_channels.go
package main

import (
    "fmt"
    "time"
)

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

    go func() {
        time.Sleep(1 * time.Second)
        messages <- "Hello from goroutine!"
    }()

    msg := <-messages
    fmt.Println("Received:", msg)
}

Note

Note how the main goroutine blocks on <-messages until a value is sent into the channel.

The mainfunction demonstrates basic channel usage:

  1. We start by creating a channel with make(chan string).
  2. A goroutine sends a message into the channel after a brief delay.
  3. The main goroutine waits to receive the message, demonstrating synchronization between the two.

Buffered channels

Buffered channels let you enqueue values without blocking, up to their capacity:

buffered_channels.go
package main

import (
    "fmt"
)

func main() {
    buffer := make(chan string, 2)

    buffer <- "first"
    buffer <- "second"

    fmt.Println(<-buffer)
    fmt.Println(<-buffer)
}

Here, we create a buffered channel with a capacity of 2. We can send two messages into the channel without blocking. When we read from the channel, it retrieves the messages in the order they were sent.

The following snippet shows how the output looks when running the above code:

bash
$ go run buffered_channels.go
first
second

Channel directions

Channels can be restricted to send-only or receive-only. Restricting channels to send-only or receive-only makes APIs clearer and enhances type safety.

The following example demonstrates both types:

channel_directions.go
package main

import (
    "fmt"
)

func sendData(ch chan<- string) {
    ch <- "data"
}

func receiveData(ch <-chan string) string {
    return <-ch
}

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

    go sendData(ch)
    data := receiveData(ch)

    fmt.Println(data)
}

In this example, sendData can only send to the channel, while receiveData can only receive from it. This restriction helps prevent accidental misuse of channels.

Channel Operations and Patterns

Now that we understand the basics of channels, let's explore some common operations and patterns.

Closing channels

Closing a channel signals that no further values will be sent:

closing_channels.go
package main

import (
    "fmt"
)

func producer(ch chan<- int) {
    for i := 0; i < 5; i++ {
        ch <- i
    }
    close(ch)
}

func consumer(ch <-chan int) {
    for value := range ch {
        fmt.Printf("Received: %d\n", value)
    }
}

func main() {
    ch := make(chan int)
    go producer(ch)
    consumer(ch)
}

In this example, the producer function sends integers to the channel and then closes it. The consumer function reads from the channel until it is closed, using a for range loop.

By closing the channel, we avoid deadlocks and signal to the consumer that no more data will arrive.

Select statement

The select statement lets you react to whichever channel operation is ready first:

select_statement.go
package main

import (
    "fmt"
    "time"
)

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

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

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

    for i := 0; i < 2; i++ {
        select {
        case msg1 := <-ch1:
            fmt.Println("Received", msg1)
        case msg2 := <-ch2:
            fmt.Println("Received", msg2)
        case <-time.After(3 * time.Second):
            fmt.Println("Timeout")
        }
    }
}

In this example, we have two channels, ch1 and ch2, each receiving messages after different delays. The select statement waits for either channel to receive a message or for a timeout to occur. The output will show which message was received first, demonstrating how select can be used to handle multiple channel operations concurrently.

The output will look like this when running the above code:

bash
$ go run select_statement.go
Received from ch1
Received from ch2

Synchronization Primitives

Although channels cover many cases, sometimes you need lower-level tools like Mutex or WaitGroup from the sync package.

Mutex

Mutexes protect shared data against race conditions. Mutex locks ensure that only one goroutine can access a critical section of code at a time.

Let's look at an example:

mutex_example.go
package main

import (
    "fmt"
    "sync"
)

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
}

func main() {
    counter := &Counter{}
    var wg sync.WaitGroup

    // Start 100 goroutines that increment the counter
    for i := 0; i < 100; i++ {
        wg.Add(1)
        go func() {
            defer wg.Done()
            for j := 0; j < 100; j++ {
                counter.Increment()
            }
        }()
    }

    wg.Wait()
    fmt.Printf("Final counter value: %d\n", counter.Value())
}

In this example, we define a Counter struct with a sync.Mutex to protect access to its value. Multiple goroutines increment the counter concurrently, but the mutex ensures that only one goroutine can modify the value at a time.

The final output will show the correct counter value, demonstrating safe concurrent access.

WaitGroup

WaitGroups help coordinate goroutines by waiting for them to finish. We've already seen sync.WaitGroup in action in the goroutine example above, but here's a focused example:

waitgroup_example.go
package main

import (
    "fmt"
    "sync"
    "time"
)

func main() {
    var wg sync.WaitGroup

    for i := 0; i < 5; i++ {
        wg.Add(1)
        go func(id int) {
            defer wg.Done()
            fmt.Printf("Goroutine %d starting\n", id)
            time.Sleep(time.Duration(id) * 100 * time.Millisecond)
            fmt.Printf("Goroutine %d done\n", id)
        }(i)
    }

    wg.Wait()
    fmt.Println("All goroutines completed")
}

In this example, we start five goroutines, each simulating work by sleeping for a duration based on its ID. The WaitGroup ensures that the main function waits for all (5) goroutines to finish before printing the final message.

Although we can't predict the exact order of the Goroutine %d done messages due to the varying sleep times, we can be sure that All goroutines completed will only print after all goroutines have finished.

Error Handling in Concurrent Code

Concurrent systems often need structured error handling. A common pattern is returning both results and errors via a channel.

Let's look at an example:

error_handling.go
package main

import (
    "fmt"
)

type Result struct {
    Value int
    Error error
}

func worker(id int, jobs <-chan int, results chan<- Result) {
    for job := range jobs {
        // Simulate an operation that might fail
        if job%2 == 0 {
            results <- Result{Value: job * job, Error: nil}
        } else {
            results <- Result{Value: 0, Error: fmt.Errorf("worker %d: odd number %d", id, job)}
        }
    }
}

func main() {
    jobs := make(chan int, 10)
    results := make(chan Result, 10)

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

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

    // Collect results and handle errors
    for r := 1; r <= 10; r++ {
        result := <-results
        if result.Error != nil {
            fmt.Printf("Error: %v\n", result.Error)
        } else {
            fmt.Printf("Success: %d\n", result.Value)
        }
    }
}

In this example, we define a Result struct to encapsulate both the value and any error that occurred during processing. The worker function processes jobs and sends results back through the results channel. The main function starts multiple workers, sends jobs, and collects results, handling errors appropriately.

This pattern allows for clear separation of successful results and errors, making it easier to manage complex concurrent workflows.

Context for Cancellation

The context package is the idiomatic way to propagate cancellation and timeouts. It allows you to signal goroutines to stop work when the context is cancelled.

The following code snippet demonstrates using context for cancellation with timeouts:

context_example.go
package main

import (
    "context"
    "fmt"
    "time"
)

func longRunningTask(ctx context.Context, id int) {
    for {
        select {
        case <-ctx.Done():
            fmt.Printf("Task %d cancelled: %v\n", id, ctx.Err())
            return
        default:
            fmt.Printf("Task %d working...\n", id)
            time.Sleep(500 * time.Millisecond)
        }
    }
}

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

    for i := 1; i <= 3; i++ {
        go longRunningTask(ctx, i)
    }

    time.Sleep(3 * time.Second)
    fmt.Println("Main function ending")
}

In this example, we create a context with a timeout of 2 seconds. Each longRunningTask goroutine checks the context in its loop and exits gracefully when the context is cancelled. The main function sleeps for 3 seconds to allow tasks to run before it ends.

When you run this code, you'll see that each task prints its working message until the context times out, at which point they all print a cancellation message and exit.

Best Practices and Common Pitfalls

Best practices

  1. Use channels for communication: Prefer using channels over shared memory when possible.
  2. Close channels deliberately: Always close channels when no more data will be sent.
  3. Choose buffered channels carefully: Understand their blocking semantics.
  4. Manage goroutine lifecycles: Use context or signals to stop them gracefully.
  5. Check for leaks: Monitor goroutine counts in production and use tools like go test -race or go-leak.

Common pitfalls

  1. Loop variable capture: forgetting to pass values explicitly leads to repeated final values.
  2. Deadlocks from unclosed channels: always close producer channels when finished.
  3. Race conditions with shared data: protect state with sync.Mutex or delegate ownership to a goroutine.

Conclusion

In this post, I've covered the fundamentals of Go's concurrency model, including goroutines, channels, and synchronization primitives. Go's concurrency model provides elegant building blocks for scalable programs.

By mastering these patterns you'll be able to build production-ready systems that can handle thousands of operations in parallel without breaking a sweat.

You might also like

  • How to set up and tear down unit tests in Go
  • How to Initialize a new Go project with Modules
  • Understanding Variadic Functions in Go
Twitter iconFacebook iconLinkedIn iconPinterest iconEmail icon

Post navigation
Kubernetes Client for Java: Introducing YAKCHow to Initialize a New Go Project with Modules
© 2007 - 2025 Marc Nuri