Skip to main content
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

PurposePatternExample
SynchronizationSignal completiondone <- true
CommunicationPass dataresults <- data
BothPass result and signalresults <- 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
}

Performance Considerations

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

  1. Use WaitGroups for multiple goroutines - Cleaner than counting channels
  2. Use struct for signals - Zero memory overhead
  3. Buffer signal channels - Prevents sender blocking
  4. Close channels to signal completion - Better than sending sentinel values
  5. Prefer channels over shared memory - “Share memory by communicating”

Choosing Synchronization Primitives

Use CasePrimitiveWhy
Wait for one taskChannelSimple, idiomatic
Wait for N tasksWaitGroupDesigned for this
Protect shared stateMutexFaster for simple locks
Pass data + syncChannelCombines both
Broadcast signalclose(channel)Wakes all receivers

Build docs developers (and LLMs) love