What You'll Learn
How Golang handles concurrent programming effortlessly using Goroutines, how to communicate safely using Channels, and when to use Mutexes for state management.
The Concurrency Problem
Modern servers have dozens of CPU cores. Languages like Python (with its GIL) and Node.js (single-threaded event loop) struggle to fully utilize multi-core systems out of the box. Go was built from the ground up to solve this using the CSP (Communicating Sequential Processes) model.
1. Goroutines (Lightweight Threads)
A goroutine is a function executing concurrently with other goroutines in the same address space. They are incredibly lightweight — you can easily spawn 100,000 goroutines on a standard laptop, whereas 100,000 OS threads would crash the system.
package main
import (
"fmt"
"time"
)
func fetchAPI(url string) {
// Simulate network request
time.Sleep(1 * time.Second)
fmt.Println("Fetched:", url)
}
func main() {
// Prefixing 'go' runs the function asynchronously!
go fetchAPI("https://api.github.com")
go fetchAPI("https://google.com")
fmt.Println("Requests started...")
// We must wait, otherwise main() exits before goroutines finish
time.Sleep(2 * time.Second)
fmt.Println("Done")
}
2. Channels (Safe Communication)
Go's concurrency philosophy is: "Do not communicate by sharing memory; instead, share memory by communicating."
Channels are typed conduits through which you can send and receive values between goroutines safely, without needing explicit locks.
package main
import "fmt"
func processTask(id int, resultChan chan string) {
// Do work...
result := fmt.Sprintf("Task %d completed", id)
// SEND data into the channel
resultChan <- result
}
func main() {
// Create a channel of strings
ch := make(chan string)
// Launch 3 workers concurrently
for i := 1; i <= 3; i++ {
go processTask(i, ch)
}
// RECEIVE data from the channel (this blocks until data arrives!)
for i := 1; i <= 3; i++ {
fmt.Println(<-ch)
}
}
3. The Select Statement
What if you want to listen to multiple channels at the same time? Or implement a timeout? The select statement lets a goroutine wait on multiple communication operations.
package main
import (
"fmt"
"time"
)
func main() {
fastChan := make(chan string)
slowChan := make(chan string)
go func() {
time.Sleep(100 * time.Millisecond)
fastChan <- "Fast API response"
}()
go func() {
time.Sleep(2 * time.Second)
slowChan <- "Slow DB query"
}()
// Wait for the first channel to respond, or timeout
select {
case msg := <-fastChan:
fmt.Println("Received:", msg)
case msg := <-slowChan:
fmt.Println("Received:", msg)
case <-time.After(1 * time.Second):
fmt.Println("Timeout! Systems took too long.")
}
}
4. WaitGroups (Waiting for multiple tasks)
Using time.Sleep() to wait for goroutines is a terrible practice. Instead, use a sync.WaitGroup to wait for a collection of goroutines to finish.
package main
import (
"fmt"
"sync"
"time"
)
func worker(id int, wg *sync.WaitGroup) {
defer wg.Done() // Decrement counter when function exits
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) // Increment counter
go worker(i, &wg)
}
wg.Wait() // Block until counter goes back to 0
fmt.Println("All workers finished. Exiting main.")
}
When to use Mutexes instead of Channels?
While channels are great for passing data, sometimes you just need to update a shared map or counter. Using sync.Mutex is perfectly valid and often more performant for simple state protection.
var mu sync.Mutex
var counter int
func increment() {
mu.Lock()
counter++
mu.Unlock()
}