A channel in Go is a typed conduit for sending values between goroutines. It allows coordinating work by sharing messages instead of sharing memory recklessly.
We already know how to launch goroutines and wait for them to finish. Now we are going to communicate values between them.
In languages like Java or C++, when two threads need to share data, they usually access a shared variable protected by a lock (Mutex). This is error-prone, leading to Race Conditions and complex Deadlocks.
Go encourages this second option with a simple idea:
“Do not communicate by sharing memory; instead, share memory by communicating.”
For this we have channels. We can think of them as typed conduits through which goroutines send values and synchronize.
Declaration and Syntax
A channel is a data type like any other. It is defined with the keyword chan followed by the type of data that will travel through the pipe.
// Declaration (zero value is nil, be careful!)
var nilChan chan int
// Initialization of a channel ready to use
ch := make(chan int)Sending or receiving on a nil channel blocks forever, and closing it causes a panic. This behavior allows enabling or disabling cases in a select, but usually indicates an error outside that pattern.
The Arrow Operator <-
To send and receive data we use the <- operator. The direction of the arrow indicates the data flow.
- Send data to the channel:
ch <- value(Put the value into the channel). - Receive data from the channel:
variable := <-ch(the arrow leaves the channel).
Unbuffered Channels
By default, channels in Go do not have a buffer. This means they have no “space” to store data.
How do they work then? They work through Strict Synchronization.
For the communication to complete, a send and a receive must meet. One of the goroutines may have been blocked before the other arrives.
- If you send (
ch <- 1) and no one is listening, you block. - If you try to receive (
<-ch) and no one is sending, you block.
It’s like passing a baton in a relay race: both runners must coincide in time and space.
package main
import (
"fmt"
"time"
)
func main() {
// Create a channel WITHOUT buffer
messages := make(chan string)
go func() {
fmt.Println("Goroutine: Sending message...")
// This line BLOCKS until someone reads from the channel
messages <- "Ping!"
fmt.Println("Goroutine: Message delivered")
}()
fmt.Println("Main: Waiting a bit...")
time.Sleep(2 * time.Second)
// When this line executes, the goroutine is unblocked
received := <-messages
fmt.Println("Main: Received:", received)
}Unbuffered channels are perfect for synchronizing two goroutines. They guarantee that the exchange has occurred.
Buffered Channels
Sometimes we don’t want the sender to block if the receiver is busy. We want a “queue” or a mailbox where we can leave the message and continue working.
These are Buffered Channels. They are created by passing the capacity to make.
ch := make(chan string, 3) // Capacity for 3 messages
In a buffered channel:
- Sending only blocks if the buffer is full.
- Receiving only blocks if the buffer is empty.
func main() {
// Channel with capacity for 2 integers
queue := make(chan int, 2)
// We can send without anyone listening (because there is space)
queue <- 1
queue <- 2
fmt.Println("We have inserted 2 elements without blocking")
// queue <- 3 // CAREFUL! This would block because the buffer is full (Deadlock in main)
fmt.Println(<-queue) // 1
fmt.Println(<-queue) // 2
}When to use Buffer?
Use them to decouple producers and consumers when the production/consumption speeds vary. But be careful: a buffer is not infinite memory. If the producer is systematically faster than the consumer, the buffer will fill up and you will block again.
Closing Channels and Using range
The producing side can close a channel to indicate that no more data will be sent. It is not mandatory to close all channels: it is only necessary when the receiver needs to detect that end or release a range loop.
close(ch)
This is very useful so the receiver knows when to stop waiting. In fact, we can iterate over a channel with range. The range loop continuously receives values and breaks automatically when the channel is closed.
func producer(c chan int) {
for i := 0; i < 5; i++ {
c <- i
}
close(c) // Important! Close when finished
}
func main() {
numbers := make(chan int)
go producer(numbers)
// Range iterates until the channel is closed
for n := range numbers {
fmt.Printf("Received: %d\n", n)
}
fmt.Println("Channel closed, loop finished.")
}Sending to a closed channel or closing it a second time causes a panic. The responsibility to close it should be on the producer side, and if there are multiple senders, it must be coordinated in a single point.
We can also check if a channel is still open using the form value, ok := <-ch. When it is closed and there are no pending values left, we receive the zero value and ok is false.
Directional Channels
When passing a channel to a function, we can specify whether that function will use the channel only for reading or only for writing. This improves type safety (the compiler warns us if we make a mistake).
chan<- Type: Send-only channel.<-chan Type: Receive-only channel.
// This function can ONLY send
func send(c chan<- string) {
c <- "Hello"
// msg := <-c // Compilation ERROR
}
// This function can ONLY receive
func receive(c <-chan string) {
fmt.Println(<-c)
// c <- "Hello" // Compilation ERROR
}Differences Between Buffered and Unbuffered Channels
| Feature | Unbuffered Channel (make(chan T)) | Buffered Channel (make(chan T, N)) |
|---|---|---|
| Behavior | Each send synchronizes with a receive | Sends proceed while capacity remains |
| Sending | Blocks until someone reads | Blocks only if full |
| Receiving | Blocks until someone writes | Blocks only if empty |
| Primary Use | Strict synchronization | Performance decoupling |