Channels
In Go, a channel is a typed conduit for sending and receiving values between goroutines. Channels are a core concurrency primitive managed by the Go runtime.
Channels:
- Remove the need for explicit locks in many cases
 - Ensure safe data sharing between goroutines without race conditions
 - Are type-safe
 
Creating Channels
We create channels with the make function:
ch := make(chan int)       // Unbuffered channel
chBuf := make(chan string, 5) // Buffered channel (capacity = 5)
The type of a channel is defined as chan T where T is the type of data it carries.
Why Use Channels?
- To coordinate work between goroutines.
 - To pass data without manual synchronization.
 - To build concurrent pipelines.
 - To implement producer-consumer or fan-out/fan-in patterns.
 
Go’s philosophy: Don’t communicate by sharing memory; share memory by communicating.
Don’t communicate by sharing memory; share memory by communicating.
Basic Operations
Channels support three main operations:
- 
Send
ch <- valueSends
valueintoch. Blocks until a receiver is ready (for unbuffered channels). - 
Receive
val := <-chReceives a value from
ch. Blocks until there’s a value to receive. - 
Close
close(ch)Signals that no more values will be sent on
ch. 
Buffered vs Unbuffered Channels
Unbuffered Channels
- Capacity = 0.
 - Every send must wait for a corresponding receive, and vice versa.
 - Used for synchronization and direct handoff between goroutines.
 
Example: Synchronization:
done := make(chan bool)
go func() {
    fmt.Println("Task started")
    time.Sleep(time.Second)
    fmt.Println("Task finished")
    done <- true
}()
<-done // Waits until goroutine sends
Buffered Channels
- Have a fixed capacity.
 - Send only blocks when the buffer is full.
 - Receive only blocks when the buffer is empty.
 - Useful for decoupling sender and receiver speeds.
 
Why Buffered Channels are Important
- Decoupling: The sender and receiver don’t have to be in perfect sync — the buffer allows temporary storage.
 - Throughput: In high-throughput systems, buffering can smooth out bursts of work.
 - Producer-Consumer: Allows the producer to work ahead without blocking if the consumer is slower.
 - Rate Limiting: Control how many items are processed at once.
 
Example: Producer-Consumer with Buffer:
func producer(ch chan int) {
    for i := 1; i <= 5; i++ {
        fmt.Printf("Producing %d\n", i)
        ch <- i // Blocks only if buffer is full
        time.Sleep(200 * time.Millisecond)
    }
    close(ch)
}
func consumer(ch chan int) {
    for v := range ch {
        fmt.Printf("Consuming %d\n", v)
        time.Sleep(500 * time.Millisecond)
    }
}
func main() {
    ch := make(chan int, 2) // Capacity 2
    go producer(ch)
    consumer(ch)
}
Behavior:
- Producer can produce up to 2 values before blocking.
 - Consumer processes at its own pace.
 - The buffer smooths the speed difference between them.
 
Directional Channels
We can make channels send-only or receive-only in function parameters to enforce correct usage:
func sendData(ch chan<- int) { ch <- 42 }   // Send-only
func receiveData(ch <-chan int) { fmt.Println(<-ch) } // Receive-only
This helps prevent accidental misuse of a channel in large systems.
select Statement — Multiplexing Channels
select allows us to wait on multiple channel operations simultaneously:
- Picks the first case where a channel is ready.
 - If multiple are ready, chooses one randomly.
 - If none are ready, blocks unless a 
defaultcase exists. 
Example: Multiplexing:
select {
case msg := <-ch1:
    fmt.Println("From ch1:", msg)
case msg := <-ch2:
    fmt.Println("From ch2:", msg)
default:
    fmt.Println("No data yet")
}
Timeout with select
select {
case msg := <-ch:
    fmt.Println("Received:", msg)
case <-time.After(2 * time.Second):
    fmt.Println("Timeout!")
}
Closing Channels & Cleanup
- Only the sender should close a channel.
 - Closing a channel signals no more data will be sent.
 - Receivers can still read any buffered data before closure.
 - Reading from a closed channel returns the zero value and 
ok = false. 
Example:
val, ok := <-ch
if !ok {
    fmt.Println("Channel closed")
}
Best Practice:
func producer(ch chan<- int) {
    defer close(ch) // Always close when done
    for i := 0; i < 5; i++ {
        ch <- i
    }
}
Common Patterns
Worker Pool
func worker(id int, jobs <-chan int, results chan<- int, wg *sync.WaitGroup) {
    defer wg.Done()
    for job := range jobs {
        results <- job * 2
    }
}
func main() {
    jobs := make(chan int, 5)
    results := make(chan int, 5)
    var wg sync.WaitGroup
    for w := 1; w <= 3; w++ {
        wg.Add(1)
        go worker(w, jobs, results, &wg)
    }
    for j := 1; j <= 5; j++ {
        jobs <- j
    }
    close(jobs)
    wg.Wait()
    close(results)
    for r := range results {
        fmt.Println(r)
    }
}
Fan-out / Fan-in
- Fan-out: Multiple goroutines consume from the same channel
 - Fan-in: Multiple goroutines send into the same channel to aggregate results
 
Pipelines
Chaining goroutines:
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
}
Pitfalls
- Deadlocks if sender/receiver counts don’t match
 - Closing a channel twice causes panic
 - Buffered channels can fill up and block senders unexpectedly
 - Zero value reads from closed channels can mask logic errors
 
Best Practices
- Close channels only from the sending side
 - Use defer close() in producer goroutines
 - Use directional channels for clarity
 - Use select for multiple channels or timeouts
 - Avoid very large buffer sizes unless justified — large buffers can hide backpressure problems