DEV Community

Kittipat.po
Kittipat.po

Posted on • Edited on

Concurrency in Go: A Practical Guide with Hands-On Examples

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")
}
Enter fullscreen mode Exit fullscreen mode

Output

~ go run .
1 dog
2 dog
3 dog
4 dog
Enter fullscreen mode Exit fullscreen mode

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")
}
Enter fullscreen mode Exit fullscreen mode

Output

~ go run .
1 cat
1 dog
2 dog
2 cat
3 cat
3 dog
Enter fullscreen mode Exit fullscreen mode

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")
}
Enter fullscreen mode Exit fullscreen mode

Output

~ go run .
~
Enter fullscreen mode Exit fullscreen mode

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()
}
Enter fullscreen mode Exit fullscreen mode

Output

~ go run .
1 dog
2 dog
3 dog
4 dog
5 dog
~
Enter fullscreen mode Exit fullscreen mode

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)
    }
}
Enter fullscreen mode Exit fullscreen mode

Output

~ go run .
dog
dog
dog
dog
dog
~
Enter fullscreen mode Exit fullscreen mode

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)
}
Enter fullscreen mode Exit fullscreen mode

Output

~ go run .
fatal error: all goroutines are asleep - deadlock!

goroutine 1 [chan send]:
Enter fullscreen mode Exit fullscreen mode

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)
}
Enter fullscreen mode Exit fullscreen mode

Output

~ go run .
hello
world
~
Enter fullscreen mode Exit fullscreen mode

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)
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

Output

~ go run .
Every 500ms
Every 500ms
Every 500ms
Every 500ms
Every 2 seconds
Every 500ms
Every 500ms
Enter fullscreen mode Exit fullscreen mode

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
    }
}
Enter fullscreen mode Exit fullscreen mode

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
~
Enter fullscreen mode Exit fullscreen mode

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)

Collapse
 
psysolix profile image
Stefano P

Good practical examples and post! Thanks 🙏

Collapse
 
rogudator profile image
Danil Zotin

Your method of explaining is great!

Some comments may only be visible to logged-in visitors. Sign in to view all comments.