Understanding concurrency in go

Concurrency is about dealing with lots of things at once. Parallelism is about doing lots of things at once - Rob Pike

Go language itself provides some features to handle concurrency out of the box, hiding all the complexities so that developers write better, faster, more efficient code. These features include :-

Goroutines: A goroutine is a lightweight thread managed by the Go runtime.

Just add go in front of your function call to convert it into goroutine and you can take advantage of concurrency.

func main() {
	go say("hello") // concurrently executes func say
}

// you might also see the following pattern
func main() {
	go func(greeting string){ // concurrently executes anonymous func and 
    	say(greeting) // call our function inside it
    }("hello")
}

Channels: Channels are a typed conduit through which you can send and receive values with the channel operator, <-.

ch := make(chan int) // unbuffered channel
ch := make(chan int, 30) // buffered channel
ch := make(<-chan int) // Receive only channel
ch := make(chan<- int) // Send only channel

ch <- 1 // sends value to channel
x = <-ch // assign value from channel to x

close(ch) // close channel and clean memory

WaitGroups: A WaitGroup waits for a collection of goroutines to finish.

var wg sync.WaitGroup
        
func main() {
	wg.Add(1) // Add to set the number of goroutines to wait for
	go func(asyncFuncParam string) {
    	// each of the goroutines runs and calls Done when finished
		defer wg.Done() // executed after functions returns
		asyncFunc(asyncFuncParam)
	}("USING WAIT GROUP")
    wg.Wait() // block until all goroutines have finished
}

Concurrency in go

If you are not taking advantage of goroutines then ask yourself why not? I mean why don’t you want your code to go faaassstttt? While there are a lot of cases where sequential execution of code is important, here we really cannot do anything but there are some situations where we could take advantage of concurrency but uncontrolled concurrency is harmful. For example: Making multiple concurrent API requests might get you in trouble of getting rate limited HTTP 429 - Too Many Requests. Luck you, even in such cases we can take advantage of language features like channels and use it’s properties to limit concurrency.

In this post, I have shared 2 different ways of handling concurrency

Uncontrolled concurrency

This will queue as many goroutines to execute in concurrent mode as your system can handle. Use this when your program has many autonomous pieces independent of each other

var wg sync.WaitGroup
    
for _, item := range items {
	wg.Add(1)
	go func(asyncFuncParam string) {
		defer wg.Done()
		asyncFunc(asyncFuncParam)
	}("USING WAIT GROUP")
}
wg.Wait()

Controlled concurrency

The will create a bounded queue and limit goroutines according to a set limit per seconds while execute in concurrent mode following bucketing/ short bursts pattern. Use this when your program has many autonomous pieces independent of each other but you are rate limited due to some bottleneck.

const (
	CONCURRENCY_LIMIT = 30 // max 30 items in a channel per second 
)
    
guard := make(chan struct{}, CONCURRENCY_LIMIT)
    
for _, item := range items {
	guard <- struct{}{}
	go func(asyncFuncParam string) {
		defer func() { <-guard }()
		asyncFunc(asyncFuncParam)
	}("USING CHANNELS FOR CONTROLLED CONCURRENCY")
}

Benchmarks

200+ API requests

Mode Time of Execution Outcome
Synchronous ~1 min 20 sec All API succeeded
Uncontrolled concurrency ~5 sec Many API failed with error
Controlled concurrency ~10 sec All API succeeded

No doubt uncontrolled concurrency will be the fastest to complete the job but in the end it failed to get all the response successfully. With controlled concurrency, I can manually tweak performance and find right balance between rate limits and execution time.

Learning resources

comments powered by Disqus