This problem is more related to code review. It requires knowledge about channels and select cases, also blocking, making it one of the most difficult interview questions I faced in my career. In these kinds of questions, the context is unclear at first glance and requires a deep understanding of blocking and deadlocks. While previous articles mostly targeted advanced junior or entry-level middle topics, this one is a more senior-level problem.
Question: One of your teammates submitted this code for a code review. This code has a potential threat. Identify it and give a solution to solve it.
package main
import (
"fmt"
"time"
)
func main() {
ch := make(chan int)
go func() {
time.Sleep(2 * time.Second)
ch <- 42
fmt.Println("Sent: 42")
}()
val := <-ch
fmt.Println("Received:", val)
fmt.Println("Continuing execution...")
}
At first glance nothing suspicious in this code. If we try to run it it will actually compile and run without any noticeable problem.
[Running] go run "main.go"
Sent: 42
Received: 42
Continuing execution...
[Done] exited with code=0 in 2.124 seconds
The code itself also seems fine. We have concurrent consumption implemented correctly with 2 goroutines working independently. Let's break down the code and see what's happening:
- A channel
ch
is created withmake(chan int)
. This is an unbuffered channel. - A goroutine is started that sleeps for 2 seconds and then sends the value 42 to the channel.
- The main function performs a read operation on ch with
val := <-ch
.
Again seems fine. But what we actually have here is, that the send operation is delayed. The anonymous goroutine waits for 2 seconds before sending the value into the channel. So when we run this code the main function starts reading the channel and expects a value in there before the channel gets populated with a value. This operation blocks the further execution of the code.
Read operations on empty channels
In Go, when you try to read from an empty channel, the read operation blocks until a value becomes available. This means that the goroutine performing the read will be paused and will not proceed with further operations until it can successfully read a value from the channel.
When the code performs a read operation on a channel:
- Unbuffered Channel: If the channel is unbuffered and no value is available, the read operation will block until another goroutine sends a value to the channel.
- Buffered Channel: If the channel is buffered, the read operation will block if the buffer is empty.
The delay of 2 seconds won't be that noticeable in this case and the one who observes the execution won't even notice the gap, but from the runtime perspective, the whole execution flow was stalled for 2 seconds. Until the value 42 is sent after 2 seconds, the main goroutine is blocked on val := <-ch
. A blocking read halts all subsequent code execution until the read operation completes. This can lead to a program that appears to be frozen if there is no other goroutine sending data to the channel. If more operations are supposed to follow, they are delayed.
In the real-world scenarios, for example, we have created a mini-Youtube application. One of the heaviest components for Youtube is the video encoder, which, for example, is represented as a pool of worker services.
The process of video encoding can take anywhere from a few minutes to several hours. Imagine our main function sends a 24-hour long video to the encoder, which might take 3-4 hours to process. Everything written after the channel read line will be blocked for hours. Consequently, your backend will be unable to perform any other tasks until the video encoding is complete. If you increase the sleep timer to 20 seconds time.Sleep(20 * time.Second)
you will notice how long it takes until the last print statement appears in the output log.
Consequences of Blocking
- As we already discussed, a blocking read halts all subsequent code execution until the read operation completes. This can lead to a program that appears to be frozen if there is no other goroutine sending data to the channel.
- May cause serious concurrency issues. If the main goroutine (or any critical goroutine) blocks indefinitely waiting for data, it can prevent other important tasks from executing, leading to deadlocks or unresponsive behaviour.
- Resource utilization problems. While blocked, the goroutine does not consume CPU resources actively, but it ties up logical resources like goroutine stacks and potentially other dependent tasks.
Non-blocking Alternatives
To avoid blocking reads, you can use non-blocking alternatives like the select statement with a default case. The select statement in Go is a powerful feature that allows a goroutine to wait on multiple communication operations, making it possible to perform non-blocking operations and handle multiple channels. The select statement works by evaluating multiple channel operations and proceeding with the first one that is ready. If multiple operations are ready, one of them is chosen at random. If no operations are ready, the default case, if present, is executed, making it a non-blocking operation.
The basic syntax of the select statement:
select {
case <-ch1:
// Do something when ch1 is ready for receiving
case ch2 <- value:
// Do something when ch2 is ready for sending
default:
// Do something when no channels are ready (non-blocking path)
}
Code review
As a code reviewer, you must be able to identify this potentially dangerous code, provide a good explanation of how to avoid it and encourage the teammate to fix the problem. To fix the problem let's implement a select
statement. The fix will look like the following:
package main
import (
"fmt"
"time"
)
func main() {
ch := make(chan int)
// Goroutine to send data to the channel after 2 seconds
go func() {
time.Sleep(2 * time.Second)
ch <- 42
fmt.Println("Sent: 42")
}()
// Main function performing a non-blocking read
for {
select {
case val := <-ch:
fmt.Println("Received:", val)
fmt.Println("Continuing execution...")
return
default:
fmt.Println("No value received")
time.Sleep(500 * time.Millisecond) // Sleep for a while to prevent busy looping
// handle the execution flow of instructions and operations that must continue
}
}
}
Now if we run this, we'll see the following behaviour:
[Running] go run "main.go"
No value received
No value received
No value received
No value received
Received: 42
Continuing execution...
[Done] exited with code=0 in 2.31 seconds
The main
function will repeatedly print “No data received” during the times when the channel is empty, interspersed with “Received: 42” as values become available. The default case ensures the main function does not block and can perform other operations (like printing “No data received” and sleeping). This mechanism ensures that the main function remains responsive, even if one or both channels do not have data available.
It's that easy!
Top comments (3)
I loved the article, but I think the initial example is a little confusing. I knew that the read would block for 2 seconds, but didn't think that was a problem because the code was so simple. Maybe it would be better to have a bit more preamble around the example to make it clear that we care about UX in this instance: "what would make for a better user experience with this code?" Or perhaps move it into a stand-alone function so the interviewee has to think about how the function would impact the rest of the code base (a different form of UX). As-is, I think it's more tricky due to not understanding the requirements than not knowing about the blocking.
Either way, I learned something about using the select statement! And the article as a whole was well-written. Thank you for sharing!
I had the same thought. I was confused reading that, because it asked what the problem was, and I didn't see any problem. It was supposed to block until a value was received. That's how it's supposed to work. I was trying to figure out if there was some race condition or something that could panic, or something like that, but there's nothing like that in there.
Yea, the example is confusing, but I guess it's meant to be confusing. This isn't an example that I created. This code is directly from my interview task, the only difference is that the question was asked in Russian. I did my best to translate it in a way so it won't lose the context. I think the interviewer deliberately blurred the context of the question to hear more from me while trying to figure out what's wrong here.
Thank you for sharing your idea, this is very helpful, and I appreciate it!