1. Why channels are needed
When you write a program in a single thread, everything is simple: a function computes a value and returns it via return. But as soon as you start work in a goroutine, a practical problem appears: the goroutine lives its own life, and the result of its work needs to be delivered back somehow.
You can try to “put the result into a shared variable” — but then we remember data races. You can protect the variable with a mutex — but that’s a different technique. Or you can do it the Go way: don’t share memory, communicate — send values through a channel.
In Go this is a very typical style: “share memory by communicating”.
Channel as a type: chan T
A channel in Go is a value of the special type chan T that can carry values of type T between goroutines. It’s important to state this explicitly: a channel is not a “universal pipe for anything”. If you created chan int, then only int values can go through it. If you created chan string — only string.
This restriction is actually nice: the compiler protects you from accidental confusion. Channels don’t “friendly-cast types” the way dynamic languages sometimes do. Go honestly says: “the deal was int, so give me int.”
A small table for reference:
| Channel | What you can send |
|---|---|
|
integers (int) |
|
strings (string) |
|
boolean values (bool) |
|
values of type Task (for example, a struct) |
And this really helps you keep the communication protocol in your head: “results fly through this channel, and events fly through that one.”
2. Creating a channel: make(chan T) and nil
With channels there is an important “initialization ritual”. If you declare a channel variable via var, you haven’t created the channel itself yet. You only created a variable that currently holds the zero value for the channel type — that is, nil.
But for the channel to actually exist and to be able to send/receive values, you need to create it using make.
Let’s compare two options (and this is really crucial in real life):
package main
import "fmt"
func main() {
var ch1 chan int // nil channel (channel not created)
ch2 := make(chan int) // channel created
fmt.Println(ch1 == nil) // true
fmt.Println(ch2 == nil) // false
}
The output here will be:
// true
// false
Why is a nil channel dangerous? Because any send or receive operation on a nil channel blocks forever. It won’t “return an error”, it won’t “panic” — it will simply wait forever.
The rule is simple: if you plan to use a channel, create it via make.
4. Send/receive: two basic operations
With channels, at the beginning you need to learn literally two operations:
- send (sending): ch <- v
- receive (receiving): <-ch
And here it’s important not to confuse the “form”. Sending is a statement. Receiving is an expression: you can assign it to a variable or use it right “in place”.
Sending: ch <- v
Sending looks like this:
ch <- 42
This is read as “send 42 into the channel ch”.
Receiving: v := <-ch
Receiving looks like this:
v := <-ch
This is read as “receive a value from the channel ch and put it into v”.
You can also do this (if you want to receive and immediately use it):
fmt.Println(<-ch)
5. Minimal example with a goroutine
Let’s assemble “Hello, channels!”. The idea is: the goroutine will send a number into the channel, main will wait for it and print it.
package main
import "fmt"
func main() {
ch := make(chan int)
go func() {
ch <- 42
}()
v := <-ch
fmt.Println(v) // 42
}
Pay attention to two things.
First: ch := make(chan int) — the channel is created; it is not nil.
Second: v := <-ch can wait. And that’s fine. Here waiting is part of the mechanics: main “stands on receive” until the goroutine sends a value.
6. Blocking as a “handshake”
With an unbuffered channel (and make(chan T) creates exactly that), communication looks like a handshake: the sender and receiver must “meet”.
In this model:
- sending ch <- v blocks until someone starts receiving from ch;
- receiving <-ch blocks until someone sends into ch.
A small diagram:
sequenceDiagram
participant M as main (receiver)
participant G as goroutine (sender)
M->>M: v := <-ch (waiting...)
G->>G: ch <- 42
G-->>M: 42 "moves" through the channel
M->>M: fmt.Println(v)
That’s why channels are often used not only for data, but also for synchronization: “until a value arrives — we don’t move on”. The “done channel” example as the simplest way to wait for a goroutine to finish is one of the most common patterns.
7. Deadlock: when there is nobody to send or receive
Now we’ll see examples that beginners write very early. And that’s normal: this is how the brain tests the boundaries of the universe.
Sending without a receiver
package main
func main() {
ch := make(chan int)
ch <- 1 // deadlock: nobody is receiving
}
What will happen? main will try to send 1, but since the channel is unbuffered, the send needs a receiver “right now”. And there is no receiver. As a result, the program will hang, and the Go runtime will usually terminate it with a message like:
// fatal error: all goroutines are asleep - deadlock!
(The phrase sounds like all goroutines went to sleep and refused to work. In some sense — yes.)
Receiving without a sender
The mirror situation:
package main
func main() {
ch := make(chan int)
_ = <-ch // deadlock: nobody is sending
}
Separately about a nil channel
Another classic: “why is it just standing there?”. Because the channel might be nil.
package main
func main() {
var ch chan int // nil
// ch <- 1 // will hang forever
// _ = <-ch // will hang forever
_ = ch
}
It’s useful to remember this wording: a nil channel means “does not exist”, and any attempt to communicate with it turns into infinite waiting.
8. Mini example: return a Task via a channel
So that the examples are not “about a spherical horse in a vacuum”, let’s start developing a small fragment of a learning app about tasks. Imagine that task creation might not be instant (for example, we simulate access to storage). We want to run creation in a goroutine and return the ready task back via a channel.
Let’s make a simple struct:
package main
import "fmt"
type Task struct {
ID int
Title string
}
func main() {
created := make(chan Task)
go func() {
t := Task{ID: 1, Title: "Read about chan"}
created <- t
}()
task := <-created
fmt.Println(task.Title) // Read about chan
}
So far there’s no database, no network, no real complexity — and that’s great. Our goal right now is not “real production”, but correct mechanics: the goroutine produced a result, sent it, and main received it.
If you want something a bit more “life-like”, you can move creation into a function. The channel is passed as an argument — and that’s fine: channels are easy to pass around; they behave like a “reference to a communication line”.
package main
import "fmt"
type Task struct {
ID int
Title string
}
func createTaskAsync(title string, out chan Task) {
out <- Task{ID: 1, Title: title}
}
func main() {
ch := make(chan Task)
go createTaskAsync("Buy coffee (for the brain)", ch)
t := <-ch
fmt.Println(t.ID, t.Title) // 1 Buy coffee (for the brain)
}
Note: the function createTaskAsync here does not “return a Task via return”. It returns it via the channel out. This is the core idea of channels: the result of work can be delivered not through the call stack, but through a stream of messages.
9. Common mistakes
Mistake #1: forgot make and got “infinite waiting”.
This is the most common story: a programmer honestly declared var ch chan int, then somewhere does ch <- 10, and the program just “freezes”. No errors, no hints — just silence. The reason is that ch is nil, and operations on a nil channel block forever. The cure is a habit: if the channel must work, create it with make(chan T) as close as possible to where it’s needed.
Mistake #2: trying to send to a channel in the same goroutine where you later wait to receive.
Sometimes the code looks logical: “send first, then receive”. But for an unbuffered channel it’s not “first–then”, it’s “met–exchanged”. If in main you write ch <- 1 and there is no receiver yet, the send blocks, and you’ll never reach the receive. In your head it looks like a queue, but in reality it’s a handshake.
Mistake #3: expecting receive to be a “statement”, not an expression.
Beginners sometimes try to write something like “receive a value” on a separate line without using the result, and then are surprised by the strange style. In Go, receive is the expression <-ch; you usually either assign it to a variable (v := <-ch), or use it directly at the point of use (fmt.Println(<-ch)). The habit of thinking “receive gives a value” quickly makes code cleaner.
Mistake #4: channel type mismatch (chan int vs chan int64).
Channels are strictly typed. If you decided somewhere that it will be chan int, and then try to send an int64, or pass a chan int into a function that expects chan int64, the compiler won’t allow it. This is not nitpicking; it’s real protection: mixing “almost the same” numeric types in concurrent code very quickly turns into confusion.
Mistake #5: expecting the “channel to do something on its own” if nobody receives or sends.
A channel is not an active object and not a queue with a separate thread. It doesn’t “deliver” values by itself: there must be goroutines that perform send/receive. When you design an exchange, it’s useful to say the protocol out loud: who sends, who receives, and at what moment. If any of those questions has no answer, there’s a high chance you’re building a deadlock with your own hands.
GO TO FULL VERSION