漫行记Wandering
Journal 3,993 字 14 分钟 Golang

Golang Channel - Part 1

The Problem Channels Solve

When you use goroutines, they run concurrently. But concurrently brings a question: How do goroutines safely communicate or share data with each other without race conditions?

  • use shared memory with locks (like sync.Mutex)
  • Go’s philosophy: “Do not communicate by sharing memory; instead, share memory by communicating.” -> That’s what channels are for.

What is a Channel in Go?

A channel is like a pipe:

  • One goroutine can send data into the channel.
  • Another goroutine can receive data from the channel.

We can think of channels as a typed conveyor belt

ch := make(chan int) // channel that carries int
  • Channel are typed(you can only send/receive one type.)
  • By default, channels are unbuffered (synchronous) - Sender waits until someone receives, and receiver waits until someone sends.
  • Of course, we can create buffered channels (asynchronous) with a specified capacity, In this case, channel are asynchronous - Sender only waits when the buffer is full, and receiver only waits when the buffer is empty.

What does a Channel Look Like in Go’s Runtime?

Channels are implemented in the runtime package. The central struct is called hchan.

The definition look something like this(simplified, names vary slightly in different Go versions):

type hchan struct {
    qcount   uint      // number of elements in queue
    dataqsiz uint      // buffer capacity
    buf      unsafe.Pointer // circular buffer
    elemsize uint16    // size of each element
    closed   uint32    // is channel closed?
    elemtype *_type    // element type (for reflection)

    sendx    uint      // send index (next slot to write)
    recvx    uint      // recv index (next slot to read)

    recvq    waitq     // list of waiting receivers
    sendq    waitq     // list of waiting senders

    lock     mutex     // protects all fields
}

So at runtime, every chan T is really a pointer to an hchan.

The buffer: circular ring

  • if dataqsiz == 0, this is an unbuffered channel
  • if dataqsiz > 0, this is a buffered channel, buf points to an array of slots(capacity = dataqsiz).
  • sendx and recvx are indices into this array, forming a circular ring buffer.

Wait queues

sendq and recvq are linked lists of goroutines(sudog objects) that are blocked on this channel.

  • If a goroutine does ch <- value and the channel is full or unbuffered with no receiver, runtime enqueues it in the sendq and parks it(puts it to sleep).
  • If a goroutine does <- ch and the channel is empty or unbuffered with no sender, runtime enqueues it in the recvq and parks it.
  • When the opposite side arrives, runtime dequeues on goroutine and wakes it.

Channel Operations

Send(ch <- value)

  1. Lock channel
  2. If there’s a receiver waiting in recvq, do a direct handoff:
    • Copy value directly into receiver’s memory.
    • Wake receiver goroutine.
  3. Else if buffer has space, put value at buf[sendx], increment sendx and qcount.
  4. Else (buffer full or unbuffered with no receiver):
    • Enqueue current goroutine in sendq.
    • Park (sleep) goroutine.
  5. Unlock channel

Receive(value <- ch)

  1. Lock channel
  2. If there’s a sender waiting in sendq, do a direct handoff:
    • Copy value directly from sender’s memory.
    • Wake sender goroutine.
  3. Else if buffer has data, read value from buf[recvx], increment recvx and decrement qcount.
  4. Else if closed, return zero value with ok == false.
  5. Else (buffer empty or unbuffered with no sender):
    • Enqueue current goroutine in recvq.
    • Park (sleep) goroutine.
  6. Unlock channel

Close(ch)

  • closed flag is set automatically under the lock.
  • All waiting receivers are woken up to receive zero values, ok == false.
  • Any further sends panic, “send on closed channel”.
  • Any further receive sees closed, returns zero value immediately.

Nil Channel

A chant T variable that is nil doesn’t point to an hchan at all. So:

  • Send/Receive just block forever.(because runtime sees nil pointer, so no queue to join).
  • Close panics.

So we can set a channel variable to nil inside a select to disable that case.

Select

When we talk about channels, we should also mention select. Select can choose one ready case pesudo-randomly(to avoid starvation). If none are ready:

  • If there’s a default case, it runs that.
  • Otherwise it parks the goroutine, registering interest in all the cases; a matching send/receive will wake it.

Patterns:

  • Timeout: select { case v := <- ch: ...; case <- time.After(d): ... }
  • Cancellation: select { case v := <- ch: ...; case <- ctx.Done(): ... }

Performance

Since channel use lock internally, how about performance? Actually it’s not bad, here are some considerations

  • Low-contention fast path: Most channel ops complete quickly (e.g. buffer has space, buffer has data, or a sender/receiver is waiting). The runtime holds the lock for just a few nanoseconds.

  • Hand-off optimization: In the unbuffered case, if a sender and receiver are both ready, the runtime directly copies the value from sender’s stack to receiver’s stack (no buffer). That’s just a memory copy + wakeup.

  • Cache-friendly ring buffer: For buffered channels, the circular queue is contiguous memory. Writing/reading buf[sendx] and buf[recvx] are cache-friendly operations.

  • Runtime tuned locks: Go’s internal mutex is very lightweight compared to OS locks — it often spins briefly, then parks the goroutine efficient

← 随笔