Goroutines and channels: the foundation of concurrency in Go

1. Overview

Go is designed to write concurrent programs simply, without needing callback hell or complex thread pools. The two core building blocks are goroutines (lightweight execution units) and channels (communication channels between goroutines). After reading this article, you’ll know how to launch goroutines, synchronize them using sync.WaitGroup, communicate safely over channels, use select to handle multiple channels at once, and avoid common errors like goroutine leaks or deadlocks.

All examples use Go 1.22+, no external libraries needed — you just need to run go run main.go.

2. What is a Goroutine

A goroutine is a function that runs independently, managed by the Go runtime rather than the operating system. Creating a goroutine is very cheap (a few KB stack, auto-scaling), so you can spawn tens of thousands of goroutines without worrying about exhausting system resources like OS threads.

package main

import (
	"fmt"
	"time"
)

func sayHello(name string) {
	fmt.Println("Hello,", name)
}

func main() {
	go sayHello("Order Service")
	go sayHello("Payment Service")

	time.Sleep(100 * time.Millisecond) // wait for goroutines to finish
	fmt.Println("Main done")
}

The go keyword before a function call will run that function in a new goroutine, without blocking main. Since main might finish before the two goroutines get a chance to print, we must time.Sleep temporarily to wait — this is an unreliable approach, used only for illustration. The output might be:

Hello, Payment Service
Hello, Order Service
Main done

The output order is not fixed because the scheduler decides which goroutine runs first. This is the essence of concurrency: multiple tasks happen “simultaneously” but without guaranteed order.

Note: Using time.Sleep to synchronize goroutines in real code is an anti-pattern. Section 4 will introduce sync.WaitGroup as a replacement.

3. Channels: Communication Between Goroutines

Channels are type-safe pipes for goroutines to send and receive data. Go’s philosophy is: “Don’t communicate by sharing memory; share memory by communicating.”

package main

import "fmt"

func calculateTotal(amount float64, result chan<- float64) {
	tax := amount * 0.1
	result <- amount + tax
}

func main() {
	result := make(chan float64)

	go calculateTotal(1_000_000, result)

	total := <-result
	fmt.Printf("Total order amount: %.0f\n", total)
}
  • make(chan float64) creates an unbuffered channel of type float64.
  • chan<- float64 in the function parameter means the channel is send-only, allowing the compiler to catch errors if you accidentally read from it.
  • result <- amount + tax sends a value into the channel.
  • <-result in main will block until data is available, serving as natural synchronization — no need for Sleep.
Total order amount: 1100000

3.1. Unbuffered vs buffered channel

An unbuffered channel (make(chan T)) requires sender and receiver to “meet” at the same time — sending will block until someone receives. A buffered channel (make(chan T, n)) allows up to n values to be sent without requiring the receiver to be ready.

package main

import "fmt"

func main() {
	orders := make(chan string, 3)

	orders <- "Order-1"
	orders <- "Order-2"
	orders <- "Order-3"
	close(orders)

	for order := range orders {
		fmt.Println("Processing:", order)
	}
}

Since the buffer has capacity 3, the three sends don’t block. close(orders) signals that there’s no more data, allowing the range loop to exit naturally instead of blocking indefinitely waiting for the next value.

Processing: Order-1
Processing: Order-2
Processing: Order-3

Note: Only the sender can close a channel. Sending on a closed channel causes a panic send on closed channel; reading from a closed channel is still valid, returning the zero value and ok == false.

4. Synchronizing Multiple Goroutines with WaitGroup

When you need to wait for multiple goroutines to finish without caring about their return values, sync.WaitGroup is a cleaner choice than channels.

package main

import (
	"fmt"
	"sync"
)

func processOrder(id int, wg *sync.WaitGroup) {
	defer wg.Done()
	fmt.Printf("Processed Order-%d\n", id)
}

func main() {
	var wg sync.WaitGroup

	for i := 1; i <= 3; i++ {
		wg.Add(1)
		go processOrder(i, &wg)
	}

	wg.Wait()
	fmt.Println("All orders processed")
}
  • wg.Add(1) increments the counter before the goroutine runs — must be called before go, not inside the goroutine, to avoid race conditions.
  • defer wg.Done() decrements the counter when the goroutine finishes.
  • wg.Wait() blocks main until the counter reaches 0.
Processed Order-2
Processed Order-1
Processed Order-3
All orders processed

The first three lines can print in any order, but the last line always prints last — that’s what WaitGroup guarantees.

4.1. Testing Concurrent Behavior

You can write tests to verify that all goroutines complete, using an atomic counter instead of relying on print order:

package orderpkg

import (
	"sync"
	"sync/atomic"
	"testing"

	"github.com/stretchr/testify/assert"
)

func TestProcessOrders_AllCompleted(t *testing.T) {
	var wg sync.WaitGroup
	var processed int32

	for i := 1; i <= 5; i++ {
		wg.Add(1)
		go func(id int) {
			defer wg.Done()
			atomic.AddInt32(&processed, 1)
		}(i)
	}

	wg.Wait()

	assert.Equal(t, int32(5), processed)
}

atomic.AddInt32 prevents race conditions when multiple goroutines increment the counter — if you use plain processed++, go test -race will report a data race.

Dependency for the test (add to go.mod):

go get github.com/stretchr/testify

5. Select: Handling Multiple Channels at Once

select is like switch but for channels — it picks whichever case is ready first to execute. Very useful when you need to wait for multiple data sources or set a timeout.

package main

import (
	"fmt"
	"time"
)

func fetchFromCache(ch chan<- string) {
	time.Sleep(50 * time.Millisecond)
	ch <- "data from cache"
}

func fetchFromDB(ch chan<- string) {
	time.Sleep(200 * time.Millisecond)
	ch <- "data from database"
}

func main() {
	cacheCh := make(chan string)
	dbCh := make(chan string)

	go fetchFromCache(cacheCh)
	go fetchFromDB(dbCh)

	select {
	case res := <-cacheCh:
		fmt.Println("Received:", res)
	case res := <-dbCh:
		fmt.Println("Received:", res)
	case <-time.After(100 * time.Millisecond):
		fmt.Println("Timeout, using default data")
	}
}

select blocks until one of the cases has data ready. Since cache returns after 50ms and the database after 200ms, the cacheCh case wins:

Received: data from cache

time.After creates a channel that receives a value after a specified duration, used to implement a timeout for the entire select.

6. Canceling Goroutines Properly with Context

A common mistake is spawning a goroutine with no way to stop it, leading to goroutine leaks. The context package solves this by sending cancellation signals throughout the call chain.

package main

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

func monitorPayment(ctx context.Context, orderID string) {
	for {
		select {
		case <-ctx.Done():
			fmt.Println("Stopped monitoring order:", orderID, "-", ctx.Err())
			return
		case <-time.After(500 * time.Millisecond):
			fmt.Println("Checking order status:", orderID)
		}
	}
}

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

	go monitorPayment(ctx, "Order-99")

	<-ctx.Done()
	time.Sleep(50 * time.Millisecond) // wait for goroutine to print the last line
}
  • context.WithTimeout creates a context that automatically calls Done() after 1200ms.
  • In monitorPayment, select listens to both ctx.Done() and periodic ticks. When the context expires, the goroutine exits cleanly instead of running forever.
  • defer cancel() ensures context resources are freed even if the function exits early.
Checking order status: Order-99
Checking order status: Order-99
Stopped monitoring order: Order-99 - context deadline exceeded

Note: Always call cancel() (via defer) whether using WithTimeout or WithCancel. Forgetting this doesn’t cause immediate errors but leaks context in long-running loops.

7. Common Mistakes

Deadlock from an unread channel. Sending on an unbuffered channel with no goroutine to receive will freeze the program, and the Go runtime will detect it:

func main() {
	ch := make(chan int)
	ch <- 1 // no one to receive → deadlock
}
fatal error: all goroutines are asleep - deadlock!

Goroutine leak. A goroutine waits to read a channel forever because no one sends and there’s no cancellation mechanism — memory stays allocated until the program exits. Always pair context or a “done” channel when launching long-running goroutines.

Incorrect loop variable capture (before Go 1.22). If you use Go < 1.22, the loop variable i in for is shared across iterations, causing all goroutines to see the same final value. From Go 1.22 onwards, each loop iteration creates a new i, so this issue is gone — but if your project uses an older version, pass the variable as a parameter like the example in section 4.1.

Race conditions from shared variables. Two goroutines reading/writing the same variable without going through channels or sync/atomic is a classic race condition. Always run go test -race or go run -race during development to catch these early.

When NOT to use goroutines: for short tasks, the cost of creating and synchronizing goroutines may outweigh the benefits. If the work takes only a few microseconds, sequential execution is often faster and more readable.

8. Conclusion

Goroutines give you a way to create lightweight execution threads at minimal cost, while channels provide a safe communication mechanism instead of direct memory sharing. Combined with sync.WaitGroup for synchronization, select for handling multiple sources, and context for proper cancellation, you have all the tools to build common concurrency patterns in Go — from worker pools and pipelines to fan-in/fan-out. Next, practice with go test -race to build the habit of detecting race conditions early.

golanggoroutinechannelconcurrency