A Simple Guide to Goroutines and Channels in Go
Goroutines and channels are Go's powerful concurrency primitives that make writing concurrent programs elegant, efficient, and safe. This guide explains how to use them effectively.
Concurrency is one of Go's standout features, and goroutines with channels are the tools that make concurrent programming in Go both powerful and approachable. Unlike threads in many other languages, goroutines are lightweight, managed by the Go runtime, and make it easy to run functions concurrently.
NOTE: This post assumes you have a basic understanding of Go and programming concepts. We'll explore both goroutines and channels, and see how they work together to build concurrent programs.
## What Are Goroutines?
A goroutine is a lightweight thread of execution managed by the Go runtime. They're incredibly cheap to create—you can run thousands or even millions of goroutines without significant overhead.
### Creating a Goroutine
Creating a goroutine is as simple as adding the go keyword before a function call:
import (
"fmt"
"time"
)
func sayHello(name string) {
fmt.Printf("Hello, %s!\n", name)
}
func main() {
// Regular function call (runs synchronously)
sayHello("Alice")
// Goroutine call (runs concurrently)
go sayHello("Bob")
// Give the goroutine time to finish
time.Sleep(time.Millisecond)
}### Anonymous Functions with Goroutines
You can also start goroutines with anonymous functions:
import (
"fmt"
"time"
)
func main() {
go func(msg string) {
fmt.Println(msg)
}("Hello from goroutine!")
// Wait for goroutine to finish
time.Sleep(time.Millisecond)
}### Multiple Goroutines
Let's see multiple goroutines running concurrently:
import (
"fmt"
"time"
)
func worker(id int, jobs <-chan int, results chan<- int) {
for j := range jobs {
fmt.Printf("Worker %d started job %d\n", id, j)
time.Sleep(time.Second) // Simulate work
fmt.Printf("Worker %d finished job %d\n", id, j)
results <- j * 2
}
}
func main() {
jobs := make(chan int, 100)
results := make(chan int, 100)
// Start 3 workers
for w := 1; w <= 3; w++ {
go worker(w, jobs, results)
}
// Send 5 jobs
for j := 1; j <= 5; j++ {
jobs <- j
}
close(jobs)
// Collect results
for a := 1; a <= 5; a++ {
<-results
}
}## What Are Channels?
Channels are the pipes that connect concurrent goroutines. You can send values into channels from one goroutine and receive those values in another goroutine.
### Creating and Using Channels
import "fmt"
func main() {
// Create a channel
messages := make(chan string)
// Send a value in a goroutine
go func() {
messages <- "ping"
}()
// Receive the value
msg := <-messages
fmt.Println(msg)
}### Channel Directions
You can specify channel direction to make your code clearer:
import "fmt"
// Only sends to channel
func sender(ch chan<- string) {
ch <- "Hello"
close(ch)
}
// Only receives from channel
func receiver(ch <-chan string) {
for msg := range ch {
fmt.Println(msg)
}
}
func main() {
ch := make(chan string)
go sender(ch)
receiver(ch)
}### Buffered Channels
By default, channels are unbuffered, meaning they only accept sends when there's a corresponding receive. Buffered channels accept a limited number of values without a receiver:
import "fmt"
func main() {
// Create a buffered channel with capacity 2
ch := make(chan string, 2)
// Sends won't block because channel is buffered
ch <- "first"
ch <- "second"
// Receive values
fmt.Println(<-ch) // "first"
fmt.Println(<-ch) // "second"
}## Synchronization with Channels
Channels are excellent for synchronizing goroutines:
import (
"fmt"
"time"
)
func worker(done chan bool) {
fmt.Print("Working...")
time.Sleep(time.Second)
fmt.Println("done")
// Signal that work is done
done <- true
}
func main() {
done := make(chan bool)
go worker(done)
// Wait for worker to finish
<-done
}## Select Statement
The select statement lets a goroutine wait on multiple communication operations:
import (
"fmt"
"time"
)
func main() {
c1 := make(chan string)
c2 := make(chan string)
go func() {
time.Sleep(time.Second)
c1 <- "one"
}()
go func() {
time.Sleep(2 * time.Second)
c2 <- "two"
}()
// Wait for both values, handling whichever arrives first
for i := 0; i < 2; i++ {
select {
case msg1 := <-c1:
fmt.Println("Received:", msg1)
case msg2 := <-c2:
fmt.Println("Received:", msg2)
}
}
}### Select with Timeout
import (
"fmt"
"time"
)
func main() {
c1 := make(chan string)
go func() {
time.Sleep(2 * time.Second)
c1 <- "result"
}()
select {
case res := <-c1:
fmt.Println(res)
case <-time.After(time.Second):
fmt.Println("Timeout!")
}
}## Real-World Examples
### Concurrent Web Scraping
import (
"fmt"
"net/http"
"sync"
"time"
)
func fetchURL(url string, wg *sync.WaitGroup) {
defer wg.Done()
start := time.Now()
resp, err := http.Get(url)
if err != nil {
fmt.Printf("Error fetching %s: %v\n", url, err)
return
}
defer resp.Body.Close()
fmt.Printf("Fetched %s (status: %d) in %v\n",
url, resp.StatusCode, time.Since(start))
}
func main() {
urls := []string{
"https://go.dev",
"https://github.com",
"https://golang.org",
}
var wg sync.WaitGroup
for _, url := range urls {
wg.Add(1)
go fetchURL(url, &wg)
}
wg.Wait()
fmt.Println("All URLs fetched!")
}### Worker Pool Pattern
import (
"fmt"
"sync"
"time"
)
type Job struct {
ID int
Payload string
Processed bool
}
type Result struct {
JobID int
Output string
Error error
}
func worker(id int, jobs <-chan Job, results chan<- Result, wg *sync.WaitGroup) {
defer wg.Done()
for job := range jobs {
fmt.Printf("Worker %d processing job %d\n", id, job.ID)
// Simulate processing
time.Sleep(time.Millisecond * 100)
results <- Result{
JobID: job.ID,
Output: fmt.Sprintf("Processed job %d", job.ID),
Error: nil,
}
}
}
func main() {
numJobs := 10
numWorkers := 3
jobs := make(chan Job, numJobs)
results := make(chan Result, numJobs)
var wg sync.WaitGroup
// Start workers
for w := 1; w <= numWorkers; w++ {
wg.Add(1)
go worker(w, jobs, results, &wg)
}
// Send jobs
for j := 1; j <= numJobs; j++ {
jobs <- Job{
ID: j,
Payload: fmt.Sprintf("Job payload %d", j),
}
}
close(jobs)
// Wait for workers to finish
go func() {
wg.Wait()
close(results)
}()
// Collect results
for result := range results {
if result.Error != nil {
fmt.Printf("Job %d failed: %v\n", result.JobID, result.Error)
} else {
fmt.Printf("Job %d succeeded: %s\n", result.JobID, result.Output)
}
}
}### Pipeline Pattern
import "fmt"
// First stage: generate numbers
func generate(nums ...int) <-chan int {
out := make(chan int)
go func() {
for _, n := range nums {
out <- n
}
close(out)
}()
return out
}
// Second stage: square numbers
func square(in <-chan int) <-chan int {
out := make(chan int)
go func() {
for n := range in {
out <- n * n
}
close(out)
}()
return out
}
// Third stage: multiply by 2
func multiplyByTwo(in <-chan int) <-chan int {
out := make(chan int)
go func() {
for n := range in {
out <- n * 2
}
close(out)
}()
return out
}
func main() {
// Set up the pipeline
numbers := generate(1, 2, 3, 4, 5)
squared := square(numbers)
doubled := multiplyByTwo(squared)
// Consume the output
for result := range doubled {
fmt.Println(result) // 2, 8, 18, 32, 50
}
}### Fan-Out/Fan-In Pattern
import (
"fmt"
"sync"
"time"
)
func producer(id int, out chan<- int, wg *sync.WaitGroup) {
defer wg.Done()
for i := 0; i < 3; i++ {
out <- id*10 + i
}
}
func consumer(in <-chan int, done chan<- bool) {
for value := range in {
fmt.Printf("Consumed: %d\n", value)
time.Sleep(time.Millisecond * 50)
}
done <- true
}
func main() {
// Fan-out: distribute work to multiple goroutines
source := make(chan int)
var wg sync.WaitGroup
for i := 0; i < 3; i++ {
wg.Add(1)
go producer(i, source, &wg)
}
// Close source channel when producers finish
go func() {
wg.Wait()
close(source)
}()
// Fan-in: collect results from multiple goroutines
done := make(chan bool)
go consumer(source, done)
<-done
fmt.Println("All done!")
}## Context for Cancellation
Go's context package is essential for managing goroutine lifecycles:
import (
"context"
"fmt"
"time"
)
func worker(ctx context.Context, id int) {
for {
select {
case <-ctx.Done():
fmt.Printf("Worker %d: shutting down\n", id)
return
default:
fmt.Printf("Worker %d: working\n", id)
time.Sleep(500 * time.Millisecond)
}
}
}
func main() {
ctx, cancel := context.WithCancel(context.Background())
// Start workers
for i := 1; i <= 3; i++ {
go worker(ctx, i)
}
// Let them work for a bit
time.Sleep(time.Second * 2)
// Cancel all goroutines
fmt.Println("Cancellation signal sent!")
cancel()
// Wait for clean shutdown
time.Sleep(time.Second)
}### Context with Timeout
import (
"context"
"fmt"
"time"
)
func task(ctx context.Context) {
for {
select {
case <-ctx.Done():
fmt.Println("Task cancelled:", ctx.Err())
return
default:
fmt.Println("Task working...")
time.Sleep(500 * time.Millisecond)
}
}
}
func main() {
// Create context with 2 second timeout
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
go task(ctx)
// Wait for timeout
time.Sleep(3 * time.Second)
}## Best Practices
### 1. Always Close Channels
The sender should close the channel when done:
import "fmt"
func producer(ch chan<- int) {
for i := 0; i < 5; i++ {
ch <- i
}
close(ch) // Always close when done
}
func main() {
ch := make(chan int)
go producer(ch)
// Range automatically stops when channel is closed
for value := range ch {
fmt.Println(value)
}
}### 2. Never Close from Receiver
Only the sender should close the channel, never the receiver:
func consumer(ch <-chan int) {
// Error: cannot close receive-only channel
close(ch)
}### 3. Use WaitGroups for Multiple Goroutines
import (
"fmt"
"sync"
)
func worker(id int, wg *sync.WaitGroup) {
defer wg.Done()
fmt.Printf("Worker %d starting\n", id)
fmt.Printf("Worker %d done\n", id)
}
func main() {
var wg sync.WaitGroup
for i := 1; i <= 5; i++ {
wg.Add(1)
go worker(i, &wg)
}
wg.Wait()
fmt.Println("All workers finished")
}### 4. Avoid Goroutine Leaks
Always ensure goroutines can exit:
func bad() {
ch := make(chan int)
go func() {
value := <-ch // Will block forever if nothing is sent
fmt.Println(value)
}()
// Function returns, goroutine blocked forever
}
// GOOD: Goroutine can exit
func good() {
ch := make(chan int)
go func() {
value, ok := <-ch
if !ok {
return // Channel closed, exit
}
fmt.Println(value)
}()
close(ch) // Signal goroutine to exit
}## Common Pitfalls
### 1. Data Races
package main
import (
"fmt"
"sync"
)
func main() {
var counter int
var wg sync.WaitGroup
for i := 0; i < 1000; i++ {
wg.Add(1)
go func() {
defer wg.Done()
counter++ // Data race!
}()
}
wg.Wait()
fmt.Println(counter) // Unpredictable result
}Fix with mutex:
package main
import (
"fmt"
"sync"
)
func main() {
var counter int
var wg sync.WaitGroup
var mu sync.Mutex
for i := 0; i < 1000; i++ {
wg.Add(1)
go func() {
defer wg.Done()
mu.Lock()
counter++
mu.Unlock()
}()
}
wg.Wait()
fmt.Println(counter) // Correct result: 1000
}### 2. Forgetting to Wait
func bad() {
go func() {
fmt.Println("Goroutine running")
time.Sleep(time.Second)
fmt.Println("Goroutine done")
}()
// Program exits immediately
}
// GOOD: Wait for goroutine
func good() {
done := make(chan bool)
go func() {
fmt.Println("Goroutine running")
time.Sleep(time.Second)
fmt.Println("Goroutine done")
done <- true
}()
<-done // Wait for signal
}## Conclusion
Goroutines and channels are Go's answer to concurrency, making it easy to write concurrent programs that are both efficient and correct. Remember:
- Goroutines are lightweight threads for concurrent execution
- Channels are for communication and synchronization between goroutines
- Use
selectfor waiting on multiple operations - Always close channels from the sender side
- Use context for managing goroutine lifecycles
- Be careful of data races and goroutine leaks
With these tools and patterns, you can build highly concurrent applications that are both performant and maintainable.
Published on January 19, 2026
10 min read
Found an Issue!
Find an issue with this post? Think you could clarify, update or add something? All my posts are available to edit on Github. Any fix, little or small, is appreciated!
Edit on GitHubLast updated on
A Simple Guide to Error Handling in Go
Error handling in Go is explicit and straightforward. Unlike exceptions in other languages, Go treats errors as values that you handle explicitly. This guide covers everything you need to know about error handling in Go.
A Simple Guide to React Error Boundaries
Error Boundaries are React components that catch JavaScript errors anywhere in their child component tree, log those errors, and display a fallback UI instead of the crashed component tree.
