Mastering Go Concurrency: From Basics to Advanced Patterns
The article walks readers through Go's concurrency model, explaining lightweight goroutines and channel communication, demonstrates common patterns such as worker pools and fan‑in/fan‑out with concrete code, highlights typical pitfalls like race conditions, deadlocks and memory leaks, and offers practical best‑practice recommendations for safe concurrent programming.
Go’s concurrency model is built around lightweight goroutine s and channels, which together provide a simple yet powerful way to write concurrent programs.
Goroutine – a cheap thread managed by the Go runtime. Creating one costs only a few kilobytes of stack.
// Create a goroutine
func main() {
go func() {
fmt.Println("Hello from goroutine")
}()
fmt.Println("Hello from main")
time.Sleep(time.Second) // wait for goroutine to finish
}Channel – the communication mechanism between goroutines.
// Use a channel for communication
func main() {
ch := make(chan string)
go func() {
ch <- "Hello from goroutine" // send
}()
msg := <-ch // receive
fmt.Println(msg)
}Common concurrency patterns :
Worker pool – a fixed number of workers consume jobs from a buffered channel and write results back.
func worker(id int, jobs <-chan int, results chan<- int) {
for j := range jobs {
fmt.Printf("Worker %d processing job %d
", id, j)
time.Sleep(time.Second)
results <- j * 2
}
}
func main() {
const numJobs = 5
jobs := make(chan int, numJobs)
results := make(chan int, numJobs)
// start 3 workers
for w := 1; w <= 3; w++ {
go worker(w, jobs, results)
}
// send jobs
for j := 1; j <= numJobs; j++ {
jobs <- j
}
close(jobs)
// collect results
for a := 1; a <= numJobs; a++ {
<-results
}
}Fan‑in / Fan‑out – multiple goroutines produce values that are merged into a single output channel.
func generate(done <-chan struct{}, nums ...int) <-chan int {
out := make(chan int)
go func() {
defer close(out)
for _, n := range nums {
select {
case out <- n:
case <-done:
return
}
}
}()
return out
}
func square(done <-chan struct{}, in <-chan int) <-chan int {
out := make(chan int)
go func() {
defer close(out)
for n := range in {
select {
case out <- n * n:
case <-done:
return
}
}
}()
return out
}
func merge(done <-chan struct{}, cs ...<-chan int) <-chan int {
var wg sync.WaitGroup
out := make(chan int)
output := func(c <-chan int) {
defer wg.Done()
for n := range c {
select {
case out <- n:
case <-done:
return
}
}
}
wg.Add(len(cs))
for _, c := range cs {
go output(c)
}
go func() {
wg.Wait()
close(out)
}()
return out
}
func main() {
done := make(chan struct{})
defer close(done)
in := generate(done, 1, 2, 3, 4, 5)
c1 := square(done, in)
c2 := square(done, in)
for n := range merge(done, c1, c2) {
fmt.Println(n)
}
}Typical pitfalls and their fixes:
Race condition – unsynchronized access to shared variables can produce nondeterministic results.
var counter int
var wg sync.WaitGroup
func increment() {
defer wg.Done()
for i := 0; i < 1000; i++ {
counter++ // race condition
}
}
func main() {
wg.Add(2)
go increment()
go increment()
wg.Wait()
fmt.Println(counter) // may not be 2000
}Fix with a mutex:
var mu sync.Mutex
func incrementSafe() {
defer wg.Done()
for i := 0; i < 1000; i++ {
mu.Lock()
counter++
mu.Unlock()
}
}Deadlock – sending on an unbuffered channel without a receiver blocks forever.
func main() {
ch := make(chan int) // unbuffered
ch <- 1 // blocks, no receiver
fmt.Println(<-ch) // never reached
}Fix by using a buffered channel or receiving in another goroutine:
func main() {
ch := make(chan int, 1) // buffered
ch <- 1
fmt.Println(<-ch)
}Memory leak – goroutine waiting on a channel that is never closed keeps the program alive.
func main() {
ch := make(chan int)
go func() {
for range ch { // never exits
// process data
}
}()
// forgot to close ch
}Fix by using context for cancellation and explicitly closing the channel:
func main() {
ch := make(chan int)
ctx, cancel := context.WithCancel(context.Background())
go func() {
for {
select {
case <-ctx.Done():
return
case data, ok := <-ch:
if !ok {
return
}
// process data
}
}
}()
// ... use channel ...
cancel()
close(ch)
}Best‑practice checklist for Go concurrency:
Use context to control goroutine lifetimes.
Synchronize goroutines with sync.WaitGroup.
Protect shared mutable state with mutexes or atomic operations.
Prefer buffered channels to limit concurrency and avoid blocking.
Detect goroutine leaks with the race detector and careful testing.
Use select to handle multiple channel operations safely.
Prefer unbuffered channels for hand‑off semantics when appropriate.
Set the number of goroutines based on workload and system resources.
Signed-in readers can open the original source through BestHub's protected redirect.
This article has been distilled and summarized from source material, then republished for learning and reference. If you believe it infringes your rights, please contactand we will review it promptly.
Golang Shines
We share daily the latest Golang technical articles, practical resources, language news, tutorials, and real-world projects to help everyone learn and improve.
How this landed with the community
Was this worth your time?
0 Comments
Thoughtful readers leave field notes, pushback, and hard-won operational detail here.
