Introduction
Navigating the realm of concurrency in programming can be like orchestrating a symphony of activities that occur simultaneously. Enter the world of Go, a programming language that gracefully handles concurrency through goroutines and channels. In this blog, we'll journey through a series of hands-on examples, each illustrating an essential lesson in harnessing the power of concurrency in Go.
Lesson 1: Starting with the Basics
func infiniteCount(thing string) {
for i := 1; true; i++ {
fmt.Println(i, thing)
time.Sleep(time.Second * 1)
}
}
// The program counts "dog" forever and never gets to "cat".
func main() {
infiniteCount("dog")
infiniteCount("cat")
}
Output
~ go run .
1 dog
2 dog
3 dog
4 dog
In our first lesson, we get acquainted with the concept of concurrency. The function infiniteCount
continuously prints out numbers along with a given text, providing us with a simple example of a recurring task. However, the call to infiniteCount("cat")
never occurs after starting with infiniteCount("dog")
, highlighting how concurrency can affect program execution.
Lesson 2: Introducing Goroutines
func infiniteCount(thing string) {
for i := 1; true; i++ {
fmt.Println(i, thing)
time.Sleep(time.Second * 1)
}
}
// Using goroutines: "dog" is counted in the background.
func main() {
go infiniteCount("dog")
infiniteCount("cat")
}
Output
~ go run .
1 cat
1 dog
2 dog
2 cat
3 cat
3 dog
In lesson two, we dive into goroutines, Go's concurrency units. By using the go
keyword before a function call, we're able to launch concurrent execution. We start counting "dog" concurrently and immediately proceed to counting "cat". This showcases the non-blocking nature of goroutines.
Lesson 3: The Challenge of Asynchrony
func infiniteCount(thing string) {
for i := 1; true; i++ {
fmt.Println(i, thing)
time.Sleep(time.Second * 1)
}
}
// Running both count functions as goroutines.
func main() {
go infiniteCount("dog")
go infiniteCount("cat")
}
Output
~ go run .
~
Expanding on the previous lesson, we explore the scenario where both infiniteCount("dog")
and infiniteCount("cat")
are run as goroutines. Surprisingly, the expected output doesn't appear. Why? Because the program's main function exits before the goroutines finish execution, leading to an incomplete run.
Lesson 4: Synchronization with WaitGroups
func count(thing string) {
for i := 1; i <= 5; i++ {
fmt.Println(i, thing)
time.Sleep(time.Millisecond * 500)
}
}
// Employing sync.WaitGroup to wait for goroutines.
func main() {
var wg sync.WaitGroup
wg.Add(1)
go func() {
count("dog")
wg.Done()
}()
wg.Wait()
}
Output
~ go run .
1 dog
2 dog
3 dog
4 dog
5 dog
~
To address the synchronization challenge posed in the previous lesson, we introduce the sync.WaitGroup
. This construct helps us ensure that all goroutines finish before the program terminates. With sync.WaitGroup
, we can synchronize goroutines' execution, waiting for their completion using wg.Wait()
.
Lesson 5: Communicating via Channels
func countWithChannel(thing string, c chan string) {
for i := 1; i <= 5; i++ {
c <- thing
time.Sleep(time.Millisecond * 500)
}
close(c)
}
// Leveraging channels for communication.
func main() {
c := make(chan string)
go countWithChannel("dog", c)
for msg := range c {
fmt.Println(msg)
}
}
Output
~ go run .
dog
dog
dog
dog
dog
~
Moving beyond isolated goroutines, we delve into channels, the communication mechanism between concurrent processes in Go. We modify the countWithChannel
function to send messages through channels. In lesson5, we create a channel, pass it to the function, and receive and print messages using a loop.
Lesson 6: Escaping Channel Deadlocks
func countWithChannel(thing string, c chan string) {
for i := 1; i <= 5; i++ {
c <- thing
time.Sleep(time.Millisecond * 500)
}
close(c)
}
// Experiencing channel deadlock.
func main() {
c := make(chan string)
c <- "hello world" // Causes deadlock
msg := <-c
fmt.Println(msg)
}
Output
~ go run .
fatal error: all goroutines are asleep - deadlock!
goroutine 1 [chan send]:
Here, we confront the issue of channel deadlocks. While we might expect sending and receiving a message to work, we're greeted with a deadlock. Why? The send operation is blocking until there's a receiver. As there's no active receiver, the program is stuck in a deadlock.
Lesson 7: Buffers for Unblocking
// Utilizing buffered channels to prevent deadlocks.
func main() {
c := make(chan string, 2)
c <- "hello"
c <- "world"
msg := <-c
fmt.Println(msg)
msg = <-c
fmt.Println(msg)
}
Output
~ go run .
hello
world
~
To circumvent deadlocks, we introduce buffered channels. A buffered channel allows sending values without blocking until the buffer is full. In lesson7, we create a buffered channel with a capacity of 2, enabling us to send two messages without encountering a deadlock.
Lesson 8: Selecting from Channels
// Employing select to choose from available channels.
func main() {
c1 := make(chan string)
c2 := make(chan string)
go func() {
for {
time.Sleep(time.Millisecond * 500)
c1 <- "Every 500ms"
}
}()
go func() {
for {
time.Sleep(time.Second * 2)
c2 <- "Every 2 seconds"
}
}()
for {
select {
case msg1 := <-c1:
fmt.Println(msg1)
case msg2 := <-c2:
fmt.Println(msg2)
}
}
}
Output
~ go run .
Every 500ms
Every 500ms
Every 500ms
Every 500ms
Every 2 seconds
Every 500ms
Every 500ms
The select
statement comes to the rescue in this lesson. We have two channels, c1
and c2
, each receiving messages at different intervals. By using select
, we can non-blockingly choose whichever channel is ready to send data, showcasing the versatility of this construct.
Lesson 9: Building Worker Pools
func worker(id int, jobs <-chan int, results chan<- int) {
for j := range jobs {
fmt.Println("worker", id, "started job", j)
time.Sleep(time.Second)
fmt.Println("worker", id, "finished job", j)
results <- j * 2
}
}
// Creating worker pools for distributed tasks.
func main() {
const numJobs = 5
jobs := make(chan int, numJobs)
results := make(chan int, numJobs)
for w := 1; w <= 3; w++ {
go worker(w, jobs, results)
}
for j := 1; j <= numJobs; j++ {
jobs <- j
}
close(jobs)
for a := 1; a <= numJobs; a++ {
<-results
}
}
Output
~ go run .
worker 3 started job 1
worker 2 started job 3
worker 1 started job 2
worker 1 finished job 2
worker 2 finished job 3
worker 3 finished job 1
worker 1 started job 4
worker 2 started job 5
worker 2 finished job 5
worker 1 finished job 4
~
Our final lesson delves into worker pools, a vital concept in concurrent programming. We define a worker
function that processes jobs from a channel and sends results to another channel. In lesson9, we create a worker pool of three goroutines, distribute tasks, and collect results, effectively managing concurrent execution.
You can find the code examples used in this blog on my GitHub repository. Feel free to explore, experiment, and contribute!
Conclusion 🥂
As you wrap up this exploration, you're now armed with the prowess of concurrency through Go's goroutines and channels. Whether it's juggling diverse tasks, seamlessly exchanging information, or optimizing work sharing, you've delved into the heart of concurrent programming. This newfound ability empowers you to enhance your code's performance, responsiveness, and efficiency.
Top comments (2)
Good practical examples and post! Thanks 🙏
Your method of explaining is great!
Some comments may only be visible to logged-in visitors. Sign in to view all comments.