Go-ing Concurrent: The Power of Goroutines and Channels

Gaurav Kapatia
Newton School
Published in
5 min readJul 21, 2024

Concurrency is a fundamental feature of Go, setting it apart from many other programming languages. In this blog, we’ll explore how Go handles concurrency, focusing on its two primary constructs: Goroutines and Channels. By the end of this article, you’ll have a solid understanding of how to leverage these features to build efficient, concurrent applications in Go.

What is Concurrency?

Concurrency is the ability of a program to make progress on multiple tasks simultaneously. It is particularly useful in applications that perform many tasks that can operate independently, such as web servers, real-time systems, and more.

Concurrency and parallelism are often used interchangeably, but they represent distinct concepts in computing. Concurrency refers to the ability of a system to handle multiple tasks at the same time, but not necessarily simultaneously. It involves structuring a program to be responsive and efficient by overlapping the execution of tasks, so while one task waits (e.g., for I/O operations), another can proceed.

On the other hand, parallelism involves executing multiple tasks simultaneously, leveraging multiple processors or cores to perform computations in parallel. While concurrency is about managing a lot of tasks at once and improving throughput, parallelism is about speeding up the execution of tasks by performing them simultaneously. Go excels in concurrency with its lightweight Goroutines and channels, making it ideal for writing responsive, high-throughput applications.

Introduction to Goroutines

What are Goroutines?

Goroutines are lightweight threads managed by the Go runtime. Unlike traditional threads, Goroutines are extremely cheap in terms of memory and CPU usage, allowing you to create thousands of them without significant overhead.

How to Create a Goroutine

Creating a Goroutine is simple. You use the go keyword followed by a function call. This starts a new Goroutine that runs concurrently with the calling Goroutine.

package main

import (
"fmt"
"time"
)

func main() {
go sayHello()
time.Sleep(1 * time.Second) // Allow time for the Goroutine to run
}

func sayHello() {
fmt.Println("Hello, World!")
}

In the example above, the sayHello function runs concurrently with the main function.

You may have noticed this comment — “Allow time for the Goroutine to run” — in our earlier examples. This highlights a crucial aspect of Go’s concurrency model: once the main function exits, all running Goroutines are abruptly terminated. Go does not wait for Goroutines to complete their execution when the main function finishes, which can lead to incomplete tasks and unexpected behaviour. To ensure that Goroutines complete their work, you must use synchronisation techniques such as sync.WaitGroup or channels to coordinate the lifecycle of Goroutines with the main function.

Example: Concurrent Tasks

Let’s see a more practical example where multiple tasks run concurrently:

package main

import (
"fmt"
"time"
)

func main() {
for i := 0; i < 5; i++ {
go func(n int) {
fmt.Printf("Goroutine %d is running\n", n)
time.Sleep(2 * time.Second)
fmt.Printf("Goroutine %d is done\n", n)
}(i)
}

time.Sleep(3 * time.Second) // Wait for all Goroutines to complete
}

Understanding Channels

Channels provide a way for Goroutines to communicate with each other and synchronise their execution. They allow you to send and receive values between Goroutines in a thread-safe manner.

Creating and Using Channels

You create a channel using the make function:

ch := make(chan int)

You can send and receive values using the <- operator:

ch <- 42 // Send value to the channel
value := <-ch // Receive value from the channel

Example: Basic Channel Communication

Here’s a simple example of using channels to communicate between Goroutine and main:

package main

import (
"fmt"
)

func main() {
ch := make(chan string)

go func() {
ch <- "Hello from Goroutine!"
}()

message := <-ch
fmt.Println(message)
}

And here’s a simple example of using channels to communicate between Goroutines:

package main

import (
"fmt"
"time"
)

func main() {
ch := make(chan string) // Created a new channel

go sender(ch) // Passed that channel to sender
go receiver(ch) // Passed that channel to receiver

time.Sleep(1 * time.Second) // Allow some time for the goroutines to finish
}

func sender(ch chan string) {
ch <- "Hello from sender Goroutine!"
}

func receiver(ch chan string) {
message := <-ch
fmt.Println(message)
}

Buffered Channels

Buffered channels can hold a fixed number of values without a corresponding receiver. This allows you to decouple the timing of sends and receives.

package main

import (
"fmt"
)

func main() {
ch := make(chan int, 2)

ch <- 1
ch <- 2

fmt.Println(<-ch)
fmt.Println(<-ch)
}

Select Statement

The select statement allows a Goroutine to wait on multiple communication operations. It is similar to a switch statement but for channels.

package main

import (
"fmt"
"time"
)

func main() {
ch1 := make(chan string)
ch2 := make(chan string)

go func() {
time.Sleep(1 * time.Second)
ch1 <- "Message from ch1"
}()

go func() {
time.Sleep(2 * time.Second)
ch2 <- "Message from ch2"
}()

for i := 0; i < 2; i++ {
select {
case msg1 := <-ch1:
fmt.Println(msg1)
case msg2 := <-ch2:
fmt.Println(msg2)
}
}
}

Real-World Example: Worker Pool

A common concurrency pattern is the worker pool, where a number of worker Goroutines process tasks from a shared channel.

package main

import (
"fmt"
"sync"
)

func worker(id int, jobs <-chan int, results chan<- int, wg *sync.WaitGroup) {
defer wg.Done()
for j := range jobs {
fmt.Printf("Worker %d processing job %d\n", id, j)
results <- j * 2
}
}

func main() {
const numJobs = 5
jobs := make(chan int, numJobs)
results := make(chan int, numJobs)

var wg sync.WaitGroup
for w := 1; w <= 3; w++ {
wg.Add(1)
go worker(w, jobs, results, &wg)
}

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

wg.Wait()
close(results)

for result := range results {
fmt.Println(result)
}
}

In this example, three worker Goroutines process five jobs. The main function distributes jobs to the workers via the jobs channel and collects results from the results channel.

Goroutines and Channels are powerful tools that make concurrent programming in Go both efficient and straightforward. By understanding and utilising these constructs, you can build highly performant applications that can handle numerous concurrent tasks with ease. Whether you’re building a web server, a real-time system, or any application that requires concurrency, Go’s concurrency model provides the tools you need to succeed.

--

--

Gaurav Kapatia
Newton School

Tech Lead at Newton School. Python, Django, Kubernetes entusiast. Leading tech at a $100M+ ed-tech startup. Passionate about innovation and scalable solutions.