In the real world of applications, not every operation inside a program executes concurrently, however many operations will most probably do. A lot of the times though, synchronous code needs a certain condition to be true in order to proceed, thus waiting for a certain amount of concurrent actions to complete first.
The order in which concurrent code executes is always non-deterministic, thus the way in which
the go
keyword appears, does not mean that's the order in which way the Go Scheduler will
schedule them or that, that's the order in which they will actually get to run.
That being said, sometimes there is a need to preserve order, but still execute our
operations concurrently.
Also in a real world, a service does not always run standalone, meaning sometimes our applications rely on other downstream applications in order to produce a result. In many cases we do not control those downstream applications, and many of those applications may have API Rate Limiting, which means our upstream application has to be careful on how many requests it makes, and at which rate they happen.
So we need some kind of waiting mechanism for concurrent code. We need to not wait too long, so that our system is efficient and makes the best use of our resources, but also wait just enough, so that the order is preserved, certain conditions are met, and we have control over the amount of concurrent operations happening in our system.
Luckily there's no need to reinvent the wheel, Go introduces the sync.WaitGroup
type,
which does exactly all the above mentioned.
Go is a powerful language, and it gained its popularity because people hear its Concurrency Model is one of the best. While Go has quite an elegant way of doing Concurrency, Go did not give up the Classic way of doing Concurrency.
In Go there are 2 ways of making sure concurrent code executes correctly and safely:
Concurrency Primitives
Channels
Very important to stress out, it's not one versus the other, and there's no better
way of executing concurrent code using one or the other. It all depends on the
type of scenario and use case. Generally everything that can be achieved with channels
can be achieved with the sync
Concurrency Primitives as well. It's a matter of choice depending on
Complexity, Composition, Performance or in general on what's being done with the data.
All of Go's Concurrency Primitives are stored inside the sync
package, which stands for
Synchronization, because most of the times that's what we as developers do with the
executing concurrent code.
These are all the available types under the sync
package:
WaitGroup
Mutex
RWMutex
Locker
Cond
Map
Pool
In order to achieve tasks like:
- Waiting on a Condition (concurrent operations) to finish
- Rate Limiting the amount of max Concurrent Operations
- Preserve Concurrent Operations order
and a bunch of other things, we can simply make use os the sync.WaitGroup
type.
Using a sync.WaitGroup
is fairly simple, all we need to do is make sure to:
- Create a
sync.WaitGroup
- Call the
Add()
- Call the
Done()
method inside each concurrent operation, once it's done - Call the
Wait()
method at the waiting point
Using a sync.WaitGroup
in your code may cause a wide variety of issues,
this is why here are some golden rules I recommend everyone who works with WaitGroup(s)
Done()
MUST be called as many times asAdd()
- If calls to
Done()
are less than calls toAdd()
=> deadlock - If calls to
Done()
are more than calls toAdd()
=> panic - Calling
Wait()
without callingAdd()
will return immediately sync.WaitGroup
MUST always be passed by reference (as pointer) => (possible panic)- Calling another
Wait()
before the previous one returns => panic - Call
Add(n)
when you can, as opposed toAdd(1)
multiple times => (a little faster)
It's fairly easy to implement a WaitGroup. We only need couple of functionalities:
- We need an
Add
method to increment the internal state counter (Atomically) - We need a
Done
method to decrement the internal state counter (Atomically) - We need a
Wait
method to infinitely wait till the internal state counter reaches 0 (Atomically)
To test programs for race conditions, just use the -race
flag
before running:
cd into/the/example/dir
go run -race main.go
To run the benchmarks:
cd benchmarks
# run all the benchmarks in the current directory
go test -bench=.
# run the benchmarks for 3s, by default it runs them for 1s
go test -bench=. -benchtime=3s
To run your programs by tracing your go routines, and the way they're scheduled:
# run the program by tracing the Go Scheduler
GOMAXPROCS=1 GODEBUG=schedtrace=5000,scheddetail=1 go run main.go
# run the program by tracing the Go Scheduler on already built binary
go build -o exec
GOMAXPROCS=1 GODEBUG=schedtrace=5000,scheddetail=1 ./exec
- Concurrency in Go #2 - WaitGroups (Part 1) - [Download Zip]
- Concurrency in Go #3 - WaitGroups (Part 2) - [Download Zip]
- Concurrency in Go #4 - WaitGroups (Part 3) - [Download Zip]
- Without WaitGroup
- Basic Example
- With WaitGroup
- Deadlock
- Passed by Value
- WaitGroup reuse before Wait() return - simple
- WaitGroup reuse before Wait() return - loop
- Too many calls to Done()
- No calls to Add()
- Limit Go Routines
- Rate Liming Example
- Go Routines Order - Simple
- Go Routines Order - Preserve Order
- Go Routines Order - Different Workloads
- WaitGroup Implementation
- Benchmark - WaitGroup Add-One vs Add-Many