Channels are the primary way goroutines communicate with each other. They enable safe data transfer between concurrent operations, following Go's philosophy: "Don't communicate by sharing memory; share memory by communicating."
Understanding Channels
Channels are typed conduits through which you can send and receive values.
Creating Channels
// Unbuffered channel
ch := make(chan int)
// Buffered channel with capacity 3
ch := make(chan int, 3)Sending and Receiving
ch := make(chan string)
// Send (blocks until someone receives)
go func() {
ch <- "hello"
}()
// Receive (blocks until someone sends)
msg := <-ch
fmt.Println(msg)The <- operator is used for both sending and receiving.
Buffered vs Unbuffered Channels
Unbuffered Channels
Unbuffered channels block on both send and receive:
ch := make(chan int)
go func() {
ch <- 42 // Blocks until received
}()
val := <-ch // Blocks until sentProvides synchronization guarantee.
Buffered Channels
Buffered channels don't block until buffer is full:
ch := make(chan int, 2)
ch <- 1 // Doesn't block
ch <- 2 // Doesn't block
// ch <- 3 // Would block, buffer fullUseful for producer-consumer patterns.
Choosing Between Them
- Unbuffered: Strong synchronization, handoff
- Buffered: Decoupling, throughput
Channel Directions
You can specify channel direction in function signatures.
Send-Only Channels
func sender(ch chan<- int) {
ch <- 42
// val := <-ch // Compile error!
}Receive-Only Channels
func receiver(ch <-chan int) {
val := <-ch
fmt.Println(val)
// ch <- 42 // Compile error!
}Bidirectional Channels
func inout(ch chan int) {
ch <- 42
val := <-ch
}Directional types prevent accidental misuse.
Closing Channels
Close a channel to indicate no more values will be sent.
Basic Closing
ch := make(chan int, 2)
ch <- 1
ch <- 2
close(ch)
// Receive remaining values
for val := range ch {
fmt.Println(val)
}Detecting Closed Channel
val, ok := <-ch
if !ok {
fmt.Println("Channel closed")
}Closing Best Practices
// Only sender should close
func producer(ch chan<- int) {
for i := 0; i < 5; i++ {
ch <- i
}
close(ch) // Signal completion
}
func consumer(ch <-chan int) {
for val := range ch {
fmt.Println(val)
}
}Never close from receiver side.
Range Over Channels
Iterate over channels until closed.
ch := make(chan int)
go func() {
for i := 0; i < 5; i++ {
ch <- i
}
close(ch)
}()
for val := range ch {
fmt.Println(val)
}Select Statement
Select waits on multiple channel operations.
Basic Select
ch1 := make(chan string)
ch2 := make(chan string)
go func() {
time.Sleep(1 * time.Second)
ch1 <- "one"
}()
go func() {
time.Sleep(2 * time.Second)
ch2 <- "two"
}()
for i := 0; i < 2; i++ {
select {
case msg1 := <-ch1:
fmt.Println("Received", msg1)
case msg2 := <-ch2:
fmt.Println("Received", msg2)
}
}Default Case
Default runs when no channel is ready:
select {
case msg := <-ch:
fmt.Println(msg)
default:
fmt.Println("No message ready")
}Timeout with Select
select {
case res := <-result:
fmt.Println(res)
case <-time.After(2 * time.Second):
fmt.Println("Timeout")
}Channel Patterns
Generator Pattern
func generator(nums ...int) <-chan int {
out := make(chan int)
go func() {
for _, n := range nums {
out <- n
}
close(out)
}()
return out
}
ch := generator(1, 2, 3)
for n := range ch {
fmt.Println(n)
}Fan-Out
Multiple goroutines reading from same channel:
func worker(id int, jobs <-chan int, results chan<- int) {
for j := range jobs {
fmt.Printf("Worker %d job %d\n", id, j)
time.Sleep(time.Second)
results <- j * 2
}
}
func main() {
jobs := make(chan int, 100)
results := make(chan int, 100)
// Start 3 workers
for w := 1; w <= 3; w++ {
go worker(w, jobs, results)
}
// Send 5 jobs
for j := 1; j <= 5; j++ {
jobs <- j
}
close(jobs)
// Collect results
for a := 1; a <= 5; a++ {
<-results
}
}Fan-In
Multiple goroutines writing to same channel:
func fanIn(input1, input2 <-chan string) <-chan string {
out := make(chan string)
go func() {
for {
select {
case s := <-input1:
out <- s
case s := <-input2:
out <- s
}
}
}()
return out
}Pipeline
Connect stages with channels:
func gen(nums ...int) <-chan int {
out := make(chan int)
go func() {
for _, n := range nums {
out <- n
}
close(out)
}()
return out
}
func sq(in <-chan int) <-chan int {
out := make(chan int)
go func() {
for n := range in {
out <- n * n
}
close(out)
}()
return out
}
func main() {
// Set up pipeline
c := gen(2, 3)
out := sq(c)
// Consume output
fmt.Println(<-out) // 4
fmt.Println(<-out) // 9
}Quit Channel
Signal goroutines to exit:
func worker quit chan struct{}) {
for {
select {
case <-quit:
fmt.Println("Worker quitting")
return
default:
fmt.Println("Working...")
time.Sleep(500 * time.Millisecond)
}
}
}
func main() {
quit := make(chan struct{})
go worker(quit)
time.Sleep(2 * time.Second)
close(quit) // Signal worker to stop
time.Sleep(time.Second)
}Context and Channels
Context integrates with channels for cancellation.
With Context
import "context"
func worker(ctx context.Context, ch chan<- int) {
for {
select {
case <-ctx.Done():
fmt.Println("Worker cancelled")
return
case ch <- time.Now().Unix():
}
}
}
func main() {
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
ch := make(chan int)
go worker(ctx, ch)
for val := range ch {
fmt.Println(val)
}
}Batching
Batch channel items:
func batch(in <-chan int, size int) <-chan []int {
out := make(chan []int)
go func() {
defer close(out)
batch := make([]int, 0, size)
for val := range in {
batch = append(batch, val)
if len(batch) == size {
out <- batch
batch = make([]int, 0, size)
}
}
if len(batch) > 0 {
out <- batch
}
}()
return out
}Semaphores with Channels
Limit concurrency:
func process(items []string, concurrency int) {
sem := make(chan struct{}, concurrency)
var wg sync.WaitGroup
for _, item := range items {
wg.Add(1)
sem <- struct{}{} // Acquire
go func(i string) {
defer wg.Done()
defer func() { <-sem }() // Release
// Process item
fmt.Println(i)
time.Sleep(time.Second)
}(item)
}
wg.Wait()
}Common Pitfalls
Forgetting to Close
// WRONG: Deadlock
ch := make(chan int)
go func() {
for val := range ch { // Blocks forever
fmt.Println(val)
}
}()
ch <- 1
// RIGHT: Close channel
go func() {
for val := range ch {
fmt.Println(val)
}
}()
ch <- 1
close(ch)Closing from Receiver
// WRONG: Panic if closed multiple times
func consumer(ch <-chan int) {
for val := range ch {
if val == 0 {
close(ch) // Panic!
}
}
}
// RIGHT: Sender closes
func producer(ch chan<- int) {
for i := 0; i < 5; i++ {
ch <- i
}
close(ch)
}Sending to Closed Channel
ch := make(chan int, 1)
close(ch)
ch <- 1 // Panic!Best Practices
- 1Close channels from sender side only
- 2Use range for receiving until closed
- 3Check ok value when receiving
- 4Use select for multiple operations
- 5Provide quit channels for cancellation
- 6Keep channel ownership clear
- 7Use directional types in function signatures
Performance Tips
Channels have overhead:
// For simple signaling, prefer sync.Cond
// For data transfer, channels are fine
// Buffered channels can reduce contention
ch := make(chan int, 100)
// But don't make buffers too large
ch := make(chan int, 1000000) // Wastes memoryDebugging Channels
Use goroutine leak detection:
func monitor() {
ticker := time.NewTicker(time.Second)
for range ticker.C {
fmt.Printf("Goroutines: %d\n", runtime.NumGoroutine())
}
}Conclusion
Channels are Go's elegant solution to concurrent communication. They enable safe data transfer between goroutines while providing powerful composition patterns through select and range.
Master channels, and you'll write Go programs that are both concurrent and clear. The channel-based approach to concurrency avoids the pitfalls of shared memory while maintaining efficiency.
Practice with different channel patterns, understand when to use buffered vs unbuffered channels, and leverage select for complex synchronization. Channels are one of Go's most distinctive features and a key reason why concurrent programming in Go feels so natural.