For aspiring Go learners, I personally think Concurrency is your final boss. The dopamine rush you will get when you understand how it works and then you will also understand why Go is popular for concurrent applications.
In my opinion, Go has the most elegant implementations of concurrency. I haven’t dug into the Rust or Zig world to know about their implementation, but based on my experience, the simplicity that Go provides is simply unmatched.
Goroutines
What is a goroutine? Glad you asked, let’s explore it together. A goroutine is a mechanism provided by the Go runtime to execute code concurrently. And how do you invoke a goroutine? It’s stupidly simple using the keyword go. And remember, concurrency is not the same as parallelism.
go dosomething()
go dosomethingElse()
Now if you just spawn a new goroutine like this, nothing will happen, the program will run and exit. Example:
package main
import "time"
func worker(input string) {
time.Sleep(2 * time.Second)
println("done", input)
}
func main() {
go worker("goroutine")
go worker("goroutine 2")
}
Nothing will happen, you can check it here https://go.dev/play/p/L5RA483OS4_4 and why is that? We are running the program asynchronously, but we are not waiting to get results from our worker function. There are two ways we could achieve that, one of those is using WaitGroup, here is how to make our worker work!
WaitGroup
package main
import (
"sync"
"time"
)
func worker(input string, wg *sync.WaitGroup) {
defer wg.Done()
time.Sleep(2 * time.Second)
println("done", input)
}
func main() {
var wg sync.WaitGroup
wg.Add(2) // two goroutines
go worker("goroutine", &wg) // first goroutine
go worker("goroutine 2", &wg) // second goroutine
// Wait for all goroutines to finish
wg.Wait()
}
Here we are creating a waitgroup, adding 2, indicating 2 more goroutines to be waited for and spawning two goroutines. We pass the waitgroup to the goroutines, the wg.Done() decrements the counter from waitgroup. And wg.Wait blocks the main function until the counter is zero.
With Go 1.25, Go introduced a new way to work with WaitGroup, it simplifies the use of WaitGroup, here is the example: https://go.dev/play/p/JjGwiWgxidW
package main
import (
"sync"
"time"
)
func worker(input string) {
time.Sleep(2 * time.Second)
println("done", input)
}
func main() {
var wg sync.WaitGroup
wg.Go(func() {
worker("goroutine")
})
wg.Go(func() {
worker("goroutine 2")
})
wg.Wait()
}
If we are going to use WaitGroup, I prefer this syntax over the other, and about the other syntax, generally you would not be using WaitGroup that way, you would probably do something like this
package main
import (
"sync"
"time"
)
func worker(input string, wg *sync.WaitGroup) {
defer wg.Done() // always defer as first statement
time.Sleep(2 * time.Second)
println("done", input)
}
func main() {
inputs := []string{"goroutine 1", "goroutine 2", "goroutine 3"} // slice of work items
var wg sync.WaitGroup
wg.Add(len(inputs)) // add number of items dynamically
for _, input := range inputs {
go worker(input, &wg)
}
wg.Wait() // block until all goroutines finish
println("all done")
}
That’s how you would probably do it. Live example: https://go.dev/play/p/XtjVMDDalTG
Channels
There is another way to communicate with goroutines, and it’s channel, rightly named so. It’s quite interesting how it works. Here is our worker using channels
package main
import (
"fmt"
"time"
)
func worker(input string, ch chan string) {
time.Sleep(2 * time.Second)
str := "done " + input
ch <- str
}
func main() {
ch := make(chan string)
go worker("goroutine 1", ch)
go worker("goroutine 2", ch)
// since we know we already have two goroutines
for i := 0; i < 2; i++ {
fmt.Println(<-ch)
}
}
Few interesting tidbits:
- Use
ch<-to send something to the channel - Use
<-chto get it from the channel
Here in the above examples, I deliberately used 2 to iterate and get values, because we know we have only two goroutines sending values to the channel. Channels can be of two types: this is an example of an unbuffered channel. You can also create buffered channels with a fixed capacity, which allows the channel to hold multiple values before blocking the sender.
-
Unbuffered channel (make(chan string)): Sender blocks until receiver is ready
-
Buffered channel (make(chan string, 5)): Sender only blocks when buffer is full
package main
import (
"fmt"
"time"
)
func worker(input string, ch chan string) {
time.Sleep(2 * time.Second)
str := "done " + input
ch <- str
}
func main() {
ch := make(chan string, 2) // buffered channel with capacity 2
go worker("goroutine 1", ch)
go worker("goroutine 2", ch)
// The buffer allows both sends to complete without blocking,
// but we still need to know how many values to receive
for i := 0; i < 2; i++ {
fmt.Println(<-ch)
}
}
Another way to get values from channel and also close it is using range. If you use range to iterate then you don’t have to use <-ch to get values from channel, Go does that for you.
Example:
package main
import (
"fmt"
"time"
)
func worker(input string, ch chan string, done chan bool) {
time.Sleep(2 * time.Second)
str := "done " + input
ch <- str
done <- true // Signal completion
}
func main() {
ch := make(chan string)
done := make(chan bool)
inputs := []string{"goroutine 1", "goroutine 2"}
for _, input := range inputs {
go worker(input, ch, done)
}
// Close channel after all goroutines complete
go func() {
for i := 0; i < len(inputs); i++ {
<-done // Wait for each goroutine
}
close(ch)
}()
for result := range ch {
fmt.Println(result)
}
}
Here we use a “done channel” pattern. Each goroutine sends a signal to the done channel after sending its result to ch. A separate goroutine waits for all workers to complete (by receiving from done as many times as we have workers), then safely closes the ch channel. The way range works is, it will keep running forever expecting values until the channel is closed. If you don’t close the channel, you will get a deadlock error.
The way I have written the code is not the most idiomatic way to write Go code, an ideal Go code could be something like this, using WaitGroup
package main
import (
"fmt"
"sync"
"time"
)
func worker(input string, ch chan string, wg *sync.WaitGroup) {
defer wg.Done()
time.Sleep(2 * time.Second)
str := "done " + input
ch <- str
}
func main() {
ch := make(chan string)
var wg sync.WaitGroup
inputs := []string{"goroutine 1", "goroutine 2"}
for _, input := range inputs {
wg.Add(1)
go worker(input, ch, &wg)
}
go func() {
wg.Wait()
close(ch)
}()
for i := range ch {
fmt.Println(i)
}
}
You would often use channels alongside WaitGroup to write more idiomatic Go code.
Select
When dealing with multiple channels, the select statement makes it possible to wait on multiple channels, its syntax is similar to a switch statement. Check the below example
package main
import (
"fmt"
"time"
)
func worker(input string, ch chan string, interval time.Duration) {
time.Sleep(interval * time.Second)
str := "done " + input
ch <- str
}
func main() {
ch1 := make(chan string)
ch2 := make(chan string)
go worker("goroutine 1", ch1, 2)
go worker("goroutine 2", ch2, 1)
for range 2 {
select {
case msg1 := <-ch1:
fmt.Println("received", msg1)
case msg2 := <-ch2:
fmt.Println("received", msg2)
}
}
}
Here we will get
received done goroutine 2
received done goroutine 1
This is really a powerful feature of Go, and becomes really useful when dealing with multiple channels and multiple goroutines. For example, if the computation of goroutine 2 is done before goroutine 1, there is no point in blocking goroutine 1’s operations.
Like switch, the select statement also takes a default statement, it enables non-blocking channel operations. It would be much easier to explain with an example
package main
import (
"fmt"
"time"
)
func main() {
messages := make(chan string)
signals := make(chan bool)
// Non blocking receive
select {
case msg := <-messages:
fmt.Println("received message", msg)
default:
fmt.Println("no message received")
}
// Non blocking send
select {
case messages <- "hello":
fmt.Println("sent message", "hello")
default:
fmt.Println("no message sent")
}
// Using default for non-blocking multi way select
select {
case msg := <-messages:
fmt.Println("received message", msg)
case sig := <-signals:
fmt.Println("received signal", sig)
default:
fmt.Println("no activity")
}
// Polling pattern: check for messages, do other work if none available
go func() {
time.Sleep(200 * time.Millisecond)
messages <- "delayed message"
}()
go func() {
time.Sleep(500 * time.Millisecond)
signals <- true
}()
// Poll for messages while doing other work
for i := 0; i < 5; i++ {
select {
case msg := <-messages:
fmt.Println("received:", msg)
case sig := <-signals:
fmt.Println("received: signal ", sig)
default:
fmt.Printf("doing other work (iteration %d)\n", i)
time.Sleep(50 * time.Millisecond)
}
}
}
On first select we are receiving message from messages channel in a non-blocking manner, on second select we are sending message to messages channel. On third select we are receiving from multi channels in non-blocking way.
We execute two anonymous functions and set different sleep intervals, in our example delayed message will be received.
On final select we have a fictional polling, where we do other work in between waiting for messages and signals.
The output would be
no message received
no message sent
no activity
doing other work (iteration 0)
doing other work (iteration 1)
doing other work (iteration 2)
doing other work (iteration 3)
received: delayed message
Live example: https://go.dev/play/p/qHc9Rx3RBTG
Conclusion
The simplified concurrency is one of the selling points of Go, it’s really simple yet very powerful. We can easily write concurrent fast Go applications using these tools provided by Go, here is an example of how concurrency makes our program faster
package main
import (
"encoding/json"
"fmt"
"io"
"net/http"
"sync"
"time"
)
var apiUrl = "https://pokeapi.co/api/v2/"
type PokemonType struct {
Name string `json:"name"`
Height int `json:"height"`
Weight int `json:"weight"`
BaseExperience int `json:"base_experience"`
}
func getPokemonDetails(p string) error {
pokemonResponse := PokemonType{}
url := apiUrl + "pokemon/" + p
res, err := http.Get(url)
if err != nil {
return err
}
defer res.Body.Close()
if res.StatusCode != http.StatusOK {
return fmt.Errorf("failed to fetch data: %s", res.Status)
}
decodedData, err := io.ReadAll(res.Body)
if err != nil {
return err
}
err = json.Unmarshal(decodedData, &pokemonResponse)
if err != nil {
return err
}
fmt.Printf("Name: %s, Height: %d, Weight: %d, Base Experience: %d\n",
pokemonResponse.Name,
pokemonResponse.Height,
pokemonResponse.Weight,
pokemonResponse.BaseExperience,
)
return nil
}
func main() {
fmt.Println("Hello Goroutine")
func() {
start := time.Now()
withoutGoroutine()
elapsed := time.Since(start)
fmt.Printf("Without Goroutine took %s\n", elapsed)
}()
func() {
start := time.Now()
withGoRoutine()
elapsed := time.Since(start)
fmt.Printf("With Goroutine took %s\n", elapsed)
}()
}
func withGoRoutine() {
var wg sync.WaitGroup
wg.Go(func() {
getPokemonDetails("ditto")
})
wg.Go(func() {
getPokemonDetails("charizard")
})
wg.Go(func() {
getPokemonDetails("abra")
})
wg.Wait()
}
func withoutGoroutine() {
getPokemonDetails("piplup")
getPokemonDetails("bulbasaur")
getPokemonDetails("hypno")
}
Example output:
Hello Goroutine
Name: piplup, Height: 4, Weight: 52, Base Experience: 63
Name: bulbasaur, Height: 7, Weight: 69, Base Experience: 64
Name: hypno, Height: 16, Weight: 756, Base Experience: 169
Without Goroutine took 578.913833ms
Name: ditto, Height: 3, Weight: 40, Base Experience: 101
Name: abra, Height: 9, Weight: 195, Base Experience: 62
Name: charizard, Height: 17, Weight: 905, Base Experience: 240
With Goroutine took 68.407375ms
This is what I got on first run, it will vary on your machine, but I can guarantee you that, the one with goroutine will always be faster
I would highly recommend checking out these resources as well to learn more about Go concurrency