Day 22: Channels in Go: Communication Between Goroutines

Venkat Annangi
Venkat Annangi
26/04/2025 02:38 8 min read 19 views
# routines # communication #gochannels
Day 22: Channels in Go: Communication Between Goroutines

Day 22: Channels in Go: Communication Between Goroutines

Now that you've learned about goroutines, we need to explore how these concurrent processes can communicate with each other. This is where channels come in - one of Go's most powerful features.

What are Channels?

Channels are communication pipes that allow different goroutines to share data safely. Think of them like a tube where one goroutine can put values in one end, and another goroutine can take those values out the other end.

The key benefit: Channels help goroutines communicate without the risk of data races and without needing complex locking mechanisms.

Creating and Using Channels

Let's start with the basics:

go
 
// Creating a channel that can transport int values ch := make(chan int)

// Sending a value into a channel (the arrow points where the data goes) ch <- 42 
// Receiving a value from a channel (the arrow shows data coming out) value := <-ch

A Simple Example

Let's see a complete example to understand better:

go
 
package main

import (
    "fmt"     "time" )

func main() {
    // Create a channel     messages := make(chan string)

    // Start a goroutine that sends a message     go func() {
        fmt.Println("Goroutine: I'm sending a message...")
        time.Sleep(2 * time.Second) // Simulate work being done         messages <- "Hello from the goroutine!"     }()

    // Main goroutine receives the message     fmt.Println("Main: Waiting for message...")
    msg := <-messages
    fmt.Println("Main: Received:", msg)
}

When you run this program:

  1. We create a channel for string messages
  2. We start a goroutine that will send a message after 2 seconds
  3. The main goroutine waits to receive a message
  4. When the message arrives, the main goroutine prints it

The important thing to notice: the receiving operation <-messages blocks the main goroutine until a message arrives. This creates a natural synchronization between the goroutines.

Buffered Channels

By default, channels are unbuffered, meaning they can only hold one value at a time and senders block until a receiver takes the value. You can create buffered channels that can store multiple values:

go
 
// Create a buffered channel that can hold up to 3 values ch := make(chan int, 3)

// We can send 3 values without blocking ch <- 1 ch <- 2 ch <- 3 
// If we try to send a 4th value without any receives, it will block // ch <- 4  // This would block until someone receives 

Channel Direction

You can specify if a channel is only for sending or only for receiving, which is useful in function parameters:

go
 
// Function that only receives from the channel func receive(ch <-chan int) {
    value := <-ch
    fmt.Println("Received:", value)
}

// Function that only sends to the channel func send(ch chan<- int) {
    ch <- 42 }

Closing Channels

When you're done sending values, you can close a channel:

go
 
close(ch)

Receivers can detect when a channel is closed:

go
 
value, ok := <-ch
if !ok {
    fmt.Println("Channel is closed!")
}

A Practical Example: Worker Pool

Channels are perfect for creating worker pools. Let's build a simple worker pool where multiple goroutines process jobs from a single queue:

go
 
package main

import (
    "fmt"     "time" )

// Job represents work to be done type Job struct {
    ID     int     Value  int     Result int // will store value^2 }

func main() {
    jobs := make(chan Job, 10)      // Channel for sending jobs     results := make(chan Job, 10)   // Channel for receiving results     
    // Start 3 worker goroutines     for w := 1; w <= 3; w++ {
        go worker(w, jobs, results)
    }
    
    // Send 5 jobs     for j := 1; j <= 5; j++ {
        job := Job{ID: j, Value: j}
        fmt.Printf("Sending job #%d\n", job.ID)
        jobs <- job
    }
    close(jobs) // No more jobs to send     
    // Collect all results     for a := 1; a <= 5; a++ {
        result := <-results
        fmt.Printf("Result: Job #%d, Value: %d, Result: %d\n", 
                  result.ID, result.Value, result.Result)
    }
}

// worker processes jobs and sends results func worker(id int, jobs <-chan Job, results chan<- Job) {
    for job := range jobs {
        fmt.Printf("Worker %d processing job #%d\n", id, job.ID)
        time.Sleep(500 * time.Millisecond) // Simulate work         job.Result = job.Value * job.Value // Calculate the square         results <- job
    }
}

This worker pool:

  1. Creates channels for jobs and results
  2. Starts 3 worker goroutines that process jobs
  3. Sends 5 jobs to be processed
  4. Collects and displays the results

Range over Channels

The for range loop provides a clean way to receive values from a channel until it's closed:

go
 
// Send values in a separate goroutine go func() {
    for i := 1; i <= 5; i++ {
        ch <- i
    }
    close(ch)
}()

// Receive values until the channel is closed for num := range ch {
    fmt.Println("Received:", num)
}

Selecting from Multiple Channels

The select statement lets you wait on multiple channel operations:

go
 
select {
case v1 := <-ch1:
    fmt.Println("Received from channel 1:", v1)
case v2 := <-ch2:
    fmt.Println("Received from channel 2:", v2)
case ch3 <- 42:
    fmt.Println("Sent to channel 3")
default:
    fmt.Println("No channel operations were ready")
}

The select statement blocks until one of the cases can proceed. If multiple cases are ready, one is chosen randomly.

Timeouts with Select

A common pattern is using select with a timeout:

go
 
select {
case result := <-resultChan:
    fmt.Println("Received result:", result)
case <-time.After(2 * time.Second):
    fmt.Println("Timed out after 2 seconds")
}

Exercise for Practice

Try this exercise to practice using channels:

go
 
// Create a program that: // 1. Generates numbers 1-10 and sends them to a channel // 2. Has two receiver goroutines that print the numbers they receive // 3. Uses proper channel closing to signal when all numbers are sent 

Key Points to Remember

  1. Channels are typed - they can only transport values of their declared type
  2. Unbuffered channels block senders until a receiver is ready
  3. Buffered channels only block when the buffer is full
  4. Closing a channel is important when no more values will be sent
  5. Receiving from a closed channel returns the zero value of its type
  6. Use for range to receive all values from a channel until it's closed
  7. Use select to work with multiple channels

Channels combined with goroutines provide a powerful, safe way to handle concurrent programming tasks. They help avoid the common concurrency problems found in other languages like race conditions and deadlocks.

Remember: "Do not communicate by sharing memory; instead, share memory by communicating."

Comments