Go’s concurrency model is one of its standout features. While channels often steal the spotlight for goroutine communication, understanding concurrency at its core, without relying on channels, is crucial for mastering Go. This post dives deep into goroutines, the sync
package, and practical patterns for synchronization.
Concurrency vs. Parallelism
Concurrency is about managing multiple tasks at once, while parallelism is about executing multiple tasks simultaneously. Go is designed for concurrency, making it easy to structure programs that can handle multiple operations independently.
Goroutines: The Building Blocks
A goroutine is a lightweight thread managed by the Go runtime. Creating a goroutine is as simple as prefixing a function call with go
.
package main
import (
"fmt"
"time"
)
func task(name string) {
for i := 0; i < 5; i++ {
fmt.Printf("Task %s is running: %d\n", name, i)
time.Sleep(time.Millisecond * 500)
}
}
func main() {
go task("A")
go task("B")
// Let the main function wait for goroutines to finish
time.Sleep(time.Second * 3)
fmt.Println("Main function exiting")
}
Synchronization Without Channels
Using sync.WaitGroup
sync.WaitGroup
is a powerful tool to wait for multiple goroutines to complete their work.
package main
import (
"fmt"
"sync"
"time"
)
func worker(id int, wg *sync.WaitGroup) {
defer wg.Done() // Decrement the counter when the goroutine completes
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 <= 3; i++ {
wg.Add(1)
go worker(i, &wg)
}
wg.Wait() // Wait for all goroutines to finish
fmt.Println("All workers completed")
}
Using sync.Mutex
When multiple goroutines access shared data, race conditions can occur. A sync.Mutex
ensures that only one goroutine can access a critical section of code at a time.
package main
import (
"fmt"
"sync"
)
type Counter struct {
value int
mu sync.Mutex
}
func (c *Counter) Increment() {
c.mu.Lock()
c.value++
c.mu.Unlock()
}
func main() {
counter := &Counter{}
var wg sync.WaitGroup
for i := 0; i < 10; i++ {
wg.Add(1)
go func() {
defer wg.Done()
counter.Increment()
}()
}
wg.Wait()
fmt.Printf("Final Counter Value: %d\n", counter.value)
}
Practical Patterns
Worker Pool Without Channels
A worker pool is a pattern where multiple workers perform tasks concurrently. Instead of channels, workers can access tasks from a shared slice protected by a mutex.
package main
import (
"fmt"
"sync"
)
func worker(id int, tasks *[]int, mu *sync.Mutex, wg *sync.WaitGroup) {
defer wg.Done()
for {
mu.Lock()
if len(*tasks) == 0 {
mu.Unlock()
return
}
task := (*tasks)[0]
*tasks = (*tasks)[1:]
mu.Unlock()
fmt.Printf("Worker %d processing task %d\n", id, task)
}
}
func main() {
tasks := []int{1, 2, 3, 4, 5}
var mu sync.Mutex
var wg sync.WaitGroup
for i := 1; i <= 3; i++ {
wg.Add(1)
go worker(i, &tasks, &mu, &wg)
}
wg.Wait()
fmt.Println("All tasks processed")
}
Concurrency Pitfalls
-
Race Conditions: When multiple goroutines access and modify shared data, race conditions can lead to unpredictable behavior. Tools like
sync.Mutex
andsync/atomic
help mitigate this. - Deadlocks: Occurs when goroutines wait indefinitely for resources locked by each other. Careful planning is essential to avoid deadlocks.
- Starvation: A goroutine may be blocked indefinitely if other goroutines dominate resources.
Conclusion
Concurrency in Go is incredibly powerful, even without channels. By mastering goroutines, the sync
package, and common patterns, you can write efficient, high-performance programs. Experiment with these tools, and you’ll quickly see why Go is a go-to choice for concurrent programming!
Top comments (0)