Back to blog
Aug 08, 2026
5 min read

Mastering Concurrency in Go: Goroutines, Channels, and Select

A practical deep dive into Go's concurrency model — goroutines, channels, and the select statement — with real-world patterns every backend engineer should know.

Concurrency is one of Go’s biggest strengths. The language was designed from the ground up to make concurrent programming simpler and safer than in most other languages.

In this post we will explore the three core building blocks of concurrency in Go:

  • Goroutines
  • Channels
  • The select statement

By the end you will understand not only how they work, but when and how to use them effectively in real backend systems.


1. Goroutines — Lightweight Concurrent Functions

A goroutine is a lightweight thread managed by the Go runtime. Starting a new goroutine is as simple as putting the go keyword in front of a function call:

package main

import (
	"fmt"
	"time"
)

func sayHello() {
	fmt.Println("Hello from a goroutine!")
}

func main() {
	go sayHello() // starts a new goroutine

	time.Sleep(100 * time.Millisecond) // give the goroutine time to run
	fmt.Println("Hello from main")
}

Key characteristics of goroutines

  • Extremely cheap to create (you can easily have tens of thousands of them).
  • Scheduled by the Go runtime, not by the operating system.
  • They share the same address space (be careful with shared memory!).

Important: When the main function returns, the entire program exits — even if other goroutines are still running. That is why we often use sync.WaitGroup, channels, or context to coordinate completion.


2. Channels — Safe Communication Between Goroutines

Go follows the philosophy:

Do not communicate by sharing memory; instead, share memory by communicating.

Channels are the primary way goroutines talk to each other.

Creating a channel

// Unbuffered channel
ch := make(chan int)

// Buffered channel (capacity 5)
buffered := make(chan string, 5)

Sending and receiving

ch <- 42        // send value to channel
value := <-ch   // receive value from channel

Unbuffered vs Buffered channels

TypeBehaviorUse when…
UnbufferedSender blocks until receiver is ready (and vice-versa)You want strong synchronization
BufferedSender blocks only when the buffer is fullYou want some decoupling / throughput

Closing a channel

close(ch)

Receiving from a closed channel is safe and returns the zero value. You can also check if the channel is closed:

value, ok := <-ch
if !ok {
	fmt.Println("Channel is closed")
}

3. The select Statement — Multiplexing Channels

select lets a goroutine wait on multiple channel operations at the same time. It is similar to a switch, but for channels:

select {
case msg := <-ch1:
	fmt.Println("Received from ch1:", msg)
case ch2 <- 42:
	fmt.Println("Sent to ch2")
case <-time.After(1 * time.Second):
	fmt.Println("Timeout")
default:
	fmt.Println("No communication ready")
}

Common patterns with select

Timeouts

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

Non-blocking operations

select {
case msg := <-ch:
	fmt.Println(msg)
default:
	fmt.Println("no message available")
}

4. Practical Patterns You Should Know

Worker Pool

One of the most useful patterns in backend services:

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 = 5
	jobs := make(chan int, numJobs)
	results := make(chan int, numJobs)

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

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

	// collect results
	for a := 1; a <= numJobs; a++ {
		fmt.Println("result:", <-results)
	}
}

Fan-out / Fan-in

Useful when you want to process data in parallel and then combine the results.


5. Best Practices & Common Pitfalls

Do

  • Prefer channels over shared memory + mutexes when possible.
  • Always think about who is responsible for closing a channel (usually the sender).
  • Use context.Context for cancellation and timeouts in real services.
  • Use sync.WaitGroup when you just need to wait for a group of goroutines to finish.

Don’t

  • Never close a channel from the receiver side.
  • Avoid leaking goroutines (always have a way to stop them).
  • Don’t use buffered channels as a way to “fire and forget” without understanding the consequences.
  • Be careful with unbounded goroutine creation under load.

6. When Should You Reach for Concurrency?

Concurrency is powerful, but it is not free. Ask yourself:

  • Am I waiting on I/O (network, disk, database)?
  • Can independent pieces of work run at the same time?
  • Will the added complexity be worth the performance gain?

If the answer is yes, goroutines + channels are usually the right tool.


Conclusion

Goroutines, channels, and select form the foundation of concurrent programming in Go. Mastering them will make you a much more effective backend engineer — especially when building services that need to handle many concurrent requests, background jobs, or real-time workloads.

In future posts we will explore:

  • The powerful context package
  • Graceful shutdown patterns
  • Advanced concurrency patterns and common concurrency bugs

Until then, try rewriting a small sequential program using goroutines and channels. The best way to learn concurrency is by writing concurrent code.

Happy coding!