Asynchronous Programming In Go

When I transitioned from javascript to Golang, I started getting bored doing a lot of type assertion and type casting. So I asked myself, Why Go? The answer lies in Golang's asynchronous capabilities.

To achieve asynchronous programming, Golang provides a feature called Goroutines.

What is Goroutine?

A Goroutine is a function/method that executes independently and concurrently with other Goroutines running in the code. It is a lightweight thread managed by go runtime.

Advantages over operating-system threads

  • Goroutines are more lightweight and efficient. As a result, more Goroutines can be spawned by a program than threads.
  • They start and clean themselves faster than threads due to less system overhead.
  • Minimum stack space size is 2 kb.

Every Go program has a single Goroutine running at all times, and that Goroutine is the main Goroutine.

Let us take an example of a program calculating profit or loss. Here, we have taken the slice of the selling price and cost price of 5 items.

1. Synchronous execution

package main

import (
	"fmt"
)

func calculateIncome(sp int, cp int, income *[]int, itemNo int) {

	(*income)[itemNo] = sp - cp

	fmt.Println("Item no: ", itemNo, " Income: ", (*income)[itemNo])
}

func main() {

	sp := []int{100, 110, 120, 80, 200}
	cp := []int{50, 130, 150, 60, 110}

	income := make([]int, 5)

	for item := range sp {

		calculateIncome(sp[item], cp[item], &income, item)
	}

}

When you run the above code, you will get the following output.

Item no:  0  Income:  50
Item no:  1  Income:  -20
Item no:  2  Income:  -30
Item no:  3  Income:  20
Item no:  4  Income:  90

Here, the calculateIncome function is running synchronously. Ideally, we want to calculate it asynchronously as it is an independent task. So let's see how it's done using Goroutine.

2. Asynchronous execution

package main

import (
	"fmt"
	"time"
)

func calculateIncome(sp int, cp int, income *[]int, itemNo int) {

	(*income)[itemNo] = sp - cp

	fmt.Println("Item no: ", itemNo, " Profit/loss: ", (*income)[itemNo])
}

func main() {

	sp := []int{100, 110, 120, 80, 200}
	cp := []int{50, 130, 150, 60, 110}

	income := make([]int, 5)

	for item := range sp {

		go calculateIncome(sp[item], cp[item], &income, item)
	}
}

After execution, it prints nothing because the main Goroutine terminates before calculateIncome Goroutines complete their execution.

So we must block the execution of the main Goroutine until all the Goroutines complete their execution. We can achieve it by adding some sleep, but it's not a good solution. So let's see the efficient ways to achieve it.

1. Using WaitGroup

WaitGroup provided by sync package, waits for multiple Goroutines to finish. It has three main methods:

  1. Add(int) - It increases the WaitGroup counter by a given integer value.
  2. Wait() - Wait() waits until the WaitGroup count becomes zero.
  3. Done() - It decrements the WaitGroup counter by 1.

Let us use it in our example.

package main

import (
	"fmt"
	"sync"
)

func calculateIncome(sp int, cp int, income *[]int, itemNo int, wg *sync.WaitGroup) {
	defer (*wg).Done()

	(*income)[itemNo] = sp - cp
	fmt.Println("Item no: ", itemNo, " Profit/loss: ", (*income)[itemNo])
}

func main() {

	var wg sync.WaitGroup
	sp := []int{100, 110, 120, 80, 200}
	cp := []int{50, 130, 150, 60, 110}

	income := make([]int, 5)

	for item := range sp {

		wg.Add(1)
		go calculateIncome(sp[item], cp[item], &income, item, &wg)
	}
	wg.Wait()
}

Every time we start a new calculateIncome Goroutine, we increment the wait group count by 1 using wg.Add(1). The wg.Wait() method blocks the execution of the main until the count becomes zero.

In the calculateIncome, we have a deferred call to the (*wg).Done(), which decrements the wait group count by 1.

Wait Group is usually used for low-level designs. For better communication, we must use channels.

2. Using Channels

Channel is used to communicate between multiple Goroutines. It generally transfers signals from one Goroutine to another. We can make a channel of any type using the chan keyword followed by channel type.

Let us use it in our example.

package main

import (
	"fmt"
	"strconv"
)

func calculateIncome(sp int, cp int, c chan string, itemNo int) {

	c <- "Item no: " + strconv.Itoa(itemNo) + " Income: " + strconv.Itoa(sp-cp)

}

func main() {
	sp := []int{100, 110, 120, 80, 200}
	cp := []int{50, 130, 150, 60, 110}

	c := make(chan string)

	for item := range sp {
		go calculateIncome(sp[item], cp[item], c, item)
	}

	for range sp {
		fmt.Println(<-c)
	}

}

When you run the above code, you will get the following output.

Item no: 4 Income: 90
Item no: 0 Income: 50
Item no: 2 Income: -30
Item no: 1 Income: -20
Item no: 3 Income: 20

In the above code, we have made a channel c of type string and passed it as an argument to calculateIncome Goroutine. The calculateIncome Goroutine calculates the result and sends it on the channel. And in the main Goroutine, we will collect all the results which we will receive from the channel.

This is how by using Goroutines along with wait groups and channels, we can achieve asynchronous programming in Go.

Vaibhav Dighe

Vaibhav Dighe

Software Engineer at PLG Works
Pune, maharashtra