-
Notifications
You must be signed in to change notification settings - Fork 3
Channels
Time to explore a key primitive in Go's concurrency arsenal: channels.
- Channels are excellent for designing concurrent programs that communicate via message passing.
- Channels help prevent data races by ensuring only one goroutine accesses data at a time.
- Channels provide a higher level of abstraction for coordinating goroutines, making code easier to reason about in complex concurrent scenarios.
- Channels are great for implementing worker pools and handling concurrent tasks that need to communicate results.
Channels are how goroutines communicate with each other. Consider the following example:
package main
import (
"fmt"
"time"
)
// sum calculates and prints the sum of numbers
func sum(nums []int) {
sum := 0
for _, v := range nums {
sum += v
}
fmt.Println("Result:", sum)
}
func main() {
nums := []int{1, 2, 3, 4, 5}
// invoke the sum function as a goroutine
go sum(nums)
// force main thread to sleep
time.Sleep(100 * time.Millisecond)
}
Rather than using a time.Sleep
here, it would be better if the goroutine running the sum
function could communicate when it's done with the main
goroutine. We'll modify the program to make use of channels that facilitate this communication.
func sum(nums []int, ch chan int) {
sum := 0
for _, num := range nums {
sum += num
}
ch <- sum
}
func main() {
nums := []int{1, 2, 3, 4, 5}
// create a channel
ch := make(chan int)
// invoke sum as a goroutine and pass the channel
go sum(nums, ch)
// receive the result from the channel
result := <-ch // blocking operation
fmt.Println("Result:", result)
// create a buffered channel
ch2 := make(chan string, 2)
// add two values to the channel
ch2 <- "James"
ch2 <- "Toni"
// receive the result from the channel
fmt.Println(<-ch2)
fmt.Println(<-ch2)
}
func main() {
nums := []int{1, 2, 3, 4, 5}
// create a channel
ch := make(chan int)
// invoke sum as a goroutine and pass the channel
go sum(nums, ch)
// receive the result from the channel
result := <-ch // blocking operation
fmt.Println("Result:", result)
// create a buffered channel
ch2 := make(chan string, 2)
// add two values to the channel
ch2 <- "James"
ch2 <- "Toni"
// receive the result from the channel
// fmt.Println(<-ch2)
// fmt.Println(<-ch2)
// add another value to the channel which will block
// because the channel is full
ch2 <- "Maya"
}
Another deadlock example:
func main() {
nums := []int{1, 2, 3, 4, 5}
// create a channel
ch := make(chan int)
// invoke sum as a goroutine and pass the channel
go sum(nums, ch)
// receive the result from the channel
result := <-ch // blocking operation
fmt.Println("Result:", result)
// create a buffered channel
ch2 := make(chan string, 2)
// add two values to the channel
ch2 <- "James"
ch2 <- "Toni"
// receive the result from the channel
// fmt.Println(<-ch2)
// fmt.Println(<-ch2)
wg := sync.WaitGroup{}
wg.Add(1)
go func() {
defer wg.Done()
ch2 <- "Maya"
}()
wg.Wait() // this blocks forever
}
Channels support an interesting feature that allows us to range over them, reading their values as they are sent. Let's see it in action.
package main
import (
"fmt"
"math/rand/v2"
)
// fill the channel with ints up to max
func fill(ch chan<- int, max int) {
// loop and fill the channel up to its capacity with random numbersyy
for i := 0; i < cap(ch); i++ {
ch <- rand.IntN(max)
}
// close the channel and signal that no more values will be sent
close(ch) // not closing leads to deadlock
}
func main() {
// create a channel with a buffer size
numChan := make(chan int, 5)
// invoke the fill function as a goroutine
go fill(numChan, 100)
// range over the channel and print out the values
for num := range numChan {
fmt.Println(num)
}
}
Sometimes, we need to wait on multiple channel operations at the same time. That's where the select statement comes in. Let's see it in action.
package main
import (
"fmt"
"time"
)
func main() {
// declare an empty struct channel for signaling
sigChan := make(chan struct{})
// declare a timer channel
timeChan := time.After(1 * time.Second)
// launch a goroutine to signal after 1 second
go func() {
time.Sleep(2 * time.Second)
sigChan <- struct{}{}
}()
// wait for a signal on either channel
select {
case <-sigChan:
fmt.Println("received from sigChan")
case <-timeChan:
fmt.Println("received from timeChan")
}
}
The select statement is like a switch
statement designed just for channels. It will wait on both channels and go into the case that is able to obtain a value first.
Update timers to get the other channel to be read from first and see how the results differ.
When we want to avoid blocking on sending on or receiving from a channel we can use the default case of a select statement. Let's see that in action.
package main
import (
"fmt"
"time"
)
func main() {
// declare a signal channel to read from
readChan := make(chan struct{})
// use a default case in select to prevent blocking
select {
case <-readChan:
fmt.Println("received from sigChan")
default:
fmt.Println("no signal received")
}
// declare two timer channels
timeChan1 := time.After(200 * time.Millisecond)
timeChan2 := time.After(400 * time.Millisecond)
// common pattern to prevent blocking
// but ensure you have loop exit conditions
for {
select {
case <-timeChan1:
fmt.Println("timeChan1")
return
case <-timeChan2:
fmt.Println("timeChan2")
return
default:
fmt.Println("default")
time.Sleep(250 * time.Millisecond)
}
}
}
It depends.
Here are some guidelines to help you decide:
Prefer channels for coordination: Use channels for coordinating goroutines and managing concurrency when possible, as they fit well with Go's design philosophy.
Use mutexes for simple protection: Use mutexes for straightforward cases of protecting shared memory to keep the implementation simple and efficient.