Golang WaitGroup Complete Tutorial [Beginners Guide]

Overview on Golang WaitGroup?

WaitGroup in Go is a functionality that makes concurrency easy to use. An example of using concurrency in a Go application is when you have multiple concurrent goroutines running and each one of them takes some time to return a response. The expected behavior would be that the application will terminate when all the goroutines have returned a response. But that is not the case and that is where Go WaitGroups comes in.Go Waitgroup forces the main goroutine to wait for all running goroutines to finish executing, hence the name wait groups. Waiting for a group of goroutines to finish executing before the main goroutine can continue.

WaitGroup comes from the sync package in Go standard library and it ships with 3 methods namely:

Advertisement
  1. Add(int): Indicates the number of goroutines to wait for. The Add() function takes an integer as a parameter and this integer acts as a counter.
  2.  Wait(): Blocks a block of code, until the internal counter reduces to zero.
  3. Done(): Decreases the counter(integer passed to the Add() function) by one after a goroutine is executed.

The prerequisite for learning about Go WaitGroups in this article are:

  1. Go runtime installed
  2. Knowledge of Go basics features like for-loop, functions and goroutines.

 

Concurrency without Go WaitGroups

In this example, we will spin concurrent goroutines. Create a main.go file in your working directory and get your hands dirty.

Example

package main
 
import (
   "fmt"
   "time"
)
 
func routine(number int) {
   fmt.Printf("Starting routine %d", number)
   time.Sleep(time.Second)
   fmt.Printf("Done with routine %d", number)
}
 
func main() {
   fmt.Println("Starting main goroutine")
   go routine(1)
   go routine(2)
   go routine(3)
   go routine(4)
   go routine(5)
   fmt.Println("Finishing main goroutine")
}

Explanation

In the above example, we define a function called routine() that prints when it starts executing and when it is done executing. We also make this function to sleep for a second to simulate an expensive operation.

In the main function , we also print when it starts executing and when it is done executing. The main function before printing the last statement, it spins 5 goroutines.

Advertisement

When this code is run , the results on the terminal show the print statements in the main function and not those defined in the routine() function. What happens is that the main goroutine(main function) calls the goroutines and does not wait for them to return results and continues on to the last print statement.

Output

$ go run main.go
Starting main goroutine
Finishing main goroutine

 

Concurrency with Go WaitGroups

In this section we will add Go WaitGroup to ensure that all goroutines execute before the main goroutine terminates.

Example

package main
 
import (
   "fmt"
   "sync"
   "time"
)
 
func routine(waitgroup *sync.WaitGroup, number int) {
   defer waitgroup.Done()
   fmt.Printf("Starting routine %d \n", number)
   time.Sleep(time.Second)
   fmt.Printf("Done with routine %d \n", number)
}
 
func main() {
   fmt.Println("Starting main goroutine")
   var waitgroup sync.WaitGroup
 
   waitgroup.Add(5)
   for i := 0; i < 5; i++ {
       go routine(&waitgroup, i)
   }
   waitgroup.Wait()
   fmt.Println("Finishing main goroutine")
}

Explanation

In the preceding example, we define a goroutine called routine() that takes two arguments. One is the pointer to sync.WaitGroup and the other argument is a number of type int. Please note that WaitGroup passed to a function, is passed as a pointer. The routine function calls defer waitgroup.Done(). This ensures that the internal counter reduces by one when the goroutine is executed.

Again , this goroutine sleeps for a second to simulate an expensive operation.

In the main function, we declare a variable waitgroup of type sync.WaitGroup. We then add a counter of value 5 to indicate that we are spinning 5 goroutines. Please note that if the counter does not match the number of goroutines, the code will panic.

Using a for loop , we spin 5 goroutines and pass a pointer to the waitgroup and an integer. This time round, the main thread will be forced to wait for the goroutines to finish executing using the waitgroup.Wait() method.

Output

$ go run main.go
Starting main goroutine
Starting routine 4
Starting routine 2
Starting routine 3
Starting routine 0
Starting routine 1
Done with routine 1
Done with routine 0
Done with routine 3
Done with routine 4
Done with routine 2
Finishing main goroutine

 

Concurrency with Go WaitGroups using anonymous Goroutines

The above examples can be accomplished using anonymous goroutines as shown below.

Example

Advertisement
package main
 
import (
   "fmt"
   "sync"
   "time"
)
 
func main() {
   fmt.Println("Starting main goroutine")
   var waitgroup sync.WaitGroup
 
   waitgroup.Add(5)
   for i := 0; i < 5; i++ {
       go func(waitgroup *sync.WaitGroup, number int) {
           defer waitgroup.Done()
           fmt.Printf("Starting routine %d \n", number)
           time.Sleep(time.Second)
           fmt.Printf("Done with routine %d \n", number)
       }(&waitgroup, i)
   }
   waitgroup.Wait()
   fmt.Println("Finishing main goroutine")
}

Output

$ go run main.go
Starting main goroutine
Starting routine 4
Starting routine 0
Starting routine 1
Starting routine 2
Starting routine 3
Done with routine 0
Done with routine 4
Done with routine 1
Done with routine 3
Done with routine 2
Finishing main goroutine

 

WaitGroup Error Handling

While working with Go WaitGroups is easy and awesome, it might get messy when the program encounters an error and paincs. Go provides errgroup that wraps sync.Waitgroup to catch errors out of the goroutines. The errgroup package provides synchronization, error propagation and Context cancelation for groups of goroutines working on subtasks. In this next example we will loop through integers from 0 to 10 and return an error if an integer is not divisible by 2.

Example

package main
 
import (
   "fmt"
   "log"
 
   "golang.org/x/sync/errgroup"
)
 
func Task(number int) error {
   if number%2 != 0 {
       return fmt.Errorf(" %v not divisible by two", number)
   }
   fmt.Printf("%v is divisible by 2 \n", number)
   return nil
}
func main() {
   eg := &errgroup.Group{}
   for i := 0; i < 20; i++ {
       number := i
       eg.Go(func() error {
           return Task(number)
       })
   }
   if err := eg.Wait(); err != nil {
       log.Fatal("[ Error ]", err)
   }
   fmt.Println("Divisibility test complete!")
}

Explanation

In the above example we import the errgroup package to track errors in our program.  Next we create a Task() function that handles errors returned by goroutines. In the main function, we create a variable(eg) of type errgroup. We then loop from 0 to 20. Now instead of adding every goroutine to the group, we call go.Go with the function to be a goroutine. This function must have the signature func() error . Please note that errgroup will handle the goroutine until it is executed, hence no need for calling wg.Done().

We then wait for the goroutines to finish executing using g.Wait() which is equivalent to waitgroup.Wait().

Advertisement

 

Concurrency with WaitGroup in real application

More often than not, you will be required to consume data from API endpoints asynchronously. In this section we will simulate consuming API using goroutines and WaitGroups.

Example

package main
 
import (
   "fmt"
   "net/http"
   "sync"
)
 
func fetch(waitgroup *sync.WaitGroup, URL string) {
   defer waitgroup.Done()
   resp, err := http.Get(URL)
   if err != nil {
       fmt.Printf("Error: %s", err)
   }
   fmt.Printf("[ %s ] -> %d \n", URL, resp.StatusCode)
}
 
func main() {
   fmt.Println("Start consuming API endpoints")
   var waitgroup sync.WaitGroup
 
   URLs := []string{
       "https://www.golinuxcloud.com",
       "https://www.google.com",
       "https://go.dev",
       "https://aws.amazon.com",
       "https://www.microsoft.com",
   }
 
   waitgroup.Add(len(URLs))
   for _, URL := range URLs {
       go fetch(&waitgroup, URL)
   }
   waitgroup.Wait()
 
   fmt.Println("Done consuming API endpoints")
 
}

Explanation

In the above example, we create a fetch() function that makes a GET request to the URL provided to it. In the main function, we concurrently make GET requests to these URLs and print out their respective status codes. One thing to note is that these URLs take different time to respond.

Output

$ go run main.go
Start consuming API endpoints
[ https://www.golinuxcloud.com ] -> 200
[ https://aws.amazon.com ] -> 200
[ https://go.dev ] -> 200
[ https://www.google.com ] -> 200
[ https://www.microsoft.com ] -> 200
Done consuming API endpoints

 

Summary

In this article we learn about what WaitGroup is and  how to use WaitGroups in real world examples. WaitGroups are important for concurrency in Go , because they ensure all available goroutines execute before the main goroutine terminates. Go comes with an errgroup wrapper that handles errors from goroutines and synchronizes and propagates the errors to a higher function.If some more complex decisions and work needs to be made based off of the errors, a channel is probably better fit.

Advertisement

 

References

https://pkg.go.dev/sync
https://gobyexample.com/waitgroups
https://www.educative.io/answers/how-to-use-waitgroup-in-golang

 

Further Reading

Goroutines Complete Tutorial Explained in Layman's Terms
Goroutine vs Threads in Golang [8 Differences]

 

Didn't find what you were looking for? Perform a quick search across GoLinuxCloud

If my articles on GoLinuxCloud has helped you, kindly consider buying me a coffee as a token of appreciation.

Buy GoLinuxCloud a Coffee

For any other feedbacks or questions you can either use the comments section or contact me form.

Thank You for your support!!

Leave a Comment

X