Channels can be used to synchronize execution across goroutines. By leveraging the blocking behavior of channel operations, you can coordinate goroutine execution and wait for tasks to complete.
Basic Synchronization Pattern
Use a blocking receive to wait for a goroutine to finish:
package main
import (
"fmt"
"time"
)
func worker(done chan bool) {
fmt.Print("working...")
time.Sleep(time.Second)
fmt.Println("done")
// Signal that we're done
done <- true
}
func main() {
// Create a channel for synchronization
done := make(chan bool, 1)
// Start the worker goroutine
go worker(done)
// Block until we receive notification
<-done
}
The main goroutine blocks at <-done until the worker sends a value, ensuring the worker completes before the program exits.
Why Use Buffered Channel?
The example uses a buffered channel:
done := make(chan bool, 1)
A buffer of 1 allows the worker to send without blocking, even if the receiver hasn’t started receiving yet. This prevents potential deadlocks in some edge cases.
Synchronization Patterns
1. Single Goroutine Completion
func task(done chan bool) {
// Do work
done <- true
}
func main() {
done := make(chan bool)
go task(done)
<-done // Wait for completion
}
2. Multiple Goroutines (Sequential)
func main() {
done := make(chan bool)
go task1(done)
<-done // Wait for task1
go task2(done)
<-done // Wait for task2
}
3. Multiple Goroutines (Concurrent)
func main() {
done := make(chan bool)
// Start all tasks
for i := 0; i < 5; i++ {
go task(i, done)
}
// Wait for all to complete
for i := 0; i < 5; i++ {
<-done
}
}
For waiting on multiple goroutines, prefer using WaitGroups for better clarity and safety.
Signal-Only Channels
When you only care about synchronization (not data), use empty structs:
done := make(chan struct{})
go func() {
// Do work
done <- struct{}{} // Send signal
}()
<-done // Receive signal
struct{} is zero-sized, making it memory-efficient for signaling.
Practical Example: HTTP Request with Timeout
func fetchWithTimeout(url string, timeout time.Duration) (string, error) {
result := make(chan string, 1)
errors := make(chan error, 1)
go func() {
resp, err := http.Get(url)
if err != nil {
errors <- err
return
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
result <- string(body)
}()
select {
case data := <-result:
return data, nil
case err := <-errors:
return "", err
case <-time.After(timeout):
return "", fmt.Errorf("timeout after %v", timeout)
}
}
Synchronization vs Communication
| Purpose | Pattern | Example |
|---|
| Synchronization | Signal completion | done <- true |
| Communication | Pass data | results <- data |
| Both | Pass result and signal | results <- computedValue |
Bi-Directional Synchronization
func worker(start, done chan bool) {
<-start // Wait for start signal
fmt.Println("Working...")
time.Sleep(time.Second)
done <- true // Signal completion
}
func main() {
start := make(chan bool)
done := make(chan bool)
go worker(start, done)
fmt.Println("Preparing...")
time.Sleep(500 * time.Millisecond)
start <- true // Start the worker
<-done // Wait for completion
fmt.Println("All done")
}
Pipeline Synchronization
Channels naturally synchronize pipeline stages:
func stage1(out chan<- int) {
for i := 0; i < 5; i++ {
out <- i
}
close(out)
}
func stage2(in <-chan int, out chan<- int) {
for num := range in {
out <- num * 2
}
close(out)
}
func main() {
ch1 := make(chan int)
ch2 := make(chan int)
go stage1(ch1)
go stage2(ch1, ch2)
// Synchronized by channel operations
for result := range ch2 {
fmt.Println(result)
}
}
Each stage blocks when the next stage isn’t ready to receive, providing automatic backpressure and synchronization.
Common Patterns
Completion Signal
done := make(chan struct{})
go func() {
defer close(done) // Signal completion by closing
// Do work
}()
<-done
Shutdown Signal
shutdown := make(chan struct{})
go func() {
for {
select {
case <-shutdown:
return
default:
// Do work
}
}
}()
// Trigger shutdown
close(shutdown)
Fan-Out/Fan-In
func fanOut(in <-chan int, workers int) []<-chan int {
channels := make([]<-chan int, workers)
for i := 0; i < workers; i++ {
ch := make(chan int)
channels[i] = ch
go func(out chan<- int) {
for num := range in {
out <- process(num)
}
close(out)
}(ch)
}
return channels
}
Channel synchronization has minimal overhead, typically faster than mutex-based synchronization for coordination tasks.
Benchmarks (approximate)
- Channel send/receive: ~50-100 ns
- Mutex lock/unlock: ~20-40 ns
- Buffered channel (non-blocking): ~30-60 ns
Channels excel when you need both synchronization AND communication.
Best Practices
- Use WaitGroups for multiple goroutines - Cleaner than counting channels
- Use struct for signals - Zero memory overhead
- Buffer signal channels - Prevents sender blocking
- Close channels to signal completion - Better than sending sentinel values
- Prefer channels over shared memory - “Share memory by communicating”
Choosing Synchronization Primitives
| Use Case | Primitive | Why |
|---|
| Wait for one task | Channel | Simple, idiomatic |
| Wait for N tasks | WaitGroup | Designed for this |
| Protect shared state | Mutex | Faster for simple locks |
| Pass data + sync | Channel | Combines both |
| Broadcast signal | close(channel) | Wakes all receivers |