Golang channels and their behavior

While exploring golang multiple times, we came across the term ‘channels’. Also, in one of our recent blogs on asynchronous programming in Go, we showed the uses of channels to stimulate the async-await mechanism in golang. Still, we didn't explain channels and their behavior in detail. So let’s discuss them in this blog.

What are channels?

Channels are communication mediums between goroutines. Goroutines can send and receive data using channels. They are bidirectional by default means using the same channel goroutines can send and receive the data.

How to create a channel?

We can create a channel of any type using the make function and chan keyword followed by channel type.

intChannel:= make(chan int)

How to send and receive data using a channel?

Using the left arrow, we can perform the send and receive operation on channels.

Send operation

intChannel <- 1

Receive operation

intVar := <-intChannel

Behavior of channels

When I started exploring channels, my prime focus was on understanding their structure. I saw them as a queue that provides automatic synchronized access between goroutines. But that didn't help me a lot in understanding the concept. So I changed my focus from channel structure to its behavior.

Now, I think of channels as a signaling mechanism that allows goroutines to signal other goroutines for any particular event.

To better understand the channel signaling mechanism, let's understand its three main attributes.

  • Channels guarantee of delivery
  • Channels state
  • Signaling with or without data

Channels guarantee of delivery

Basically, there are two types of channels: unbuffered and buffered channels with different guarantees of delivery.

1. Unbuffered channel: When we create an unbuffered channel, it comes up with the guarantee that whatever data is sent will get received.

Syntax: make(chan type)

An unbuffered channel contains a single piece of data that needs to be consumed before pushing other data. That is why it blocks our main goroutine upon sending data.

2. Buffered channel: The buffered channel does not provide the guarantee that the sent data will be received.

Syntax: make(chan type, capacity)

It doesn't block the execution of the main goroutine until the capacity is fulfilled.

Let's see a simple program

func main() {
   unbufferedChannel := make(chan int)

   go func() {
       fmt.Println("Value : ", <-unbufferedChannel)
   }()

   unbufferedChannel <- 100
   unbufferedChannel <- 200

   time.Sleep(1 * time.Second)
}

The above code will throw an error because there is only a single receive operation done for two sent operations on the channel unbufferedChannel.

Channel unbufferedChannel is of unbuffered type, so it requires the guarantee that both the values sent (100,200) should be received by goroutine.
The error can be removed if we use bufferedChannel instead of unbufferedChannel as shown below

func main() {
   bufferedChannel := make(chan int, 2)

   go func() {
       fmt.Println("Value : ", <-bufferedChannel)
   }()

   bufferedChannel <- 100
   bufferedChannel <- 200

   time.Sleep(1 * time.Second)
}

Channels state

At any point in time, the state of the channel can be nil, open or closed. Let's see how we can declare or place the channel into the given states.

1. Nil channel:

var ch chan string
   // or
ch = nil

Both send and receive operations are not allowed on the nil channel.

2. Open channel:

ch := make(chan string)

Both send and receive operations are allowed on the open channel.

3. Closed channel:

close(ch)

Only receive operation is allowed on the closed channel.


Let’s take a simple example to understand the channel states

func main() {

   // nil channels
   var tasks chan int
   var isCompleted chan bool

   go func() {
       for {
           task, ok := <-tasks
           if ok {
               fmt.Println("Processing task ", task)
           } else {
               fmt.Println("All task's done")
               isCompleted <- true
               return
           }
       }
   }()

   for j := 1; j <= 5; j++ {
       tasks <- j
       fmt.Println("Sent task ", j)
   }
   close(tasks)
   fmt.Println("Sent all task")

   <-isCompleted
}

The above code gives an error because channels tasks & isCompleted are in a nil state so the sent and receive operations are not allowed.
Let's make tasks and isCompleted channels open.

func main() {

   // open channels
   tasks := make(chan int)
   isCompleted := make(chan bool)

   go func() {
       for {
           task, ok := <-tasks
           if ok {
               fmt.Println("Processing task ", task)
           } else {
               fmt.Println("All task's done")
               isCompleted <- true
               return
           }
       }
   }()

   for j := 1; j <= 5; j++ {
       tasks <- j
       fmt.Println("Sent task ", j)
   }
   close(tasks)
   fmt.Println("Sent all task")

   <-isCompleted
}

Output

Sent task  1
Sent task  2
Sent task  3
Sent all task
Processing task  1
Processing task  2
Processing task  3
All task's done

In the above program, we have closed the 'tasks' channel after sending all the tasks but still, the goroutine is able to perform receive operation on that channel.

Signaling with or without data


Signaling with data can be done by performing send operations on the channel.

ch <- "xyz"

It's usually done in the following cases:

  • When we want to start a new task in the goroutine
  • Goroutine has completed the task and wants to send the result back.

Signaling without data can be done by closing a channel.

close(ch)

It's usually done in the following cases:

  • To stop the goroutine from doing the task.
  • A goroutine reports back that they are done without any result
  • A goroutine reports back that they are done with the task and shutting down.

Understanding channels and their behavior will allow us to write better concurrent code precisely.

Vaibhav Dighe

Vaibhav Dighe

Software Engineer at PLG Works
Pune, maharashtra