goSeries · 1/52026년 3월 19일9 min read
Golang Concurrency

Golang Concurrency Part 2 - Mastering Channels

Covers Go channels from basic behavior to the buffered/unbuffered difference, direction restrictions, close rules, and the producer-consumer pattern

FFrank Advenoh
#golang#concurrency#channel

A channel is the means of communication for exchanging data between goroutines. It is the core mechanism that realizes Go's concurrency philosophy: "do not communicate by sharing memory; instead, share memory by communicating."

In this part, we fully cover channels — from basic behavior to the buffered/unbuffered difference, direction restrictions, and close rules.

1. Channel Concept and Creation

cover

A channel is created with the make function. Think of it as a typed pipe.

ch := make(chan int)       // unbuffered channel (int type)
ch := make(chan string, 5) // buffered channel (string type, buffer size 5)

2. Send / Receive Behavior

To send a value to a channel, use the <- operator.

ch <- 42    // send: send 42 to the channel
val := <-ch // receive: receive a value from the channel
func TestChannelSendReceive(t *testing.T) {
    ch := make(chan int)

    go func() {
        ch <- 42 // send
    }()

    value := <-ch // receive (blocks until sent)
    assert.Equal(t, 42, value)
}

Channels support various types. Passing a struct lets you send a result and an error together.

func TestChannelStructType(t *testing.T) {
    type Result struct {
        Value int
        Err   error
    }

    ch := make(chan Result)

    go func() {
        ch <- Result{Value: 100, Err: nil}
    }()

    result := <-ch
    assert.Equal(t, 100, result.Value)
    assert.NoError(t, result.Err)
}

3. Understanding Blocking Behavior

The most important characteristic of a channel is blocking.

  • Unbuffered channel: send and receive must be ready at the same time to proceed
  • Buffered channel: send blocks when the buffer is full; receive blocks when the buffer is empty
graph LR
    subgraph "Unbuffered Channel"
        GA[Goroutine A<br/>send - blocking] -- "handshake<br/>(proceed together)" --> GB[Goroutine B<br/>receive - blocking]
    end
graph LR
    subgraph "Buffered Channel (size 3)"
        GA2[Goroutine A<br/>3 sends OK<br/>4th send BLOCK!] --> BUF[Buffer<br/>size: 3] --> GB2[Goroutine B<br/>receive]
    end

4. Unbuffered vs Buffered Channel

Unbuffered Channel

ch := make(chan int) // buffer size 0
  • when you send, it blocks until the receiver receives
  • synchronous communication: both sides must be ready to proceed
func TestUnbufferedChannel(t *testing.T) {
    ch := make(chan int)

    go func() {
        ch <- 42 // blocks here until the receiver is ready
    }()

    time.Sleep(100 * time.Millisecond) // the sender is already blocking
    value := <-ch                       // the moment we receive, the sender proceeds too
    assert.Equal(t, 42, value)
}

Buffered Channel

ch := make(chan int, 3) // buffer size 3
  • if there's free space in the buffer, send completes immediately
  • send blocks when the buffer is full
  • asynchronous communication
func TestBufferedChannel(t *testing.T) {
    ch := make(chan int, 3)

    // can send up to 3 even without a receiver
    ch <- 1
    ch <- 2
    ch <- 3
    // ch <- 4 → blocking (buffer full)

    assert.Equal(t, 1, <-ch)
    assert.Equal(t, 2, <-ch)
    assert.Equal(t, 3, <-ch)
}

cap and len

func TestBufferedChannelCapLen(t *testing.T) {
    ch := make(chan string, 5)

    assert.Equal(t, 5, cap(ch)) // buffer capacity
    assert.Equal(t, 0, len(ch)) // number of values currently waiting

    ch <- "a"
    ch <- "b"

    assert.Equal(t, 5, cap(ch))
    assert.Equal(t, 2, len(ch))
}

When to Use Which?

SituationChoice
synchronization (handshake) between goroutines neededUnbuffered
signaling (done, quit)Unbuffered
buffering the difference between production and consumption speedsBuffered
Producer/Consumer patternBuffered
high-throughput data transfer where performance mattersBuffered

5. Channel Direction Restrictions

You can restrict a channel's direction in a function parameter.

chan<- int  // send-only (can only send)
<-chan int  // receive-only (can only receive)

This lets you prevent incorrect use of a channel at compile time.

// producer: send-only channel
func produce(ch chan<- int, values []int) {
    for _, v := range values {
        ch <- v
    }
    close(ch)
}

// consumer: receive-only channel
func consume(ch <-chan int) []int {
    var results []int
    for v := range ch {
        results = append(results, v)
    }
    return results
}

func TestChannelDirection(t *testing.T) {
    ch := make(chan int, 5)
    go produce(ch, []int{1, 2, 3, 4, 5})
    results := consume(ch)
    assert.Equal(t, []int{1, 2, 3, 4, 5}, results)
}

A bidirectional channel is implicitly converted to send-only or receive-only. Conversion in the opposite direction causes a compile error.

var sendOnly chan<- int = ch  // OK: bidirectional → send-only
var recvOnly <-chan int = ch  // OK: bidirectional → receive-only

6. Channel Close

The Meaning of close

close(ch) is a declaration that "no more values will be sent to this channel."

Receiving from a Closed Channel

func TestReceiveFromClosedChannel(t *testing.T) {
    ch := make(chan int, 1)
    ch <- 42
    close(ch)

    // if there's a value in the buffer, it returns normally
    val, ok := <-ch
    assert.Equal(t, 42, val)
    assert.True(t, ok)       // a valid value

    // empty buffer and closed channel → zero value + false
    val, ok = <-ch
    assert.Equal(t, 0, val)  // int's zero value
    assert.False(t, ok)      // closed indicator
}

Close Rules

RuleDescription
Sender closesthe sender, not the receiver, is responsible for closing
No double closeclosing an already-closed channel causes a panic
No send on a closed channelsending a value to a closed channel causes a panic
Receive from a closed channel is OKreturns zero value + false

Close Responsibility Pattern

// pattern: the sender creates the channel, sends, and closes
func generator() <-chan int {
    ch := make(chan int)
    go func() {
        defer close(ch)   // the sender is responsible for closing
        for i := range 5 {
            ch <- i
        }
    }()
    return ch // return as receive-only
}

7. Range over Channel

Using range, values are automatically received until the channel is closed.

func TestRangeOverChannel(t *testing.T) {
    ch := make(chan int, 5)

    go func() {
        for i := 1; i <= 5; i++ {
            ch <- i
        }
        close(ch) // close is required for range to terminate!
    }()

    var results []int
    for v := range ch {
        results = append(results, v)
    }

    assert.Equal(t, []int{1, 2, 3, 4, 5}, results)
}

For range ch to terminate, close(ch) must be called. Without close, range blocks forever.

Signaling Channel

To send only a completion signal rather than data, use chan struct{}. struct{} takes up no memory.

func TestChannelSignaling(t *testing.T) {
    done := make(chan struct{})

    go func() {
        // do work...
        close(done) // completion signal (delivered to all receivers)
    }()

    <-done // wait for completion
}

close(done) sends a signal to all receivers simultaneously. This is the difference from simply doing done <- struct{}{}.

If you're confused about the difference between struct{} and struct{}{}, see the FAQ.

8. Practice: Producer / Consumer Pattern

func TestProducerConsumer(t *testing.T) {
    ch := make(chan int, 10)
    var results []int

    // Producer: generate squares
    go func() {
        for i := 1; i <= 10; i++ {
            ch <- i * i
        }
        close(ch)
    }()

    // Consumer: collect results
    for val := range ch {
        results = append(results, val)
    }

    expected := []int{1, 4, 9, 16, 25, 36, 49, 64, 81, 100}
    assert.Equal(t, expected, results)
}

Multiple Producers

func TestMultipleProducers(t *testing.T) {
    ch := make(chan int, 20)
    var wg sync.WaitGroup

    // 3 producers
    for p := range 3 {
        wg.Add(1)
        go func() {
            defer wg.Done()
            for i := range 5 {
                ch <- p*100 + i
            }
        }()
    }

    // close the channel once all producers finish
    go func() {
        wg.Wait()
        close(ch)
    }()

    var results []int
    for val := range ch {
        results = append(results, val)
    }

    assert.Len(t, results, 15) // 3 x 5 = 15 items
}

9. Summary

ConceptCore
Channela type-safe means of communication between goroutines
Unbufferedsynchronous handshake, requires simultaneous send/receive readiness
Bufferedasynchronous queue, can send up to the buffer size
Direction restrictionchan<- (send-only), <-chan (receive-only)
Closethe sender is responsible; a closed channel returns the zero value
Rangeautomatically receives until the channel is closed
chan struct{}for signaling (0 memory)

In the next part, we'll cover advanced channel patterns such as the select statement, which handles multiple channels simultaneously, and fan-in/fan-out.

FAQ

Q. What's the difference between struct{} and struct{}{}?

struct{} is a type, and struct{}{} is a value (instance).

It's easier to understand by analogy with a regular struct:

type Person struct {
    Name string
    Age  int
}

p := Person{Name: "Frank", Age: 30}
//   ^^^^^^ type
//         ^^^^^^^^^^^^^^^^^^^^^^^^ value

Likewise, an empty struct has the same structure:

v := struct{}{}
//   ^^^^^^^^ type: struct{} (a struct with no fields)
//           ^^ value: {} (empty literal)
ExpressionMeaningAnalogy
intint typeblueprint
42int valuephysical object
struct{}empty struct typeblueprint (no fields)
struct{}{}empty struct valuephysical object (no content)

When used with a channel:

// create a channel — specify the type
done := make(chan struct{})  // a channel of type struct{}

// send a value — send a struct{}{} instance
done <- struct{}{}

// close — deliver a "closed" signal to all receivers instead of a value
close(done)

Since struct{} takes up 0 bytes of memory, it's the most efficient choice when you want to only deliver a signal without data.

References

관련 글