Concurrency in Go with Goroutines and Channel

golang
Reading Time: 5 minutes

Why Concurrency?

Concurrency is an ability of a program to do multiple things at the same time. Concurrency is very important in modern software, due to the need to execute independent pieces of code as fast as possible without disturbing the overall flow of the program.

Concurrency in Golang is the ability for functions to run independently of each other. Parallelism is a run-time property where two or more tasks are being executed simultaneously. Through concurrency, you want to define a proper structure for your program. Concurrency can use parallelism for getting its job done but remember parallelism is not the ultimate goal of concurrency. Go has rich support for concurrency using goroutines and channels.

What are Goroutines and Channels?

A goroutine is a function that runs independently of the function that started it. Sometimes Go developers explain a goroutine as a function that runs as if it were on its own thread.

Channel is a pipeline for sending and receiving data. Think of it as a socket that runs inside your program. Channels provide a way for one goroutine to send structured data to another.

Working with Goroutines

When it comes to syntax, a goroutine is any function that’s called after the special keyword go. Almost any function could be called as a goroutine.The frequent uses of goroutines is to run a function “in the background” while the main part of your program goes on to do something else. 

Example:

Let’s look at an example. We define a function foo that prints numbers from 1 to 3 along with the passed string. We add a delay using time.Sleep() inside the for a loop. Without the delay, the first goroutine will finish executing even before the second one starts. The delay ensures that the goroutines are running concurrently before the results are shown.

package main
import (
    "fmt"
    "time"
)

// Prints numbers from 1-3 along with the passed string
func foo(s string) {
    for i := 1; i <= 3; i++ {
        time.Sleep(100 * time.Millisecond)
        fmt.Println(s, ": ", i)
    }
}

func main() {
    
   fmt.Println("Main started...")

    // Starting two goroutines
    go foo("1st goroutine")
    go foo("2nd goroutine")

    // Wait for goroutines to finish before main goroutine ends
    time.Sleep(time.Second)
    fmt.Println("Main finished")
}

Output:

1st goroutine :  1
2nd goroutine :  1
2nd goroutine :  2
1st goroutine :  2
2nd goroutine :  3
1st goroutine :  3
Main goroutine finished

This way of using sleep in the main Goroutine to wait for other Goroutines to finish their execution is a hack we are using to understand how Goroutines work. Channels can be used to block the main Goroutine until all other Goroutines finish their execution.

Working with Channels?

Channels provide a way to send messages from one goroutine to another. Go channels work like sockets between goroutines within a single application. Like network sockets, they can be unidirectional or bidirectional. Channels can be short-lived or long-lived.

We can declare a new channel type by using the chan keyword along with a datatype:

var c chan int

Here, c is of type chan int – which means it’s a channel through which int types are sent. The default value of a channel is nil, so we need to assign a value.

Let’s write some code that declares a channel.

package main

import "fmt"

func main() {  
    var c chan int
    if c == nil {
        fmt.Println("channel c is nil, going to define it")
        c = make(chan int)
        fmt.Printf("Type of c is %T", c)
    }
}

Output:

channel c is nil, going to define it
Type of c is chan int

Sending and receiving from a channel

The syntax to send and receive data from a channel is as below:

data := <- a // read from channel a  
a <- data // write to channel a  

The direction of the arrow with respect to the channel specifies whether the data is sent or received.

In the first line, the arrow points outwards from a and hence we are reading from channel a and storing the value to the variable data.

In the second line, the arrow points towards a and hence we are writing to channel a.

package main
import "fmt"
func sendValues(myIntChannel chan int){

  for i:=0; i<5; i++ {
    myIntChannel <- i 
  }

}

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

  go sendValues(myIntChannel) // function sending value

  for i:=0; i<5; i++ {
    fmt.Println(<-myIntChannel) //receiving value
  }
}

Output:

1
2
3
4

In a goroutine, the function sendValues was sending values over myIntChannel by using a for-loop. On the other hand, myIntChannel was receiving values and the program was printing them onto the console. The most important point to note is that both the following statements were blocking operations myIntChannel <- i and <-myIntChannel.

Hence, the program when blocked on myIntChannel <- i was unblocked by the <-myIntChannel statement. This was only possible as they were running concurrently.

This means:

  • When we send data into the channel using a GoRoutine, it will be blocked until the data is consumed by another GoRoutine.
  • When we receive data from channel using a GoRoutine, it will be blocked until the data is available in the channel.

Let’s get more clarity by modifying the above code a little bit:

package main
import "fmt"
func sendValues(myIntChannel chan int){

  for i:=0; i<5; i++ {
    myIntChannel <- i //sending value 
  }

}

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

  go sendValues(myIntChannel)

  for i:=0; i<6; i++ {
    fmt.Println(<-myIntChannel) //receiving value
  }
}

Ouput:

0
1
2
3
4

fatal error: all goroutines are asleep - deadlock!

goroutine 1 [chan receive]:
main.main()
	/usercode/main.go:17 +0xa0
exit status 2

Finally, we reached a deadlock. So I just changed the for loop condition in the main loop from i < 5 to i < 6. As a result, the main routine is blocked on <-myIntChannel because the sending operation has sent only 5 values which were received by the 5 iterations of the loop. However, for the 6th iteration, there is no sending operation that will send value on the channel. Therefore, the program is blocked on the receiving operation resulting in a deadlock.

Closing a Channel

One way to fix the deadlock problem is by closing the channel. Closing a channel means that you can no longer communicate on it. Note that it only makes sense for a sender, not a receiver, to close a channel because the receiver does not know if it has received everything or not. Now let’s try closing the channel:

package main
import "fmt"
func sendValues(myIntChannel chan int){

  for i:=0; i<5; i++ {
    myIntChannel <- i //sending value 
  }
  close(myIntChannel)
}

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

  go sendValues(myIntChannel)

  for i:=0; i<6; i++ {
    fmt.Println(<-myIntChannel) //receiving value
  }
}

Output:

0
1
2
3
4
0

You can see that when we close the channel after all our send operations, the receive operation returns 0 without blocking on the 6th iteration.

Conclusion

You just saw, how concurrency in a Golang application can be achieved using goroutines and channels. The Go programming language makes this incredibly simple in comparison to other development technologies because it’s fast, you can easily spin up hundreds of thousands of goroutines, and you don’t have to have messy callback code or worry about locking.

Concurrency isn’t parallelism. Concurrency is when two or more tasks start, run, and end within the same period of time and these tasks can potentially interact with each other. The tasks are considered to be concurrent to each other, as opposed to being sequential.

knoldus

Written by 

I am an DevOps engineer having experience working with the DevOps tool and technologies like Kubernetes, Docker, Ansible, AWS cloud, prometheus, grafana etc. Flexible towards new technologies and always willing to update skills and knowledge to increase productivity.