How to share one HTTP request instance beween two goroutines?

862 views Asked by At

I have some code that makes 3 requests to fill 3 variables now. Two requests are same. I want to share one http request between two different functions (in real world, these functions are splitted into two different modules).

Let me describe the problem what I have based on much simpler example than I have in real world.

At the moment, I have the following main function and Post data structure:

type Post struct {
    ID          int    `json:"id"`
    Title       string `json:"title"`
    UserID      int    `json:"userId"`
    isCompleted bool   `json:"completed"`
}

func main() {
    var wg sync.WaitGroup

    fmt.Println("Hello, world.")

    wg.Add(3)

    var firstPostID int
    var secondPostID int
    var secondPostName string

    go func() {
        firstPostID = getFirstPostID()
        defer wg.Done()
    }()

    go func() {
        secondPostID = getSecondPostID()
        defer wg.Done()
    }()

    go func() {
        secondPostName = getSecondPostName()
        defer wg.Done()
    }()

    wg.Wait()

    fmt.Println("first post id is", firstPostID)
    fmt.Println("second post id is", secondPostID)
    fmt.Println("second post title is", secondPostName)
}

There are three goroutines, so I have 3 concurrent requests, I sync everything using sync.Workgroup. The following code is implementation of the requests:

func makeRequest(url string) Post {
    resp, err := http.Get(url)
    if err != nil {
        // handle error
    }
    defer resp.Body.Close()
    body, err := ioutil.ReadAll(resp.Body)
    var post Post

    json.Unmarshal(body, &post)

    return post
}

func makeFirstPostRequest() Post {
    return makeRequest("https://jsonplaceholder.typicode.com/todos/1")
}

func makeSecondPostRequest() Post {
    return makeRequest("https://jsonplaceholder.typicode.com/todos/2")
}

Here is implementation of functions which pulls needed information from fetched posts:

func getFirstPostID() int {
    var result = makeFirstPostRequest()
    return result.ID
}

func getSecondPostID() int {
    var result = makeSecondPostRequest()

    return result.ID
}

func getSecondPostName() string {
    var result = makeSecondPostRequest()

    return result.Title
}

So, at the moment I have 3 concurrent requests, this works perfectly. The problem is I don't want 2 absolutely same separate HTTP requests to fetch the second post. One would be enough. So, what I want to achieve is 2 concurrent requests for post 1 and post 2. I want second call to makeSecondPostRequest not to create new HTTP request, but share the existing one (which was sent by the first call).

How I can achieve this?

Note: the following code is how this can be done using JavaScript, for example.

let promise = null;
function makeRequest() {
    if (promise) {
        return promise;
    }

    return promise = fetch('https://jsonplaceholder.typicode.com/todos/1')
      .then(result => result.json())
      // clean up cache variable, so any next request in the future will be performed again
      .finally(() => (promise = null))

}

function main() {
    makeRequest().then((post) => {
        console.log(post.id);
    });
    makeRequest().then((post) => {
        console.log(post.title);
    });
}

main();
1

There are 1 answers

0
Schwern On

While you could put something together like promises, in this case it's not necessary.

Your code is written in a procedural fashion. You've written very specific functions which pull specific little bits off the Post and throw out the rest. Instead, keep your Post together.

package main

import(
    "fmt"
    "encoding/json"
    "net/http"
    "sync"
)

type Post struct {
    ID          int    `json:"id"`
    Title       string `json:"title"`
    UserID      int    `json:"userId"`
    isCompleted bool   `json:"completed"`
}

func fetchPost(id int) Post {
    url := fmt.Sprintf("https://jsonplaceholder.typicode.com/todos/%v", id)
    resp, err := http.Get(url)
    if err != nil {
        panic("HTTP error")
    }
    defer resp.Body.Close()

    // It's more efficient to let json Decoder handle the IO.
    var post Post
    decoder := json.NewDecoder(resp.Body)
    err = decoder.Decode(&post)
    if err != nil {
        panic("Decoding error")
    }

    return post
}

func main() {
    var wg sync.WaitGroup

    wg.Add(2)

    var firstPost Post
    var secondPost Post

    go func() {
        firstPost = fetchPost(1)
        defer wg.Done()
    }()

    go func() {
        secondPost = fetchPost(2)
        defer wg.Done()
    }()

    wg.Wait()

    fmt.Println("First post ID is", firstPost.ID)
    fmt.Println("Second post ID is", secondPost.ID)
    fmt.Println("Second post title is", secondPost.Title)
}

Now instead of caching responses you can cache Posts. We can do this by adding a PostManager to handle fetching and caching Posts.

Note that normal map is not safe for concurrent use, so we use sync.Map for our cache.

type PostManager struct {
    sync.Map
}

func (pc *PostManager) Fetch(id int) Post {
    post, ok := pc.Load(id)
    if ok {
        return post.(Post)
    }
    post = pc.fetchPost(id)
    pc.Store(id, post)

    return post.(Post)
}

func (pc *PostManager) fetchPost(id int) Post {    
    url := fmt.Sprintf("https://jsonplaceholder.typicode.com/todos/%v", id)
    resp, err := http.Get(url)
    if err != nil {
        panic("HTTP error")
    }
    defer resp.Body.Close()

    // It's more efficient to let json Decoder handle the IO.
    var post Post
    decoder := json.NewDecoder(resp.Body)
    err = decoder.Decode(&post)
    if err != nil {
        panic("Decoding error")
    }

    return post
}

PostManager methods must take a pointer receiver to avoid copying the mutex inside sync.Map.

And instead of fetching Posts directly, we use the PostManager.

func main() {
    var postManager PostManager

    var wg sync.WaitGroup

    wg.Add(2)

    var firstPost Post
    var secondPost Post

    go func() {
        firstPost = postManager.Fetch(1)
        defer wg.Done()
    }()

    go func() {
        secondPost = postManager.Fetch(2)
        defer wg.Done()
    }()

    wg.Wait()

    fmt.Println("First post ID is", firstPost.ID)
    fmt.Println("Second post ID is", secondPost.ID)
    fmt.Println("Second post title is", secondPost.Title)
}

PostManager's caching would be improved by using conditional requests to check if the cached Post has changed or not.

Its locking can also be improved, as written its possible to fetch the same Post at the same time. We can fix this using singleflight to allow only one call to fetchPost with a given ID to happen at a time.

type PostManager struct {
    group singleflight.Group
    cached sync.Map
}

func (pc *PostManager) Fetch(id int) Post {
    post,ok := pc.cached.Load(id)
    if !ok {
        // Multiple calls with the same key at the same time will only run the code once, but all calls get the result.
        post, _, _ = pc.group.Do(strconv.Itoa(id), func() (interface{}, error) {
            post := pc.fetchPost(id)
            pc.cached.Store(id, post)
            return post, nil
        })
    }
    return post.(Post)
}