Go Concurrency Patterns: Goroutines and Channels
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:
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:
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."
Creating and using channels
In the following code snippet, I create a channel to send and receive string messages between goroutines:
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 main
function demonstrates basic channel usage:
- We start by creating a channel with
make(chan string)
. - A goroutine sends a message into the channel after a brief delay.
- 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:
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:
$ 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:
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:
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:
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:
$ 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:
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:
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:
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:
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
- Use channels for communication: Prefer using channels over shared memory when possible.
- Close channels deliberately: Always close channels when no more data will be sent.
- Choose buffered channels carefully: Understand their blocking semantics.
- Manage goroutine lifecycles: Use
context
or signals to stop them gracefully. - Check for leaks: Monitor goroutine counts in production and use tools like
go test -race
orgo-leak
.
Common pitfalls
- Loop variable capture: forgetting to pass values explicitly leads to repeated final values.
- Deadlocks from unclosed channels: always close producer channels when finished.
- 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.