JavaRush /Courses /Go SELF /Channels: `chan T`, `make(chan T)`, send/receive

Channels: `chan T`, `make(chan T)`, send/receive

Go SELF
Level 66, Lesson 0
Available

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
chan int
integers (int)
chan string
strings (string)
chan bool
boolean values (bool)
chan Task
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.

1
Task
Go SELF, level 66, lesson 0
Locked
Square over the radio
Square over the radio
1
Task
Go SELF, level 66, lesson 0
Locked
Two metrics
Two metrics
1
Task
Go SELF, level 66, lesson 0
Locked
Nil line
Nil line
1
Task
Go SELF, level 66, lesson 0
Locked
Squares in a Pipeline
Squares in a Pipeline
Comments
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION