Concurrency is one of Go's defining features. It lets a program make progress on multiple tasks during the same period of time, even when those tasks are not literally executing at the same instant. Go's concurrency model centers on goroutines and channels, which make it easier to structure and coordinate concurrent work without manually managing operating-system threads.

Understanding the difference between concurrency and parallelism is important. Concurrency is about structuring independent work so tasks can make progress together; parallelism is about actually executing work simultaneously on multiple CPU cores. This tutorial explores goroutines, channels, select and synchronization primitives that Go provides for both kinds of workloads.

What is concurrency?

Concurrency refers to a program's ability to deal with many tasks at once. In Go, concurrency is supported directly by the language's runtime and standard library. Unlike traditional models that rely heavily on system-level threads, Go uses a lightweight concurrency model that provides a simpler and more efficient way to manage multiple tasks.

Go was designed to make concurrent programming easier to express, but it does not eliminate concurrency bugs. Programs can still contain race conditions, deadlocks, goroutine leaks and incorrect synchronization. Goroutines, channels and tools such as the race detector reduce the amount of low-level thread management developers must perform, but correct coordination is still the programmer's responsibility.

Concurrency vs. parallelism

It's important to distinguish between concurrency and parallelism. Concurrency is about how to structure a program to handle multiple tasks at once, but not necessarily execute them simultaneously. Parallelism, on the other hand, is the actual simultaneous execution of tasks on multiple CPU cores.

In the Go language, concurrency can exist even on a single-core machine, where the runtime switches between goroutines, which gives the appearance of tasks running in parallel. If the machine has multiple cores, Go can run multiple goroutines in parallel. However, Go's design prioritizes concurrency, making it simple to write code that runs concurrently regardless of hardware parallelism.

How Go's concurrency model works

At a high level, Go's concurrency model is built around goroutines and channels.

A goroutine is a lightweight independently scheduled function managed by the Go runtime. It is not the same thing as an operating-system thread. The runtime multiplexes many goroutines across a smaller set of OS threads and can schedule them across available CPU cores. Launching one is as simple as prefixing a function call with the go keyword.

Channels provide typed communication between goroutines and can also synchronize their execution. They are especially useful when ownership of a value can be passed from one goroutine to another. Channels do not make all shared-memory access automatically safe, however; programs that share mutable state may still need synchronization such as sync.Mutex. Go's channel model is strongly influenced by communicating sequential processes (CSP).

Goroutines and computation

Goroutines are the backbone of concurrency in Go. They are functions or methods that run independently and concurrently with other goroutines in the same address space. Launching a goroutine is as simple as prefixing a function call with the go keyword, as follows:

go doWork()

This starts doWork() in a new goroutine. The Go runtime schedules and manages these goroutines so that many can run concurrently, thanks to their lightweight nature. Unlike threads, which are expensive to create and maintain, goroutines require minimal memory (as small as a few kilobytes each) and grow dynamically as needed.

Goroutines are especially useful for independent I/O operations, servers, pipelines and worker pools. They can also help structure CPU-bound work, but CPU-bound goroutines only run in parallel when the runtime can schedule them on multiple available cores.

Channels and communication

Goroutines often must communicate or coordinate their actions. This is where channels come in. Channels provide a convenient way to send and receive values between goroutines and often avoid the need for explicit locking.

In the following example, one goroutine sends the value 37 into the channel, and another receives it:

ch := make(chan int)

go func() {

    ch <- 37

}()

fmt.Println(<-ch)

For an unbuffered channel, a send blocks until another goroutine is ready to receive, and a receive blocks until a value is ready to be sent. This rendezvous provides a useful synchronization point between goroutines. Buffered channels behave differently: a send can proceed while buffer capacity remains, and a receive can proceed while buffered values are available.

Buffered channels

Go also supports buffered channels. A buffered channel can hold a fixed number of values, so a sender does not block until the buffer becomes full. Buffering can decouple producers and consumers that operate at different speeds, but it should be chosen to match the communication pattern rather than used automatically as a performance optimization.

ch := make(chan int, 3)

ch <- 1

ch <- 2

ch <- 3

Reading channels with range

A convenient way to consume values until no more will be sent is to use for ... range on the channel. The loop waits when no value is currently available and terminates only after the channel has been closed and all buffered values have been received. The sending side is normally responsible for closing the channel.

ch := make(chan int)

// send data

go func() {

    for i := 1; i <= 5; i++ {

        ch <- i

    }

    close(ch) // Important: close the channel to stop the range loop

}()

// Process the channel using for range

for val := range ch {

    fmt.Println(val)

}

Multiplexing with the select statement

Go's select statement lets one goroutine wait on multiple channel operations. If several cases are ready, one is chosen. A default case makes the operation nonblocking because it runs immediately when no channel case is ready:

select {

case msg := <-ch1:

    fmt.Println("Received from ch1:", msg)

case msg := <-ch2:

    fmt.Println("Received from ch2:", msg)

default:

    fmt.Println("No communication")

}

select is useful for coordinating multiple channels, timeouts and cancellation signals. Use a default case carefully in loops, because repeatedly selecting the default branch can create a busy loop that consumes CPU.

Go and synchronization

While channels provide a natural synchronization mechanism, Go also offers other synchronization primitives, including sync.WaitGroup, sync.Mutex and sync.Once for more granular control.

Synchronizing with WaitGroup

sync.WaitGroup is commonly used when one goroutine must wait for a known set of other goroutines to finish. Add the expected work before starting the goroutines, call Done() when each finishes and call Wait() where execution must pause until the group completes:

var wg sync.WaitGroup

wg.Add(2)

go func() {

    defer wg.Done()

    // Do something

}()

go func() {

    defer wg.Done()

    // Do something else

}()

wg.Wait()

Recombining results

When performing concurrent computations, it's often necessary to recombine results. Channels are perfect for this task. Each worker goroutine sends its result back through a channel, and the main goroutine collects and combines them as follows:

results := make(chan int, numWorkers)

for i := 0; i < numWorkers; i++ {

    go func(i int) {

        results <- compute(i)

    }(i)

}

sum := 0

for i := 0; i < numWorkers; i++ {

    sum += <-results

}

fmt.Println("Total:", sum)

This fan-out/fan-in pattern is common in worker pools and parallel computations: work is distributed across goroutines, and results are collected through a channel.

Concurrency mistakes to watch for in Go

Go's syntax makes concurrency approachable, but production code still needs deliberate lifecycle and error handling. Keep these issues in mind:

  • Data races: Protect shared mutable state or redesign the code so a single goroutine owns it. Run tests with go test -race when appropriate.
  • Deadlocks: Make sure channel sends, receives and locks always have a path that can make progress.
  • Goroutine leaks: Long-lived goroutines need a way to stop when their work is canceled or no longer needed.
  • Channel ownership: The goroutine responsible for sending values should normally decide when the channel is closed. Never send on a closed channel.
  • Cancellation: For request-scoped or long-running work, use context.Context to propagate cancellation and deadlines.

Conclusion

With goroutines, channels and synchronization primitives in the standard library, Go makes concurrent programs comparatively straightforward to express. Developers still need to reason about ownership, cancellation, races, deadlocks and goroutine lifecycles, but they can usually do so without directly managing operating-system threads.

Whether you want to build web servers, data processing pipelines or distributed systems, Go's concurrency model provides the foundation for clean and maintainable concurrent code.

David "Walker" Aldridge is a programmer with 40 years of experience in multiple languages and remote programming. He is also an experienced systems admin and infosec blue team member with interest in retrocomputing.