Optimizing Concurrent Data Fetching in Go with singleflight
In high-throughput applications, efficiency isn’t just a nicety—it’s a necessity.
When multiple requests for the same resource flood in simultaneously, handling them
efficiently can make or break your system’s performance.
This is a challenge I’ve encountered numerous times, and one elegant solution
in Go is the singleflight
package.
Let’s dive into how singleflight
can optimize concurrent data fetching, using the example of fetching currency exchange rates in a financial application.
All code examples from this essay can be found at this repository .
The Problem: Duplicate Work and Resource Contention
Imagine a financial service that provides real-time currency exchange rates to thousands of users. Exchange rates are fetched from an external API, which has strict rate limits and charges per request. When multiple users request the same currency pair at the same time, naively forwarding all these requests to the external API leads to:
- Redundant Work: The same data is fetched multiple times.
- Increased Costs: More API calls mean higher expenses.
- Potential Throttling: Exceeding rate limits can result in being temporarily blocked.
- Inefficient Resource Utilization: Unnecessary load on both your system and the external API.
Data Flow without singleflight
sequenceDiagram participant User1 participant User2 participant Service participant ExternalAPI User1->>Service: Request exchange rate (USD/EUR) User2->>Service: Request exchange rate (USD/EUR) Note over Service: Service handles requests concurrently Service->>ExternalAPI: Fetch exchange rate (USD/EUR) [Call 1] Service->>ExternalAPI: Fetch exchange rate (USD/EUR) [Call 2] ExternalAPI-->>Service: Exchange rate data [Response 1] ExternalAPI-->>Service: Exchange rate data [Response 2] Service-->>User1: Return exchange rate Service-->>User2: Return exchange rate
Enter singleflight
Go’s singleflight
package provides a mechanism to suppress duplicate function calls.
It ensures that only one execution of a given function is in-flight for a particular key at a time.
If multiple goroutines call the function with the same key, they wait for the first call to complete and receive the same result.
Data Flow with singleflight
sequenceDiagram participant User1 participant User2 participant Service participant ExternalAPI User1->>Service: Request exchange rate (USD/EUR) User2->>Service: Request exchange rate (USD/EUR) Note over Service: Service uses singleflight Service->>ExternalAPI: Fetch exchange rate (USD/EUR) [Single Call] ExternalAPI-->>Service: Exchange rate data Service-->>User1: Return exchange rate Service-->>User2: Return exchange rate
How singleflight
Works
At its core, singleflight
maintains a map of keys to in-flight calls:
- When a function is called with a key,
singleflight
checks if there’s an ongoing call for that key. - If there is, it waits for the result of the in-flight call.
- If not, it starts a new call and registers it.
- Once the call completes, all waiting goroutines receive the result.
This mechanism effectively collapses multiple concurrent calls into a single call, sharing the result among all callers.
Implementing singleflight
(for our example)
In our currency exchange rate service, we can use singleflight
to prevent redundant API calls. Here’s how:
- Create a
singleflight.Group
: This group will manage in-flight calls. - Use the
Do
Method: Wrap the API call in theDo
method, using the currency pair as the key. - Handle Contexts: Since
singleflight
doesn’t natively support contexts, we’ll manually check for context cancellation. - Implement Caching: Store the fetched exchange rates to serve future requests without hitting the API.
|
|
Benchmarks
Handling High Concurrency and Resource Limits
To simulate high concurrency and external API limitations, we:
- Increased the Number of Goroutines: Simulating thousands of concurrent requests.
- Introduced a Semaphore: Limiting the number of concurrent API calls to mimic rate limits.
- Added an API Call Counter: Tracking the number of actual API calls made.
Two scenarios have been benchmarked:
- With
singleflight
: Only one API call is made, regardless of the number of concurrent requests. - Without
singleflight
: An API call is made for each request, leading to excessive API calls and increased latency due to semaphore blocking.
|
|
Results
|
|
Benchmark | Iterations | Time (ns/op) | API Calls | Time per Iteration | Memory (B/op) | Allocations (allocs/op) |
---|---|---|---|---|---|---|
FetchExchangeRate_Singleflight-10 | 5 | 202,096,742 | 1.000 | 202.0 ms | 154,904 | 4,240 |
FetchExchangeRate_NoSingleflight-10 | 1 | 4,023,039,416 | 1000 | 4,023 ms | 636,304 | 13,393 |
The benchmark results clearly show that using singleflight
:
- Is ~20x faster per operation
- Uses ~4x less memory
- Has ~3x fewer allocations
- Makes 1000x fewer API calls
Testing
This opportunity was used to cover all different scenarios, both with a table-driven approach and case-by-case testing. Primarily, it demonstrates that easier maintenance can be achieved with the TD approach. Take a look at all the tests in this repo , for a more detailed overview.
To run and parse the tests, you can use the following command:
|
|
And if you don’t have tparse
installed, you can do so by running:
|
|
Table-Driven tests for our example
|
|
When to Use singleflight
singleflight
is ideal when:
- Multiple Requests for Identical Data: Your system frequently receives concurrent requests for the same resource.
- Expensive Operations: The function you’re calling is resource-intensive or has significant latency.
- External Rate Limits: You’re interacting with services that limit the number of requests or charge per request.
Conclusion
Optimizing concurrent data fetching is critical in building efficient, scalable applications. Go’s singleflight
package provides a powerful yet simple mechanism to suppress duplicate function calls, reducing redundant work and improving performance.
In our currency exchange rate service, implementing singleflight
led to a dramatic reduction in API calls and response times under high concurrency. By combining singleflight
with proper caching and context handling, we built a robust solution that scales gracefully.
Key Takeaways:
- Understand Your Concurrency Patterns: Identify where redundant work occurs and how it impacts your system.
- Leverage Go’s Concurrency Tools: Packages like
singleflight
can significantly simplify concurrency management. - Benchmark and Test Thoroughly: Real-world performance gains are validated through careful benchmarking and testing.
- Handle Contexts Appropriately: Always ensure that your functions respect cancellations and timeouts.
Special shoutout to Amaury Brisou for the inspiring questions that led me to reconsider how cancellation is handled with wait groups.
mermaid
|
|