Learn how to use concurrency in Go with this tutorial

Learn how to use concurrency in Go with this tutorial

Concurrency is a powerful feature of the Go programming language, designed to simplify the development of programs that perform multiple tasks simultaneously. Go’s concurrency model is built around goroutines and channels to cleanly and efficiently manage concurrent tasks without the complexity commonly found in other languages’ threading models.

Separating the concepts of concurrency and parallelism enables developers to write highly scalable programs while maintaining code clarity and readability. Here we explore the Go language’s approach to simplify concurrent programming through goroutines, channels and synchronization.

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 with simplicity in mind. Its concurrency model avoids many of the common pitfalls of traditional thread-based programming, such as deadlocks, race conditions and complex locking mechanisms. The language uses goroutines and channels to abstract away much of the underlying complexity, so developers can focus on the logic of the concurrent tasks rather than the intricacies of thread management.

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 thread managed by the Go runtime. Rather than manually create threads and handle their lifecycles, a developer can launch a goroutine with a simple go keyword. The Go runtime manager schedules these goroutines efficiently across available CPU cores or runs them context-switching in an async manner if an unused core is not available.

Channels act as conduits through which goroutines communicate safely, without the need for explicit locks or shared memory management. This model is often referred to as communicating sequential processes (CSP), a formal language introduced in the 1970s to describe patterns of interaction in concurrent systems. Go’s implementation of CSP enables safe data exchange and natural synchronization between goroutines.

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 ideal for tasks including I/O operations, CPU-bound computations, or anything that would benefit from running concurrently without blocking the main program flow.

Channels and communication

Goroutines often must communicate or coordinate their actions. This is where channels come in. Channels provide a safe way to send and receive values between goroutines without 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)

By design, sending and receiving operations on a channel block until the other side is ready ensures proper synchronization.

Buffered channels

Go also supports buffered channels, which enable storage of a limited number of values in the channel without blocking the sender immediately, as shown in the following code. This provides a way to control the flow of data and thus improve efficiency in certain scenarios, such as when the producer and consumer goroutines operate at different speeds.

ch := make(chan int, 3)
ch <- 1
ch <- 2
ch <- 3 

Reading channels

The simplest way to read data from a channel on a consumer goroutine is to use the for…range construct to read the channel, as the following code shows. This will read the data from the channel in order and block the goroutine until data is available. It will continue processing the channel until the sender closes 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 enables multiplexing, which lets a goroutine wait on multiple communication operations simultaneously. This is incredibly useful when handling multiple channels or performing nonblocking operations, as in the following code:

select {
case msg := <-ch1:
    fmt.Println("Received from ch1:", msg)
case msg := <-ch2:
    fmt.Println("Received from ch2:", msg)
default:
    fmt.Println("No communication")
}

With select, a developer can build responsive and efficient concurrent applications that gracefully handle varying workloads.

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

WaitGroup is commonly used to wait for a collection of goroutines to finish their work. The following code ensures the main program waits for both goroutines to complete before proceeding:

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 so:

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 is a very common pattern in parallel computation tasks such as map-reduce.

Conclusion

With first-class language support for goroutines and channels, Go removes much of the complexity traditionally associated with concurrent and parallel programming so developers can focus on designing application logic instead of threads, locks, and potential deadlocks and other minutia, which are headaches common in other languages.

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.

By admin