Go concurrency and AWS SDK

This article seeks to highlight significant performance improvements by using Golangs’s native concurrency tools when performing tasks that can be jointly done without losing context.

Challenge

I needed to audit resources across an entire AWS Organization spanning 7 accounts and 3 regions. Since manual console checks would be time-consuming (21 separate API calls), I wrote a Go script to automate this task.

This scenario presented the perfect opportunity to test Go's legendary concurrency in action. Go has become the language of choice for DevOps and infrastructure tools like Kubernetes, largely due to its powerful concurrency model. But just how much difference does it make in practice?

I decided to run the same task two ways: sequentially and concurrently. The results? A 16.7x performance improvement that demonstrates exactly why Go dominates the infrastructure tooling space.

This article compares the performance of counting EC2 instances across multiple AWS accounts and regions using both synchronous and concurrent approaches.

Go Concurrency: What is concurrency?

Concurrency in Go means structuring your program to handle multiple tasks simultaneously using goroutines - lightweight, independently executing functions managed by the Go runtime. These goroutines excel at I/O-bound tasks like API calls, where traditional sequential code wastes time waiting for network responses.

Running the Performance Analysis

Synchronous (Sequential) Pattern

Let’s go over the synchronous pattern and take note of the time it takes to run. The synchronous method used the nested for loop over the profiles and the region to execute the listInstance function one after the other. No extra function is spun in the background.

As seen in the result, it took about 16-17secs. Since each API call will wait for the previous one to complete before it proceeds, the network latency adds up and becomes slow.

    start := time.Now()
    for _, profile := range profiles {
        for _, region := range regions {
            if err := listEC2Instances(region, profile); err != nil {
                fmt.Fprintln(os.Stderr, err)
            }
        }
    }

    fmt.Printf("\n✅ Done in %s\n", time.Since(start))
➜ go run main.go
[Account-1/us-east-1] Running instances: 0
[Account-1/eu-west-1] Running instances: 3
[Account-1/eu-west-2] Running instances: 31
[Account-2/us-east-1] Running instances: 0
[Account-2/eu-west-1] Running instances: 8
[Account-2/eu-west-2] Running instances: 0
[Account-3/us-east-1] Running instances: 0
[Account-3/eu-west-1] Running instances: 11
[Account-3/eu-west-2] Running instances: 0
[Account-4/us-east-1] Running instances: 0
[Account-4/eu-west-1] Running instances: 5
[Account-4/eu-west-2] Running instances: 0
[Account-5/us-east-1] Running instances: 0
[Account-5/eu-west-1] Running instances: 1
[Account-5/eu-west-2] Running instances: 0
[Account-6/us-east-1] Running instances: 0
[Account-6/eu-west-1] Running instances: 3
[Account-6/eu-west-2] Running instances: 0
[Account-7/us-east-1] Running instances: 0
[Account-7/eu-west-1] Running instances: 0
[Account-7/eu-west-2] Running instances: 0

Done in 15.995265334s

Using Go’s Native Concurrency

For this test, let’s use the native Golang concurrency tool - Waitgroup for a number of reasons:

  • It is very simple and easy to understand

  • The highest number of instances in a particular region does not meet the present AWS EC2 API limits so it is safe to call it concurrently

  • We do not need the result of the goroutines in other aspects of the code for processing (We are just printing the result on screen without ordering or any other processing)

Concurrency Pattern

As there are 3 regions and 7 AWS accounts, the nested for loop will trigger about 21 goroutines to run concurrently to get information on the count of instances based on region and profile(AWS account).

Waitgroup is important to ensure all goroutines run successfully. It uses a counter method to ensure this happens. wg.Add(1) adds to the counter upon spawning of a new goroutine, wg.Done() is called when it is done executing and decreases the counter, and wg.Wait() unblocks execution when all goroutines are done executing and the waitgroup counter is 0. This helps to ensure the execution is complete and there are no lingering goroutines in the background.

Upon execution, it is observed it runs much faster than sequentially as it ran for about 1sec. This is due to the fact that each API call runs (almost) in parallel with one another and they all run independently of one another according to their region and respective AWS accounts.

As you can also notice, the results will always be random as there is no guarantee which goroutine will execute successfully first. The order will always be randomized

    start := time.Now()

    var wg sync.WaitGroup
    for _, profile := range profiles {
        for _, region := range regions {
            wg.Add(1)
            go func(p, r string) {
                defer wg.Done()
                if err := listEC2Instances(r, p); err != nil {
                    fmt.Fprintln(os.Stderr, err)
                }
            }(profile, region)
        }
    }

    wg.Wait()

    fmt.Printf("\n✅ Done in %s\n", time.Since(start))
➜ go run main.go
[Account-2/eu-west-2] Running instances: 0
[Account-5/eu-west-2] Running instances: 0
[Account-3/eu-west-2] Running instances: 0
[Account-4/eu-west-2] Running instances: 0
[Account-7/eu-west-1] Running instances: 0
[Account-6/eu-west-2] Running instances: 0
[Account-6/eu-west-1] Running instances: 3
[Account-1/eu-west-1] Running instances: 3
[Account-7/eu-west-2] Running instances: 0
[Account-4/eu-west-1] Running instances: 5
[Account-3/eu-west-1] Running instances: 11
[Account-2/eu-west-1] Running instances: 8
[Account-5/eu-west-1] Running instances: 1
[Account-1/eu-west-2] Running instances: 31
[Account-5/us-east-1] Running instances: 0
[Account-3/us-east-1] Running instances: 0
[Account-2/us-east-1] Running instances: 0
[Account-7/us-east-1] Running instances: 0
[Account-4/us-east-1] Running instances: 0
[Account-6/us-east-1] Running instances: 0
[Account-1/us-east-1] Running instances: 0

Done in 1.006744458s

Test Results Summary

Test RunSynchronous (Sequential)Goroutines (Concurrent)
118.706365708s1.006744458s
215.995265334s965.000708ms
316.794172667s979.524625ms
416.352032291s995.938833ms
516.030370583s1.072037125s

Performance Analysis

MetricSynchronousGoroutinesImprovement
Average Time16.776 seconds1.004 seconds16.7x faster
Fastest Run15.995s0.965s16.6x faster
Slowest Run18.706s1.072s17.4x faster

Situations this might not work

This pattern of using a Waitgroup will probably not work in these situations:

  • When the number of resources that needs auditing in a region exceed the specified rate limiting by the API call provider, we might need to implement a worker pattern that makes API calls by the allowed rate limit

  • When you need the result to be ordered, we might need to introduce channels.

Conclusion

We have been able to see the clear speed and performance difference in getting the resources sequentially or concurrently. I hope this guide serves a good template to implement more interesting solutions using this pattern and helps with your learning. Thanks for reading

Links

Github Repo: This holds all the code implemented in this tutorial and it is easily reproducible for use

References

  1. Jon Calhoun’s Article on WaitGroup

  2. AWS Go SDK Developer Guide

0
Subscribe to my newsletter

Read articles from Abdulmajid Adesokan directly inside your inbox. Subscribe to the newsletter, and don't miss out.

Written by

Abdulmajid Adesokan
Abdulmajid Adesokan

DevOps Engineer with particular interest in AWS, Terraform, Kubernetes, Docker and Github Actions