Concurrency in Go: From Basics to Better Practices
When you want to do more than one thing at the same time...
Newbie Naive behavior
When you first want to do multiple things at once, you might write something like this:
func doSomething() {
fmt.Println("Doing something important...")
time.Sleep(time.Second * 2) // wasting your time
}
func doSomethingElse() {
fmt.Println("Doing something else that's equally important...")
time.Sleep(time.Second * 3) // still wasting your time
}
func main() {
start := time.Now()
fmt.Println("Starting first task...")
doSomething()
fmt.Println("Taking a long break...")
time.Sleep(time.Second * 5) // fake busy
fmt.Println("Starting second task...")
doSomethingElse()
fmt.Println("Taking another unnecessary break...")
time.Sleep(time.Second * 5) // fake busy again
fmt.Println("I'm finally done with all the important stuff...")
elapsed := time.Since(start)
fmt.Printf("All this hard work took %s\n", elapsed)
}
It's pretty dumb, but hey, we all start somewhere when coding from scratch!
Goroutines
So you've heard about these cool things called goroutines in Go. They're like lightweight threads, and you think, "Nice! I'll just add go
in front of everything!"
The main program doesn't give a shit about your goroutines. It'll just say "I'm done" and peace out before your goroutines even start their engines.
func main() {
go doSomething()
go doSomethingElse()
fmt.Println("I'm done")
}
directly print
go run main.go
# I'm done
WaitGroup to the Rescue
This is where WaitGroup comes in, like a responsible adult managing a bunch of hyperactive kids (goroutines).
WaitGroup tells the main program: "Hey, I'm in charge of these goroutines. You just sit tight and wait for my signal."
Here's how it works:
wg.Add()
: Hey, I'm starting a new task!
wg.Done()
: Alright, I'm finished with this task!
wg.Wait()
: Hold up, main program. Wait until all my tasks are done.
Basic
func worker(id int, wg *sync.WaitGroup) {
defer wg.Done() // i'm done. counter--
fmt.Printf("Worker %d starting\n", id)
time.Sleep(time.Second)
fmt.Printf("Worker %d done\n", id)
}
func main() {
var wg sync.WaitGroup
for i := 1; i <= 5; i++ {
wg.Add(1) // counter++
go worker(i, &wg)
}
wg.Wait() // "I'm waiting for all workers to finish!"
fmt.Println("All workers done")
}
Anonymous
func main() {
var wg sync.WaitGroup
for i := 1; i <= 3; i++ {
wg.Add(1)
go func(id int) {
defer wg.Done()
fmt.Printf("Goroutine %d working\n", id)
time.Sleep(time.Second * time.Duration(id))
}(i)
}
wg.Wait()
fmt.Println("All goroutines done")
}
Channels: When Goroutines Need to Chat
If your goroutines need to discuss their tasks, it's time to set up a channel for communication!
dual | ch := make(chan int) |
sender | sendCh := make(chan<- int) |
receiver | recvCh := make(<-chan int) |
func main() {
sendch := make(chan int)
recvch := make(<-chan int)
go sendData(sendch)
go receiveData(recvch)
recvch = sendch
// let goroutines have some time to execute
// no WaitGroup here so...
time.Sleep(time.Second)
}
func sendData(ch chan<- int) {
ch <- 42
}
func receiveData(ch <-chan int) {
fmt.Println(<-ch)
}
WaitGroup & Channel Teamwork 1
Here's a normal, correctly working example, but there's something curious about it. Why are wg.Wait()
and close(result)
inside a go func()
? Let's take a look:
func worker(id int, wg *sync.WaitGroup, result chan<- int) {
defer wg.Done()
fmt.Printf("Worker %d starting\n", id)
time.Sleep(time.Second * 2)
fmt.Printf("Worker %d done\n", id)
result <- id * 2
}
func main() {
var wg sync.WaitGroup
result := make(chan int, 3)
for i := 1; i <= 3; i++ {
wg.Add(1)
go worker(i, &wg, result)
}
go func() {
wg.Wait()
close(result)
}()
for r := range result {
fmt.Printf("Received result: %d\n", r)
}
fmt.Println("All workers are done")
}
What Happens If We Change Things?
Try changing the channel length to 2 and remove the outer goroutine. What do you think will happen?
result := make(chan int, 2)
// ...
// go func() {
wg.Wait()
close(result)
// }()
This will cause:
- Not enough buffer space
- Workers get blocked trying to send results, so they can't call Done()
wg.Wait()
keeps waiting forever
wg.Wait()
is synchronous, so it blocks the main program from continuing
- The result channel never gets to spit out its results
And boom! You get a nice fatal error: all goroutines are asleep - deadlock!
Buffer's Too Small, But We Use go func()
But if you keep it in a go func()
, even if your buffer is too small, it won't cause an error.
result := make(chan int, 2)
// ...
go func() {
wg.Wait()
close(result)
}()
WaitGroup & Channel Teamwork 2
Three Consumers Receive 5 Messages
func producer(ch chan<- int, wg *sync.WaitGroup) {
defer wg.Done()
for i := 0; i < 5; i++ {
ch <- i
time.Sleep(time.Millisecond * 100)
}
close(ch)
}
func consumer(id int, ch <-chan int, wg *sync.WaitGroup) {
defer wg.Done()
for num := range ch {
fmt.Printf("Consumer %d received: %d\n", id, num)
}
}
func main() {
var wg sync.WaitGroup
ch := make(chan int)
wg.Add(1)
go producer(ch, &wg)
for i := 0; i < 3; i++ {
wg.Add(1)
go consumer(i, ch, &wg)
}
wg.Wait()
fmt.Println("All done")
}
A Bit More on Better defer
Practices
Check out this situation. If the file fails to open, file
is nil, and there's no error handling in either the main program or the openFile function. Here's what I mean:
func openFileUnsafe(fileName string) {
file, e := os.Open(fileName)
fmt.Println((e))
// no error handling
// ...
defer file.Close()
fmt.Println("File opened successfully")
}
func main() {
openFileUnsafe("non_existent_file.txt")
fmt.Println("This line will not be reached if panic occurs")
}
When you run it, it's super weird! defer makes the error low-key LOL
WHY IT JUST FAIL SILENTLY?????
I don't know why this happens, I don't have an answer yet.
go run main.go
# open non_existent_file.txt: no such file or directory
# File opened successfully
# This line will not be reached if panic occurs
Here's a better way to write it. Even though the above didn't throw an error, the whole behavior is strange.
You should still properly handle errors everywhere, okay?
func openFileSafe(fileName string) error {
file, err := os.Open(fileName)
if err != nil {
return fmt.Errorf("failed to open file: %w", err)
}
defer file.Close()
fmt.Println("File opened successfully")
return nil
}
func main() {
if err := openFileSafe("file.txt"); err != nil {
fmt.Println("Error:", err)
return
}
fmt.Println("File processing completed")
}
When you run it:
go run wg.go
Error: failed to open file: open file.txt: no such file or directory
And there you have it! That's how you can make your Go concurrency code a bit safer and more reliable. Remember, always handle your errors and be careful with defer
!