1. Why there are two modes at all: without a buffer and with a buffer
If you look at channels through a beginner’s eyes, you want them to work like a “pipe”: you toss a value in, and it flies off somewhere, not getting in your way. But Go is pragmatic: sometimes you need strict synchronization (“we met and transferred the value”), and sometimes you need a queue to smooth out the speed difference between sender and receiver. That’s why Go has unbuffered and buffered channels — the same tool, but with different behavior.
The syntax differs by just one digit:
- make(chan int) — an unbuffered channel (capacity 0)
- make(chan int, 3) — a buffered channel (capacity 3)
And that “one digit” changes when goroutines will wait for each other.
Unbuffered channel: a “meet-in-the-middle transfer”
An unbuffered channel (make(chan T)) is convenient to imagine as handing an object from one person’s hands to another’s. If you hold out a flash drive and the other person isn’t there — you stand with your hand out. If the other person is ready to take the flash drive but you haven’t offered it yet — they stand with their hand out. Both sides must “meet” in time.
In Go terms, it sounds like this: sending into an unbuffered channel blocks until someone starts receiving, and conversely, receiving blocks until someone sends.
Let’s look at it like a mini-dialogue (sequence diagram):
sequenceDiagram
participant S as Sender goroutine
participant C as Channel (unbuffered)
participant R as Receiver goroutine
S->>C: ch <- 42
Note over S,C: blocked until there is a receive
R->>C: v := <-ch
Note over C,R: value transferred
Note over S: sender unblocked
Mini example: a “handshake” in code
package main
import (
"fmt"
)
func main() {
ch := make(chan int) // unbuffered
go func() {
ch <- 42 // will wait until main reads
}()
v := <-ch
fmt.Println(v) // 42
}
Here main receives the value and only by doing that “frees” the sender. The important feeling: an unbuffered channel is not a queue, it is synchronization.
Why is this useful rather than “inconvenient”?
Because sometimes you truly need: “don’t continue until the other goroutine has confirmed it accepted the value.” For example, you want to guarantee the order of steps: first prepared the data, then handed it off, then continue. An unbuffered channel is a built-in “barrier” without a Mutex and without shared memory.
2. Buffered channel: a queue and a shifted blocking point
A buffered channel (make(chan T, n)) can be imagined as a mailbox that can hold n letters. You can drop a letter in and leave even if the receiver is still asleep. But if the mailbox is stuffed full, you can’t shove in another letter: you’ll have to wait until someone clears the mail.
That is the essence:
- send (ch <- v) blocks when the buffer is full
- receive (<-ch) blocks when the buffer is empty (and nobody is sending right now)
So a buffer does not eliminate blocking; it simply moves the moment when it happens.
Mini example: a “queue of 2 elements”
package main
import "fmt"
func main() {
ch := make(chan string, 2) // buffered, cap=2
ch <- "A" // does not block
ch <- "B" // does not block
fmt.Println(<-ch) // A
fmt.Println(<-ch) // B
}
Here, sends don’t wait for a receiver at all because the buffer is not full yet.
Mini example: how to observe blocking on a full buffer
package main
import "fmt"
func main() {
ch := make(chan int, 1)
ch <- 1 // buffer is full
go func() {
fmt.Println(<-ch) // 1
}()
ch <- 2 // wait until "1" is read
fmt.Println(<-ch) // 2
}
As soon as the value 1 is read, space is freed in the buffer, and sending 2 can complete.
An important practical detail: “buffer size 1” as a way to avoid holding a goroutine hostage
Sometimes a goroutine must send a signal and finish, even if the other side isn’t ready to accept the signal in that same millisecond. In that case, you make a channel with buffer size 1: the sender puts the value in, exits, and does not get stuck waiting for the receiver.
We are not diving into complex “wait for several events” patterns right now; it’s enough to understand the principle: a small buffer is sometimes needed not “for speed,” but to let a goroutine finish without the risk of hanging.
4. Practical nuances: blocking, len/cap, and choosing
Who blocks exactly, and when
If you try to remember this “by feel,” it’s easy to mix up who is waiting for whom. So it’s useful to keep a simple table in your head. There’s no magic here: just two questions — “is there room to put something?” and “is there something to take?”
Let’s compare:
| Channel type | cap(ch) | When ch <- v blocks | When <-ch blocks |
|---|---|---|---|
| Unbuffered | |
Always, until there is a receiver | Always, until there is a sender |
| Buffered (n) | |
When the buffer is full (len == cap) | When the buffer is empty (len == 0) and nobody is sending |
And here is the key idea of the lecture: an unbuffered channel is “sync: we meet”, a buffered channel is “queue: we accumulate and deliver later”.
A small reminder diagram
flowchart TD
A["send: ch <- v"] --> B{"cap(ch) == 0?"}
B -- yes --> C["unbuffered: wait for receive"]
B -- no --> D{"len(ch) < cap(ch)?"}
D -- yes --> E["put into buffer, continue"]
D -- no --> F["buffer full: wait for receive"]
G["receive: <-ch"] --> H{"len(ch) > 0?"}
H -- yes --> I["take from buffer/hand-off"]
H -- no --> J{"cap(ch) == 0?"}
J -- yes --> K["unbuffered: wait for send"]
J -- no --> L["buffered: wait for send (buffer empty)"]
len(ch) and cap(ch): diagnostics, not a “control panel”
When you first see len(ch) and cap(ch), it’s tempting to think: “Oh! So we can write if len(ch) > 0 { ... } and make a non-blocking read.” That’s a very human thought — and a very dangerous one, because with concurrency, “between if and the action” enough time may pass for the world to change.
Let’s start with the correct understanding:
- cap(ch) — the capacity of the channel buffer (0 for unbuffered)
- len(ch) — how many values are in the buffer right now
Mini example: buffer diagnostics
package main
import "fmt"
func main() {
ch := make(chan int, 3)
fmt.Println(len(ch), cap(ch)) // 0 3
ch <- 10
ch <- 20
fmt.Println(len(ch), cap(ch)) // 2 3
}
This is convenient for debugging and for explaining what’s going on. But it is not “reliable synchronization.”
Why? Because concurrency means multiple goroutines can change state almost simultaneously. If you checked len(ch) and then decided to do something, another goroutine could already have read/written a value, and your conclusion is stale.
In real Go code, len(ch) more often plays the role of a “thermometer”: you can check the temperature, but you can’t control the weather.
How to choose: unbuffered or buffered
Early on, you really want a simple rule: “A buffered channel is faster, so we’ll use buffered.” But a buffer is not free performance. A buffer is a change in the protocol.
If you set a large buffer “so it definitely won’t block,” you allow the system to accumulate work. And you can accumulate infinitely… until you run out of memory or the program turns into a warehouse of unfinished tasks.
A good way to think about the choice is:
An unbuffered channel is useful when you want direct synchronization and “natural backpressure”: the sender can’t run ahead if the receiver can’t keep up. This often makes system behavior more predictable.
A buffered channel is useful when you have a clear reason to “decouple in time” the sender and the receiver. For example, the sender does work in bursts (spikes) and the receiver processes steadily, and a small buffer smooths out peaks. Or you want a short-lived goroutine to be able to send a result and finish without depending on the receiver’s exact timing (a typical buffer size 1 as a “mailbox for one letter”).
The important part is that the question “what buffer size” is design: how many elements are we willing to keep waiting? Sometimes the answer is 1, sometimes 10, sometimes 0. But almost never is the answer “a million so we don’t have to think.”
5. Example: collecting results of task processing
To avoid staying at the level of abstract “mailboxes,” let’s embed channels into a small piece of our training application (roughly a task manager). Imagine we have a list of tasks, and we want to compute a “weight” for each task in parallel (for example, the length of the title), and then collect the results in main.
We are not using channel closing and for range ch yet — that is a separate topic. Here we’ll collect results with a fixed number of reads: exactly as many as there are tasks.
Step 1: data model and result
package main
type Task struct {
ID int
Title string
}
type TaskStat struct {
ID int
TitleSize int
}
Step 2: handler for a single task
package main
func calcStat(t Task) TaskStat {
return TaskStat{
ID: t.ID,
TitleSize: len(t.Title),
}
}
Step 3: unbuffered channel — strict synchronization
package main
import "fmt"
func main() {
tasks := []Task{{1, "Buy milk"}, {2, "Write Go"}, {3, "Sleep"}}
ch := make(chan TaskStat) // unbuffered
for _, t := range tasks {
go func(task Task) {
ch <- calcStat(task) // wait until main reads
}(t)
}
for i := 0; i < len(tasks); i++ {
stat := <-ch
fmt.Println(stat.ID, stat.TitleSize)
}
}
What matters to feel: even if some goroutine computes its result instantly, it cannot “dump” it into the channel until main starts reading. That means main sets the rhythm here, and sometimes that’s actually great.
Step 4: buffered channel — a small queue of results
Now let’s make the channel buffered. For example, for 2 results:
package main
import "fmt"
func main() {
tasks := []Task{{1, "Buy milk"}, {2, "Write Go"}, {3, "Sleep"}}
ch := make(chan TaskStat, 2) // buffered, cap=2
for _, t := range tasks {
go func(task Task) {
ch <- calcStat(task) // may not wait until the buffer is full
}(t)
}
for i := 0; i < len(tasks); i++ {
stat := <-ch
fmt.Println(stat.ID, stat.TitleSize)
}
}
How did the behavior change? Now two goroutines can “fire quickly”: put results into the buffer and finish, even if main hasn’t read yet. But the third send can block if the buffer is already full (because cap=2, but there are 3 tasks).
An important observation about order
The order in which results arrive in main with this approach is not guaranteed to be “by ID.” A channel guarantees FIFO in terms of sends, but “who sent earlier” depends on the scheduler and goroutine execution speed. That’s normal: concurrency almost always means you either shouldn’t assume order, or you should enforce it separately (for example, by sorting after collection — but that’s data logic, not channel logic).
6. Typical mistakes when choosing buffered/unbuffered
Mistake #1: “A buffered channel never blocks.”
This is a popular illusion: it feels like if there is a buffer, the sender can always “drop the load and leave.” In reality, the buffer is finite. When len(ch) == cap(ch), sending blocks just like with an unbuffered channel — just at a different moment. Because of this mistake, people set cap=1, send two values, and then wonder why the program hangs.
Mistake #2: “Let’s make a huge buffer so we don’t have to think about blocking.”
A big buffer often turns into a warehouse of problems: it hides speed mismatches, accumulates values, increases memory usage, and makes latency less predictable. Sometimes the system looks “stable” until real load arrives — and then memory goes through the roof. The buffer should be a conscious decision: exactly how much are we willing to store “in the waiting queue.”
Mistake #3: building synchronization logic on len(ch).
len(ch) shows the state “right now,” but the concurrent world changes instantly. Code like if len(ch) > 0 { v := <-ch } does not guarantee a non-blocking read: between the check and the read, another goroutine could already have consumed the value. As a result, you can still hang — but now with the feeling that you were “tricked.”
Mistake #4: choosing unbuffered where the sender must be able to finish quickly.
Sometimes you have a short goroutine that must send one result and exit. If the channel is unbuffered, it can hang on ch <- v if the receiver is delayed. In such cases, a buffer of 1 often makes behavior more robust: the goroutine puts the value and finishes without becoming a hostage to timing.
Mistake #5: “A buffer is a speedup,” without understanding “what problem we are solving.”
A buffer does not automatically make your code faster. It changes where goroutines wait for each other. Sometimes it increases throughput; sometimes it only makes debugging harder. If you cannot explain in words why you need a buffer, start with unbuffered: it’s usually simpler, more honest, and it disciplines the exchange protocol.
GO TO FULL VERSION