Introduction
One of the most attractive features of the Go language is its native support for concurrent programming. It has strong support for parallelism, concurrent programming, and network communication, allowing for more efficient use of multi-core processors.
Unlike other languages that achieve concurrency through shared memory, Go achieves concurrency by using channels for communication between different goroutines.
Goroutines are the units of concurrency in Go and can be thought of as lightweight threads. They consume fewer resources when switching between them compared to traditional threads.
Channels and goroutines together constitute the concurrency primitives in Go. In this section, we will learn about channels, this new data structure.
Knowledge Points:
- Types of channels
- Creating channels
- Operating on channels
- Channel blocking
- Unidirectional channels
Overview of Channels
Channels are a special data structure primarily used for communication between goroutines.
Unlike other concurrency models that use mutexes and atomic functions for safe access to resources, channels allow for synchronization by sending and receiving data between multiple goroutines.
In Go, a channel is like a conveyor belt or a queue that follows the First-In-First-Out (FIFO) rule.
Channel Types and Declaration
A channel is a reference type, and its declaration format is as follows:
var channelName chan elementType
Each channel has a specific type and can only transport data of the same type. Let's see some examples:
Create a file named channel.go in the ~/project directory:
cd ~/project
touch channel.go
For example, let's declare a channel of type int:
var ch chan int
The above code declares an int channel named ch.
In addition to this, there are many other commonly used channel types.
In channel.go, write the following code:
package main
import "fmt"
func main() {
var ch1 chan int // Declare an integer channel
var ch2 chan bool // Declare a boolean channel
var ch3 chan []int // Declare a channel that transports slices of integers
fmt.Println(ch1, ch2, ch3)
}
The above code simply declares three different types of channels.
Run the program using the following command:
go run channel.go
The program output is as follows:
<nil> <nil> <nil>
Channel Initialization
Similar to maps, channels need to be initialized after they are declared before they can be used.
The syntax for initializing a channel is as follows:
make(chan elementType)
In channel.go, write the following code:
package main
import "fmt"
func main() {
// Channel that stores integer data
ch1 := make(chan int)
// Channel that stores boolean data
ch2 := make(chan bool)
// Channel that stores []int data
ch3 := make(chan []int)
fmt.Println(ch1, ch2, ch3)
}
Run the program using the following command:
go run channel.go
The program output is shown below:
0xc0000b4000 0xc0000b4040 0xc0000b4080
In the above code, three different types of channels are defined and initialized.
By examining the output, we can see that, unlike maps, directly printing the channel itself does not reveal its contents.
Directly printing a channel only gives the memory address of the channel itself. This is because, like a pointer, a channel is only a reference to a memory address.
So, how can we access and operate on the values in a channel?
Channel Operations
Channel operations are centered around sending and receiving data, which is done using the <- operator. To see channels in action, we need to use them with goroutines, Go's lightweight threads for concurrency.
A channel connects a sending goroutine and a receiving goroutine. If one is not ready, the other will block. Let's see a complete example.
In channel.go, write the following code:
package main
import (
"fmt"
"time"
)
func main() {
// Create an unbuffered channel
ch := make(chan int)
// Start a new goroutine using an anonymous function
go func() {
fmt.Println("Goroutine starts sending...")
// Send the value 10 to the channel
ch <- 10
fmt.Println("Goroutine finished sending.")
}()
fmt.Println("Main is waiting for data...")
// Receive the value from the channel
value := <-ch
fmt.Printf("Main received: %d\n", value)
// Give the goroutine time to print its final message
time.Sleep(time.Second)
}
Run the program using the following command:
go run channel.go
The program output will be similar to this (order might vary slightly):
Main is waiting for data...
Goroutine starts sending...
Main received: 10
Goroutine finished sending.
In this example:
ch := make(chan int)creates an unbuffered channel. Unbuffered channels require both sender and receiver to be ready at the same time for communication to happen. This is called synchronization.go func() { ... }()starts a new goroutine. Thegokeyword executes the function concurrently with themainfunction.- Inside the goroutine,
ch <- 10sends the value10to the channel. This operation will block until themaingoroutine is ready to receive. - In the
mainfunction,value := <-chreceives the value. This operation blocks until the goroutine sends a value. - Once both are ready, the value is transferred, and both goroutines unblock and continue their execution. We added
time.Sleepto ensure the program doesn't exit before the goroutine can print its final message.
Closing the Channel
When no more values will be sent on a channel, the sender can close it using the close() function. A common way to read all values from a channel until it's closed is to use a for range loop.
Let's modify channel.go:
package main
import "fmt"
func main() {
// A buffered channel to hold multiple values
ch := make(chan int, 3)
// Start a goroutine to send data
go func() {
fmt.Println("Goroutine sending 10, 20, 30")
ch <- 10
ch <- 20
ch <- 30
// Close the channel after sending all values
close(ch)
fmt.Println("Goroutine closed the channel.")
}()
fmt.Println("Main is receiving data...")
// Use a for-range loop to receive values until the channel is closed
for value := range ch {
fmt.Printf("Main received: %d\n", value)
}
fmt.Println("Main finished receiving.")
}
Run the program using the following command:
go run channel.go
The program output is as follows (order may vary slightly):
Main is receiving data...
Goroutine sending 10, 20, 30
Main received: 10
Main received: 20
Main received: 30
Goroutine closed the channel.
Main finished receiving.
This demonstrates a common pattern: the for range loop automatically handles checking if the channel is closed and exits when it is.
When working with closed channels, remember these key rules:
- Sending to a closed channel will cause a runtime error (a panic).
- Receiving from a closed, empty channel will immediately return the zero value of the channel's type.
- Closing a channel that is already closed will cause a panic.
- In general, only the sender should close a channel, never the receiver.
Channel Blocking
As we saw in the previous step, channel operations can block. This is a key feature that allows goroutines to synchronize. Let's look at this more closely.
Unbuffered Channels
Channels created with make(chan T) are unbuffered.
ch1 := make(chan int) // Unbuffered channel
An unbuffered channel has no capacity. A send operation on an unbuffered channel blocks the sending goroutine until another goroutine is ready to receive. Conversely, a receive operation blocks the receiving goroutine until another goroutine is ready to send. This creates a strong synchronization point, as we saw in our first example in the previous step.
Buffered Channels
Channels can also be buffered, meaning they have a capacity to hold a certain number of values before blocking.
ch2 := make(chan int, 5) // Buffered channel with a capacity of 5
With a buffered channel:
- A send operation blocks only when the buffer is full.
- A receive operation blocks only when the buffer is empty.
If the buffer is not full, a sender can send a value without a receiver being immediately ready. The value will be stored in the channel's queue.
Deadlock Example
What happens if we exceed a channel's capacity? The program will result in a deadlock. A deadlock occurs when all goroutines in a program are blocked, waiting for something that will never happen.
Create a file named channel1.go:
cd ~/project
touch channel1.go
Write the following code, which attempts to send two values to a channel with a capacity of one, all within the main goroutine:
package main
import "fmt"
func main() {
ch := make(chan int, 1)
ch <- 10 // This succeeds
fmt.Println("Sent 10 to channel")
ch <- 20 // This blocks forever because the channel is full
fmt.Println("succeed")
}
Run the program using the following command:
go run channel1.go
The program output is as follows:
Sent 10 to channel
fatal error: all goroutines are asleep - deadlock!
The program panics with a deadlock error. The main goroutine sends 10, which fills the buffer. It then tries to send 20 but blocks because the buffer is full. Since no other goroutine will ever receive from the channel, the main goroutine will be blocked forever, causing the Go runtime to detect a deadlock.
To fix this, the operation must be coordinated with another goroutine that can receive the value and make space in the buffer.
Unidirectional Channels
By default, a channel is bidirectional and can be read from or written to. Sometimes, we need to restrict the use of a channel, e.g., we want to ensure that a channel can only be written to or only be read from within a function. How can we achieve this?
We can explicitly declare a channel with restricted usage before initializing it.
Write-only Channel
A write-only channel means that within a particular function, the channel can only be written to and cannot be read from. Let's declare and use a write-only channel of type int:
package main
import "fmt"
func writeOnly(ch chan<- int) {
ch <- 10
fmt.Println("Data written to channel")
}
func main() {
ch := make(chan int, 1)
writeOnly(ch)
fmt.Println("Data received:", <-ch)
}
Run the program using the following command:
go run channel.go
The program output is as follows:
Data written to channel
Data received: 10
In this example, the writeOnly function restricts the channel to only writing.
Read-only Channel
A read-only channel means that within a particular function, the channel can only be read from and cannot be written to. Let's declare and use a read-only channel of type int:
package main
import "fmt"
func readOnly(ch <-chan int) {
fmt.Println("Data received from channel:", <-ch)
}
func main() {
ch := make(chan int, 1)
ch <- 20
readOnly(ch)
}
Run the program using the following command:
go run channel.go
The program output is as follows:
Data received from channel: 20
In this example, the readOnly function restricts the channel to only reading.
Combining Write-only and Read-only Channels
You can combine write-only and read-only channels to demonstrate how data flows through restricted channels:
package main
import "fmt"
func writeOnly(ch chan<- int) {
ch <- 30
fmt.Println("Data written to channel")
}
func readOnly(ch <-chan int) {
fmt.Println("Data received from channel:", <-ch)
}
func main() {
ch := make(chan int, 1)
writeOnly(ch)
readOnly(ch)
}
Run the program using the following command:
go run channel.go
The program output is as follows:
Data written to channel
Data received from channel: 30
This example illustrates how a write-only channel can pass data to a read-only channel.
Summary
In this lab, we learned the fundamentals of channels, which include:
- Types of channels and how to declare them
- Methods to initialize channels
- Operations on channels
- The concept of channel blocking
- Declaration of unidirectional channels



