Tricky Golang interview questions - Part 6: NonBlocking Read

Harutyun Mardirossian - Jul 10 - - Dev Community

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...")
}


Enter fullscreen mode Exit fullscreen mode

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


Enter fullscreen mode Exit fullscreen mode

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 with make(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.

mini-youtube-abstarct-diagram

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)
}


Enter fullscreen mode Exit fullscreen mode

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
        }
    }
}


Enter fullscreen mode Exit fullscreen mode

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


Enter fullscreen mode Exit fullscreen mode

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!

. . . . . . . . . . . . . . .