Lesson 02 / 14
Channels
A channel does two jobs at once in the same line: it carries a value from one goroutine to another, and it forces the two goroutines to meet at the same point. A send on an unbuffered channel never completes until a receiver is ready on the other side; if there is never a receiver, the attempt never goes through. Bounding measurement: a channel is not a queue, because the value carried is copied, and any change the sender makes after the send never reaches the receiver.
Published Updated
In this lesson
- Measures that a channel carries a value and synchronizes the sender with the receiver at the same time
- Shows, with select and default and without using a duration, that a send attempt does not go through on an unbuffered channel with no receiver
- Proves, by running it, that a value carried through a channel is copied, and that the sender's later change never reaches the receiver
Contents
The previous lesson sorted twelve goroutines into four ways of stopping, and set up the
first of the five “arrived” lines with a receive on an unbuffered channel: unbuffered <- 1
was written, the goroutine on the other side received it and stopped. In that one line, two
separate jobs actually happened at once — an integer passed from one goroutine to another,
and two goroutines met at the same point at the same time. This lesson separates the two: a
channel does both jobs in the same line, but without measuring them separately, what the
word “synchronization” says and what the word “transfer” says run together.
A channel is the one way a typed value passes from one goroutine to another; it is
written chan int, chan string, and the direction of the <- operator separates a send
from a receive: channel <- value is a send, value := <-channel is a receive. But a
channel is not merely a carrier. It does a second job in the same line: it synchronizes
the sender and the receiver. This second job is what sets a channel apart from a queue or a
mailbox, and it is exactly what this lesson measures.
These two jobs coming together sits at the center of Go’s approach to concurrency: instead of reaching a shared variable through a lock, sending the value directly from one goroutine to another. Locks and shared state are the subject of the Shared State topic; this lesson measures only the mechanism of communication itself — what a channel does, and what it does not.
A channel created with make(chan int) starts out empty and is bound to no goroutine; the
number of parties using it can be zero, one, or many. This lesson measures the simplest
case, a single sender and a single receiver. Cases where more than one goroutine shares the
same channel — many goroutines writing to the same result channel, or many goroutines
reading from the same work channel — are the subject of the Concurrency Patterns topic; the
channel’s own rules are established here first, then carried over there.
A Send with No Receiver Never Goes Through
A send on an unbuffered channel (make(chan T), no capacity) does not complete until a
goroutine is ready to receive that value on the other side. Instead of letting time pass and
assuming “probably no one is there,” it is possible to try, using select together with
a default branch: if the send can go through right then, select chooses that branch; if
it cannot, it falls to default without waiting at all.
// rendezvous.go — the two jobs of an unbuffered channel: carrying a value and blocking a receiverless send
package main
import "fmt"
func tryToSend(ch chan string, v string) bool {
select {
case ch <- v:
return true
default:
return false
}
}
func main() {
channel := make(chan string)
fmt.Println("did the send attempt go through with no receiver at all:", tryToSend(channel, "first attempt"))
delivered := make(chan string)
go func() {
v := <-channel
delivered <- "receiver got: " + v
}()
channel <- "hello"
fmt.Println("send completed, receiver took delivery of the value")
fmt.Println(<-delivered)
}
did the send attempt go through with no receiver at all: false send completed, receiver took delivery of the value receiver got: hello
GC4. — In the first line, no goroutine is yet waiting on channel; tryToSend returns
false on every run in this case, because there is no one waiting — this is not a
timing issue, it is the direct consequence of what an unbuffered channel is defined to be.
In the second part, a goroutine is started, and channel <- "hello" is this time written
directly, not inside a select: this line blocks until a receiver arrives and completes
once the receiver arrives. The difference matters: select+default polls a momentary
state, while a plain channel <- value waits however long it takes and stops only once it
is certain to complete.
It is worth noting that the rest of the program runs in a single goroutine (the main
goroutine): the receiver goroutine prints nothing to the screen at all, it only sends the
value it received back on the delivered channel. Every fmt.Println call is in the main
goroutine, and there is an order between them forced through a channel — this is why the
output is byte-for-byte the same on every run.
This measurement style is the rule for this lesson and the rest of the course: a goroutine never prints to the screen on its own, it only writes onto a channel; printing to the screen is always done in the main goroutine, through the value read from the channel. If two goroutines printed to the screen at the same time, the order the lines came out in would be just as undetermined as the goroutines themselves — a channel here is used not only to carry a value, but also to gather the output’s order into a single goroutine.
The Value Carried Is Copied
When a channel carries a value, it carries a copy of that value, not a reference to it. This is the channels’ counterpart of the copy/share distinction seen in array and slice assignments in the Go Fundamentals course: when a struct value is sent on a channel, the sender changing that variable afterward does not affect the copy the receiver is holding.
// copied.go — a value carried through a channel is copied, the sender's later change never reaches the receiver
package main
import "fmt"
type record struct {
Name string
Value int
}
func main() {
channel := make(chan record)
result := make(chan record)
go func() {
received := <-channel
result <- received
}()
r := record{Name: "counter", Value: 1}
channel <- r
r.Value = 999
fmt.Println("sender's own value now :", r)
fmt.Println("value the receiver took :", <-result)
}
sender's own value now : {counter 999}
value the receiver took : {counter 1}
GC5. — When the line channel <- r completed, r‘s value at that moment was
copied and handed to the receiving goroutine. The instant afterward, when r.Value = 999
is written, the only thing that changes is the sender’s own local variable; the copy the
receiver holds knows nothing of this, and its Value field stays 1. This behavior comes
from record being a value type, not a pointer — had a pointer been passed over the
channel (chan *record), what gets copied would be the pointer itself, not the data it
points to, and in that case the sender’s change would also be visible to the receiver. This
lesson measures only value transfer; what does and does not get shared through copying
pointers was a separate axis in the Arrays and Slices lesson and is not repeated here.
This copying rule carries no exception: whatever the channel’s own type is — an integer, a
string, a struct, even another channel — what is sent is always a copy of that value’s bit
pattern at that moment. A channel never adds a pointer of its own to the value sent; if
sharing is wanted, exactly as with sending *record instead of record, the party that
wants that sharing has to choose its own type for it.
The Copy Is Shallow: The Slice Inside It Is Shared
What gets copied is always only the struct itself, not every field inside it. If a struct carries a slice field, when the channel copies that struct it copies the slice’s header — length, capacity, and a pointer to the backing array — but it does not copy the backing array itself. This is the channel-transfer counterpart of the shallow-copy behavior seen with arrays and slices in the Go Fundamentals course.
// shallowcopy.go — the struct sent over a channel is copied, but its slice field shares the same backing array
package main
import "fmt"
type basket struct {
Label string
Items []int
}
func main() {
channel := make(chan basket)
result := make(chan basket)
go func() {
received := <-channel
received.Items[0] = 999
result <- received
}()
s := basket{Label: "source", Items: []int{1, 2, 3}}
channel <- s
receivedCopy := <-result
fmt.Println("receiver's copy :", receivedCopy)
fmt.Println("sender's own variable:", s)
}
receiver's copy : {source [999 2 3]}
sender's own variable: {source [999 2 3]}
GC6. — received is its own copy of the basket value coming from the channel, and
its Label field is independent of the sender — changing one does not affect the other,
just as with record.Value in the previous measurement. But since Items is a slice, what
gets copied is only the three-word slice header; the backing array the header points to is
the same one the sender has. When received.Items[0] = 999 is written, the memory location
that changes is that one backing array both variables share — this is why s.Items[0] also
shows 999, even though no goroutine ever touched the s variable itself directly. The
reason this line can be safely read in the main goroutine is that the <-result receive, by
the channel operation’s own synchronization promise, also makes the write to the backing
array visible; the channel carries not only the result value, but everything in memory
leading up to it.
This shows the limit of the sentence “a value carried through a channel is copied”: the
copying ends with the struct itself, it does not extend to the data pointed to by a
slice, map, or pointer field inside it. Code that wants to send a struct as a copy fully
independent of the channel has to separately copy the slice field with copy too — the
channel does not do this on its own.
A Channel’s Direction Can Be Part of Its Type
A channel variable can be used in both directions, but a function’s signature can restrict a
channel’s direction: chan<- int is a channel type that allows only sending, <-chan int
only receiving. This restriction is part of the type itself — a bidirectional channel,
created with the same make(chan int), can only be used in the allowed direction inside a
function once it is passed as a directional parameter.
// directional.go — a channel's direction can be part of its type: send-only, receive-only
package main
import "fmt"
func produce(target chan<- int, count int) {
for i := 1; i <= count; i++ {
target <- i
}
close(target)
}
func sum(source <-chan int) int {
total := 0
for v := range source {
total += v
}
return total
}
func main() {
channel := make(chan int)
go produce(channel, 5)
fmt.Println("sum of the five produced values:", sum(channel))
}
sum of the five produced values: 15
GC7. — The channel passed to the produce function, even though it is a bidirectional
variable, appears inside produce only as target chan<- int; trying to write
<-target in produce‘s body does not compile. Likewise, a send attempt like
source <- 1 inside sum does not compile either. This is a writing decision that makes
which side does which job with the channel readable straight from the signature: someone
looking at produce‘s signature knows, without reading the body, that it will only write to
the channel, never read from it. produce itself also closes target — who is responsible
for closing is not this lesson’s subject, it is what the topic’s closing lesson measures.
The direction restriction changes nothing at runtime; chan<- int and <-chan int share
the same underlying channel value, they are not a different channel type. What is gained is
readability at compile time alone: a function’s signature commits to what that function will
do with the channel, and a call that breaks this commitment does not compile. The channel
variable inside main is passed to both produce and sum in the same bidirectional
form; the restriction only applies inside those functions themselves.
Two Measurements Together: Carrying and Meeting
GC8. — When these two measurements are read together, a channel’s real promise comes into view: a channel operation both freezes and carries a value’s state at that moment, and meets the sender and the receiver at the same point. Likening a channel to a queue gives a useful intuition at first glance — values go in one end, come out the other — but a queue never meets the sender with the receiver, it only separates them through a buffer. An unbuffered channel has no such buffer; the meeting takes the buffer’s place. The next two lessons measure how this meeting gets deferred once a buffer is added.
These two jobs do not always carry the same weight. In some uses, only the value itself
matters — the result of a computation, a record’s content. In others, what the value is does
not matter at all, only the information that something happened; in that case a type
carrying no data at all, like chan struct{}, is preferred, and the channel serves only the
synchronization job. This lesson’s delivered channel in rendezvous.go is of the first
kind; the closing signals we will see a few times across the rest of the measurement are of
the second.
A channel also has an unwritten state: a channel variable’s zero value is nil, and a send
or receive on a nil channel never completes, it blocks forever. This behavior is defined,
and it passed by in a single sentence in the context of zero values in the Go Fundamentals
course; this course does not build it up again. But a nil channel’s property of “never
being ready” produces a useful result inside select: a branch containing a nil channel
is never chosen unless that channel is replaced with another value — so to temporarily
“close off” a branch, it is enough to turn that branch’s channel into nil.
// nilchannel.go — every operation on a nil channel blocks forever, always passed over inside select
package main
import "fmt"
func main() {
var nilChannel chan int
ready := make(chan int, 1)
ready <- 7
select {
case v := <-nilChannel:
fmt.Println("received from the nil channel:", v)
case v := <-ready:
fmt.Println("received from the ready channel:", v)
default:
fmt.Println("neither is ready")
}
}
received from the ready channel: 7
nilChannel was never initialized with make, so it stayed at its zero value, nil.
select polled all three branches: the nilChannel branch is ruled out because it can
never be ready; the ready branch is full and gets chosen. Had <-nilChannel been written
directly and called outside a select, the program would have stayed there forever; this
lesson never runs that case, because its result (blocking forever) produces no observable
output. The select statement itself is measured in detail in the next lesson; what matters
here is that a nil channel can be used as a way to make a branch ineffective.
Summary
- A channel does two jobs in the same line: it carries a value and synchronizes the sender with the receiver; the two always come together.
- On an unbuffered channel with no receiver, a send attempt (
select+default) never goes through; this is not a timing coincidence, it is the definition itself. - A send written directly (
channel <- value) blocks until a receiver arrives and completes for certain once the receiver arrives. - A value carried through a channel is copied; the sender’s change after the send never reaches the receiver.
- Every operation on a
nilchannel blocks forever; this behavior is the channels’ counterpart of a definedness established in the Go Fundamentals course.
Next Step
The unbuffered channel measured in this lesson kept the sender waiting until the receiver was ready — the meeting was set up at exactly that moment, every time. Adding capacity to a channel does not remove this requirement, it only defers it. The next lesson measures where the buffer changes blocking behavior and where it does not: a send goes through without waiting at all until capacity fills, and once it fills, it waits again.
The question to ask is this: does the requirement to meet disappear entirely, or does only when it happens change? The answer lies in separating a send to a buffered channel returning immediately from the fact that the value is still waiting to be read.
To keep your progress and take notes, Log in
My notes
Log in to take notes.