Tricky Golang interview questions - Part 4: Concurrent Consumption

crusty0gphr

Harutyun Mardirossian

Posted on June 24, 2024

Tricky Golang interview questions - Part 4: Concurrent Consumption

I want to discuss an example that is very interesting. I was surprised that many experienced developers were unable to answer it correctly. This example involves buffered channels and concurrency.

Question: What will happen when we run this code?

package main

import "fmt"

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

    go func() {
        ch <- 1
        ch <- 2
        ch <- 3
        ch <- 4
        ch <- 5
        close(ch)
    }()

    for num := range ch {
        fmt.Println(num)
    }
}
Enter fullscreen mode Exit fullscreen mode

Let's analyse this piece of code. What we have here:

  • a buffered channel ch of type int with a capacity of 4 is created
  • a goroutine that sends values
  • a goroutine sends five values to the channel
  • the first 4 sends will fill the channel
  • the fifth send operation will be blocked because the channel is at capacity

This idea seems straightforward, and you give the interviewer a confident answer: Since the buffer capacity is only 4, the fifth send operation will block, causing a deadlock. Channels in go block when they are full and more values are being sent.

fatal error: all goroutines are asleep - deadlock!
Enter fullscreen mode Exit fullscreen mode

At this point, the interviewer will kindly ask you to run the code and check the results.

[Running] go run "main.go"

1
2
3
4
5

[Done] exited with code=0 in 0.318 seconds
Enter fullscreen mode Exit fullscreen mode

And surprise surprise we see that this code actually works without deadlocks. Many interviewees, even those with significant experience, struggle to explain this unusual behaviour. There must be a deadlock in this code!

This raises another question: What can you change to create a deadlock in this code? It's quite simple: just remove the anonymous goroutine and send integers into the channel directly within the main function.

package main

import "fmt"

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

    ch <- 1
    ch <- 2
    ch <- 3
    ch <- 4
    ch <- 5 // deadlock at line:12
    close(ch)

    for num := range ch {
        fmt.Println(num)
    }
}

Enter fullscreen mode Exit fullscreen mode

Now, we've created a deadlock:

[Running] go run "main.go"

fatal error: all goroutines are asleep - deadlock!

goroutine 1 [chan send]:
main.main()
        main.go:12 +0x78

[Done] exited with code=0 in 0.318 seconds
Enter fullscreen mode Exit fullscreen mode

Let me explain why this is happening. Why does introducing a simple anonymous goroutine fix the problem?

Let's first define how concurrency works in go and what are channels:

  • Concurrency in Go is built around goroutines and channels, which provide a simple and powerful model for concurrent programming.
  • A goroutine is a lightweight thread managed by the Go runtime. Goroutines are created using the go keyword followed by a function call.
  • Channels provide a way for goroutines to communicate with each other and synchronize their execution. Channels can be used to send and receive values between goroutines.

Channels can be buffered or unbuffered:

  • Unbuffered channels have a capacity of 0. When a sender sends a value on an unbuffered channel, it will block until a receiver is ready to receive the value.
  • Buffered channels have a capacity greater than 0. When a sender sends a value on a buffered channel, it will only block if the buffer is full.

Now that we understand how concurrency works in Go, let’s explore an important concept related to channels.

Concurrent Consumption

The main idea behind concurrent consumption is to ensure that values sent to a channel are being read (or consumed) while they are being sent. This prevents the channel from getting full and blocking further send operations. Concurrent consumption in Go involves having one or more goroutines that send data to a channel while one or more other goroutines read from the same channel. This pattern is commonly used to handle situations where production (sending data) and consumption (receiving data) happen at different rates.

To ensure a smooth concurrent flow in this scenario, we need to clearly define producers and consumers that run concurrently. In go, the main function starts executing immediately upon the program's start, in its own goroutine.

  1. In the example with a deadlock, there's a single goroutine that acts as both the producer and consumer. This means that within the same goroutine, values are being sent into a channel and simultaneously received from it. However, the deadlock occurs because the channel is filled with values before all it gets consumed. This results in a situation where the channel becomes blocked, preventing further operations.
  2. In the first example the the producer and consumer are separated, they run in different goroutines. By using an anonymous goroutine to send values, the main function is free to read from the channel concurrently. This prevents the channel from getting full and blocking further sends, avoiding a deadlock situation.

So the correct answer is:
The program will output all values that were sent into the buffered channel.
Why?
Because we have 2 goroutines one to produce data (anonymous) and another to consume (main). This way we ensure a flawless execution that empties the channel on time.

It's that easy!

💖 💪 🙅 🚩
crusty0gphr
Harutyun Mardirossian

Posted on June 24, 2024

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related