Making API calls from the backend is a pretty common scenario we all come across, especially when working with microservices. Sometimes we even have to make multiple calls at the same time and doing it sequentially will be inefficient. So in this article, let us see how to implement concurrency when making multiple API calls.
Encapsulating request code
Taking a look at what we have got we have
- a struct called Comic which contains the fields required fields from the response
- a function to make the request to XKCD and to return the decoded response
type Comic struct {
Num int `json:"num"`
Link string `json:"link"`
Img string `json:"img"`
Title string `json:"title"`
}
const baseXkcdURL = "https://xkcd.com/%d/info.0.json"
func getComic(comicID int) (comic *Comic, err error) {
url := fmt.Sprintf(baseXkcdURL, comicID)
response, err := http.Get(url)
if err != nil {
return nil, err
}
err = json.NewDecoder(response.Body).Decode(&comic)
if err != nil {
return nil, err
}
return comic, nil
}
Getting started
Now coming to our basic version of the code down.
What it does is:
- takes an integer array of comic IDs and loops over it
- for each ID, retrieves the response from
getComic
and sets it in the map
As you can see, the code is pretty straightforward and should function effectively in most cases. But the problem comes with execution time.
func main() {
start := time.Now()
defer func() {
fmt.Println("Execution Time: ", time.Since(start))
}()
comicsNeeded := []int{1, 2, 3, 4, 5, 6, 7, 8, 9, 10}
comicMap := make(map[int]*Comic, len(comicsNeeded))
for _, id := range comicsNeeded {
comic, err := getComic(id)
if err != nil {
continue
}
comicMap[id] = comic
fmt.Printf("Fetched comic %d with title %v\n", id, comic.Title)
}
}
A glance at the output can tell us that the output delivered is sequential as expected, since the each API call is made only after the previous one is done.
So the execution took well above 7 seconds, which would not be ideal if the server was dealing with heavy load at scale.
Making it concurrent
Compared to other languages, I believe that Go has much better concurrency support. We will be using goroutines here, they are lightweight threads that run concurrently and can be spawned easily, thanks to Go.
After modifying the main function to spawn goroutines, here's how it looks like
func main() {
start := time.Now()
defer func() {
fmt.Println("Execution Time: ", time.Since(start))
}()
comicsNeeded := []int{11, 22, 33, 44, 55, 66, 77, 88, 99, 100}
comicMap := make(map[int]*Comic)
wg := sync.WaitGroup{}
for _, id := range comicsNeeded {
wg.Add(1)
go func(id int) {
comic, err := getComic(id)
if err != nil {
return
}
comicMap[id] = comic
fmt.Printf("Fetched comic %d with title %v\n", id, comic.Title)
wg.Done()
}(id)
}
wg.Wait()
}
Okay, what's different?
- We are creating a WaitGroup first in
wg := sync.WaitGroup
. The waitgroup can be thought of as a global counter which we can use to wait for all the goroutines to finish. - While we loop over the
comicNeeded
array,wg.Add(1)
is used to indicate that we are creating a goroutine. - The
go func(){...}()
fires the anonymous function as a separate goroutine. The id argument is used to capture the loop variable at that point of time. - Inside the goroutine, we wait for
getComic
so we can append it to the map. Once that is donewg.Done()
this indicates that this goroutine has finished. - And finally after the loop is over, we call
wg.Wait()
. Why is this necessary? When spawning of all the goroutines has been completed, the main thread has no work left to do, which will intuitively lead to its termination. However, it is undesirable, as it causes all the goroutines to quit. So in order to make the main thread wait till all the spawned goroutines are marked as done, we performwg.Wait()
.
Phew, now take a look at the output.
Woah, a reduction in execution time by a huge factor! The calls are not sequential as each goroutine executes independently based on the CPU availability.
Conclusion
To wrap it up concurrency, depending on the application, can give huge performance boosts to your code. I wanted to do this using Channels at first but later realised that it could be done much simpler using just WaitGroups. But I do have a blog next planned with the use of channels in the same context.
Top comments (3)
This is not a safe way to use maps in Go :) the problem is with
comicMap := make(map[int]*Comic)
where you write to it from multiple goroutines in parallel. Consider either using sync.Map or just a sync.Mutex to synchronize writing results to the map.If you have added a test for this and tried running them with --race flag then the compiler would have warned you.
Alternatively, you can also spawn a goroutine in the background that's just listening for results in a channel and all these goroutines that are doing HTTP requests could send results to the channel.
Anyway, there are several options how to solve this but this example might end up being a problem :)
Can we use mutex in the above example to resolve the racing? :)
Here's an updated code with channels also making it faster and ran for 1 sec