loading...

Making concurrent API requests in Go

digi0ps profile image Sriram ・3 min read

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.

asciicast

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?

  1. 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.
  2. While we loop over the comicNeeded array, wg.Add(1) is used to indicate that we are creating a goroutine.
  3. 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.
  4. Inside the goroutine, we wait for getComic so we can append it to the map. Once that is done wg.Done() this indicates that this goroutine has finished.
  5. 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 perform wg.Wait().

Phew, now take a look at the output.

asciicast

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.

Notes

Posted on by:

digi0ps profile

Sriram

@digi0ps

Polygot web developer dealing with Javascript, Python, Clojure and Go.

Discussion

pic
Editor guide
 

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 :)