Common Approach (Using Channels)
Typically, we use a channel
to collect results from multiple goroutines as follows:
package main
import "time"
func handleRequest(req int) int {
time.Sleep(2 * time.Millisecond)
return req * 2
}
func requestWithChannel(ch chan<- int, req int) {
ch <- handleRequest(req)
}
func main() {
requests := []int{1, 2, 3, 4, 5}
responses := make([]int, len(requests))
ch := make(chan int, len(requests))
for _, req := range requests {
go requestWithChannel(ch, req)
}
for i := range requests {
responses[i] = <-ch
}
}
In this code snippet, we have a handleRequest
function that simulates processing by sleeping for 2 milliseconds and then returning the input multiplied by 2. We then have a requestWithChannel
function that sends the result of handleRequest
to a channel. In the main
function, we create a channel ch
with a buffer size equal to the number of requests. We then iterate over the requests, spawning a goroutine for each request to process it concurrently. Finally, we collect the responses from the channel and print them along with the time taken to process all requests.
This approach works fine for small-scale applications, but it has some drawbacks:
- Memory Usage: The channel buffer size must be equal to the number of requests, which can lead to high memory usage for large numbers of requests.
- Performance: The overhead of using channels can impact performance, especially when dealing with a large number of requests.
Optimized Approach (Using sync.WaitGroup and Pre-Allocated Slice)
Instead of using a channel
, we can pre-allocate a slice and write results directly to it. This reduces memory usage and improves performance.
package main
import (
"sync"
"time"
)
func handleRequest(req int) int {
time.Sleep(2 * time.Millisecond)
return req * 2
}
func requestEfficiently(wg *sync.WaitGroup, res *int, req int) {
defer wg.Done()
*res = handleRequest(req)
}
func main() {
var wg sync.WaitGroup
requests := []int{1, 2, 3, 4, 5}
responses := make([]int, len(requests))
for i, req := range requests {
wg.Add(1)
go requestEfficiently(&wg, &responses[i], req)
}
wg.Wait()
}
In this optimized version, we create a slice responses
to store the results of processing the requests. We also use a sync.WaitGroup
to wait for all goroutines to finish processing before printing the results. The requestEfficiently
function takes a pointer to the sync.WaitGroup
, a pointer to the result slice, and the request to process. It processes the request, updates the result slice, and signals the sync.WaitGroup
that it has finished processing.
This approach has several advantages:
- Memory Usage: Pre-allocated slice saves memory, eliminating the need for an intermediate channel.
- Performance: Better performance by avoiding the synchronization overhead of channels.
- Safe Data Handling: No race conditions, as each goroutine writes to a unique element in the slice.
By replacing channels with a pre-allocated slice and using a sync.WaitGroup
, we can optimize concurrent processing in Go, reducing memory usage, improving performance, and ensuring safe data handling. This approach is ideal for scenarios where efficiency and simplicity are key.
Compare Two Solutions
I run both solutions and compare their performance and memory usage for processing 1000000 requests.
processWithChannel Duration: 457.1165 ms
processWithChannel Memory used: 16086 KB
processWithWaitGroup Duration: 333.071167 ms
processWithWaitGroup Memory used: 13529 KB
When Should You Use This Approach?
- When you have a list of requests and only need to collect results without sharing data between goroutines.
- When optimizing for performance and memory efficiency is a priority.
- When you want to avoid the overhead of channels by pre-allocating memory upfront.
However, for complex models like fan-in/fan-out
, using channels may still be a better option.