Go Channels: Communication, Buffering, and Synchronization Patterns

A channel provides a way for goroutines to communicate by passing messages. This approach follows the principle of sharing memory by communicating, distinct from the shared-memory model often used in other languages. The chan keyword declares a channel, specifying the type of data it carries.

var stream chan float64

At this point stream is nil and unusable until initialised with make.

Creating and Closing Channels

The make function creates a channel. It accepts the channel type and an optional capacity for buffering.

unbuffered := make(chan float64)      // capacity 0
buffered   := make(chan float64, 4)   // capacity 4

A channel should be closed when no more values will be sent. The close built‑in performs this operation.

defer close(buffered)

Sending and Receiving

The arrow operator <- indicates direction: writing into a channel or reading from it.

package main

import "fmt"

func main() {
    pipe := make(chan string, 1)
    pipe <- "hello"
    msg := <-pipe
    fmt.Println(msg)
}

Channels behave like FIFO queues. Only one goroutine can send at a time, and only one can receive.

Unbuffered Channels

When the capacity is zero, no data can be stored in the channel. Sending blocks until another goroutine is ready to receive, and receiving blocks until a sender is ready. If the same goroutine tries to both send and receive synchronously on an unbuffered channel, a deadlock occurs.

// This deadlocks.
func main() {
    ch := make(chan int)
    ch <- 42
    val := <-ch
    fmt.Println(val)
}

To avoid deadlock, perform the operation in separate goroutines.

func main() {
    ch := make(chan int)
    go func() {
        ch <- 42
    }()
    result := <-ch
    fmt.Println(result)
}

The output is 42.

Buffered Channels

Adding a buffer allows a channel to hold a fixed number of values without an immediate receiver. A send blocks only when the buffer is full; a receive blocks only when the buffer is empty.

Basic Example

func main() {
    queue := make(chan int, 2)
    queue <- 10
    queue <- 20
    fmt.Println(<-queue) // 10
    fmt.Println(<-queue) // 20
}

Producer‑Consumer with Buffer

In the following example, a producer fills the buffer, then waits while a consumer slowly empties it.

package main

import (
    "fmt"
    "time"
)

func main() {
    pipe := make(chan int, 3)
    doneWriter := make(chan bool)
    doneReader := make(chan bool)

    defer func() {
        close(pipe)
    }()

    go func() {
        for i := 1; i <= 8; i++ {
            pipe <- i
            fmt.Printf("sent %d\n", i)
        }
        doneWriter <- true
    }()

    go func() {
        for i := 1; i <= 8; i++ {
            time.Sleep(2 * time.Second)
            val := <-pipe
            fmt.Printf("received %d\n", val)
        }
        doneReader <- true
    }()

    <-doneWriter
    <-doneReader
    fmt.Println("all done")
}

Output illustrates the buffer filling, then alternating sends and receives as space becomes available.

Inspecting a Channel

len(ch) returns the current number of buffered elements. cap(ch) returns the total buffer capacity.

func main() {
    ch := make(chan int, 5)
    ch <- 7
    ch <- 14
    fmt.Printf("elements: %d, capacity: %d\n", len(ch), cap(ch))
}
// Output: elements: 2, capacity: 5

Using a Channel as a Mutex

A buffered channel of capacity 1 can serve as a mutual exclusion lock: acquiring the lock sends a token, releasing reads it back.

package main

import (
    "fmt"
    "sync"
)

func main() {
    var wg sync.WaitGroup
    var counter int
    lock := make(chan struct{}, 1)

    wg.Add(2)

    increment := func() {
        lock <- struct{}{}   // acquire
        for i := 0; i < 1000; i++ {
            counter++
        }
        <-lock              // release
        wg.Done()
    }

    decrement := func() {
        lock <- struct{}{}
        for i := 0; i < 500; i++ {
            counter--
        }
        <-lock
        wg.Done()
    }

    go increment()
    go decrement()
    wg.Wait()
    fmt.Println(counter) // 500
}

The final value of counter is always 500, becuase the channel ensures exclsuive access.

Common Pitfalls

  • Writing to or reading from a nil channel blocks forever.
  • Sending to a closed channel causes a panic.
  • Closing a closed channel also panics.
  • Closing a nil channel panics.
  • An unbuffered channel requires a concurrent sender and receiver; otherwise deadlock occurs.
  • A buffered channel blocks on send when full and on receive when empty.

Tags: Go Concurrency channels goroutines

Posted on Thu, 29 Oct 2026 16:33:21 +0000 by mr_badger