cover image for article

Golang: Channels

Edit

Author: Meet Rajesh Gor

#go

Introduction

In this part of the series, we will be continuing with the concurrency features of golang with channels. In the last post, we covered the fundamentals of go routines and wait groups. By leveraging those understood concepts, we will explore channels to communicate the data between various go routines.

What are Channels

A golang Channel is like a pipe that lets goroutines communicate. It lets you pass values from one goroutine to another. Channels are typed i.e. you declare them with chan keyword followed by the type to be sent and received (e.g. chan int). The chan type specifies the type of values that will be passed through the channel. We will explore the detailed technicalities soon. Right now, we need to just focus on what problem is channels solving.

In the previous article, we worked with go routines and wait groups which allowed us to process tasks asynchronously. However, if we wanted to access the data in between the processes, we would have to tweak the core functionality or might require global variables, however, in real-world applications, the environment is quite constrained. We would require a way to communicate data between those go routines. Channels are made just for that(more than that), but in essence, it solves that exact problem.


go
package main

import (
	"fmt"
)

func main() {
	ch := make(chan string)
	defer close(ch)

	go func() {
		message := "Hello, Gophers!"
		ch <- message
	}()

	msg := <-ch
	fmt.Println(msg)
}

In the above code example, the channel ch is created of type string and a message is sent to the channel inside a go routine as ch <- message, and the message is retrieved from the channel as <-ch.


bash
$ go run main.go

Hello, Gophers!

Channels have two key properties:


go
package main

import (
    "fmt"
)

func main() {
    ch := make(chan string)
    go func() {
        message := "Hello, Gophers!"
        ch <- message
    }()
    fmt.Println(<-ch)
    fmt.Println(<-ch)
}

In the same example, if we tried to add the second receiver i.e. <-ch, it would result in a deadlock/block forever since there is no second message sent into the channel. Only one value i.e. "Hello Gophers!" was sent as a message into the channel, and that was received by the first receiver as <-ch, however in the next receiver, there is no sender.


bash
$ go run main.go

Hello, Gophers!
fatal error: all goroutines are asleep - deadlock!

goroutine 1 [chan receive]:
main.main()
        /home/meet/code/100-days-of-golang/scripts/channels/main.go:16 +0x125
exit status 2

To sum up the deadlock concept in unbuffered channels:

Buffered Channels

In Go, you can create both buffered and unbuffered channels. An unbuffered channel has no capacity to hold data, it relies on immediate communication between the sender and receiver. However, you can create a buffered channel by specifying a capacity when using the make function, like ch := make(chan int, 5) will create a channel with a capacity of 5 i.e. it can store a certain number of values without an immediate receiver. A buffered channel allows you to send multiple values to the channel without an immediate receiver, up to its capacity. After that, it will block until the receiver retrieves values.


go
package main

import (
	"fmt"
	"sync"
)

func main() {
	buffchan := make(chan int, 2)

	wg := sync.WaitGroup{}
	wg.Add(2)

	for i := 1; i <= 2; i++ {
		go func(n int) {
			buffchan <- n
			wg.Done()
		}(i)
	}

	wg.Wait()
	close(buffchan)

	for c := range buffchan {
		fmt.Println(c)
	}
}

bash
$ go run channels.go
1
2

$ go run channels.go
2
1

In this code snippet, we create a buffered channel ch with a capacity of 2. We send two values to the channel, and even though there's no immediate receiver, the code doesn't block. If we were to send a third value, it would lead to a deadlock because there is no receiver to free up space in the buffer.

Closing Channels

Closing a channel is important to signal to the receiver that no more data will be sent. It's achieved using the built-in close function. After closing a channel, any further attempts to send data will result in a panic. On the receiving side, if a channel is closed and there's no more data to receive, the receive operation will yield the zero value for the channel's type.


go
package main

import (
	"fmt"
)

func main() {
	ch := make(chan int)

	go func() {
		for i := 1; i <= 5; i++ {
			ch <- i
		}
		close(ch)
	}()

	for num := range ch {
		fmt.Println("Received:", num)
	}
}

In this example, a goroutine sends numbers to the channel and then closes it. The main routine receives these numbers using a for-range loop. When the channel is closed and all values are received, the loop will terminate automatically. Keep in mind that only a sender can close the channel, to indicate the receiver to not wait for further values from the channel.

Select Statement for Channels

The select statement is used for handling multiple channels. There are a few operations that can be checked with a case statement in the select block.

Case Channel Operation
Sending chan <- value
Receiving <- chan

So, we can either check if there is a sender or a receiver available for a channel with a case statement just like a switch statement.


go
package main

import (
	"fmt"
	"sync"
)

func sendMessage(ch chan string, message string, wg *sync.WaitGroup) {
	defer wg.Done()
	ch <- message
}

func main() {
	var wg sync.WaitGroup

	ch1 := make(chan string, 2)
	ch2 := make(chan string, 2)
	wg.Add(2)

	go sendMessage(ch1, "Hello, Gophers!", &wg)
	go sendMessage(ch2, "Hello, Hamsters!", &wg)

	go func() {
		defer wg.Done()
		wg.Wait()
		close(ch1)
		close(ch2)
	}()
	ch1 <- "new message to c1"
	ch2 <- "new message to c2"

	select {
	case <-ch1:
		fmt.Println("Received from ch1")
	case ch1 <- "new message to c1":
		fmt.Println("Sent to ch1")
	case <-ch2:
		fmt.Println("Received from ch2")
	case ch2 <- "new message to c2":
		fmt.Println("Sent to ch2")
	}
}

bash
$ go run channels.go
Sent to ch1

$ go run simple.go
Received from ch1

$ go run simple.go
Received from ch2

$ go run simple.go
Sent to ch2

$ go run simple.go
Received from ch1

The order of the messages is not guaranteed, the operation which is performed first based on the go routine will be only logged.

In the simple example above, we have created two channels ch1 and ch2, and sent two messages to them using two go routines. The main routine then waits for the messages to be received from the channels. We close the channels when the sending is done and simply check for the 4 cases i.e. the send on channel 1, receive on channel 1, and similarly for channel 2. So, that is how we can use the select statement to check which operation is being performed on the different channels, and this forms the basis for the communication between channels. We get more ease in the flow while working with channels.

Below is an example to test which url or a web server is responding first to the request.


go
package main

import (
	"fmt"
	"net/http"
	"sync"
)

func pingGoogle(c chan string, wg *sync.WaitGroup) {
	defer wg.Done()
	res, _ := http.Get("http://google.com")
	c <- res.Status
}

func pingDuckDuckGo(c chan string, wg *sync.WaitGroup) {
	defer wg.Done()
	res, _ := http.Get("https://duckduckgo.com")
	c <- res.Status
}

func pingBraveSearch(c chan string, wg *sync.WaitGroup) {
	defer wg.Done()
	res, _ := http.Get("https://search.brave.com")
	c <- res.Status
}

func main() {
	gogChan := make(chan string)
	ddgChan := make(chan string)
	braveChan := make(chan string)

	var wg sync.WaitGroup
	wg.Add(3)

	go pingDuckDuckGo(ddgChan, &wg)
	go pingGoogle(gogChan, &wg)
	go pingBraveSearch(braveChan, &wg)

	openChannels := 3

	go func() {
		wg.Wait()
		close(gogChan)
		close(ddgChan)
		close(braveChan)
	}()

	for openChannels > 0 {
		select {
		case msg1, ok := <-gogChan:
			if !ok {
				openChannels--
			} else {
				fmt.Println("Google responded:", msg1)
			}
		case msg2, ok := <-ddgChan:
			if !ok {
				openChannels--
			} else {
				fmt.Println("DuckDuckGo responded:", msg2)
			}
		case msg3, ok := <-braveChan:
			if !ok {
				openChannels--
			} else {
				fmt.Println("BraveSearch responded:", msg3)
			}
		}
	}
}

The above example shows how to use a select statement to wait for multiple channels to be ready before proceeding with the next operation. With this example, we can get the channel that sent the response first i.e. which search engine in this case responded to the ping first. Just a bit exaggerated example but it helps in understanding the concept of the select statement.


bash
$ go run select-chan.go

DuckDuckGo responded: 200 OK
Google responded: 200 OK
BraveSearch responded: 200 OK


$ go run select-chan.go

DuckDuckGo responded: 200 OK
BraveSearch responded: 200 OK
Google responded: 200 OK

Let's break each of the steps down:

Directional Channels

Channels can also be designated as "send-only" or "receive-only" to enforce certain communication patterns and enhance safety. This is done by specifying the direction when defining the channel type.


go
package main

import (
	"fmt"
	"sync"
)

func receiver(ch <-chan int, wg *sync.WaitGroup) {
	for i := range ch {
		fmt.Println("Received:", i)
	}
	wg.Done()
}

func sender(ch chan<- int, wg *sync.WaitGroup) {
	for i := 0; i < 10; i++ {
		fmt.Println("Sent:", i)
		ch <- i
	}
	close(ch)
	wg.Done()
}

func main() {
	ch := make(chan int)
	wg := sync.WaitGroup{}
	wg.Add(2)
	go receiver(ch, &wg)
	go sender(ch, &wg)
	wg.Wait()
}

In the above example, we have created a channel ch and sent 10 values to it using two-go routines. The main routine is waiting for the goroutines to finish before closing the channel. The sender sends values 0 through 9, and the receiver prints whenever a value is received. In the sender method, we only accept the channel to send data as chan<-, and in the receiver method, the channel parameter is set to only read from the channel as <-chan.


bash
$ go run send-recv.go

Sent: 0
Received: 0
Sent: 1
Sent: 2
Received: 1
Received: 2
Sent: 3
Sent: 4
Received: 3
Received: 4
Sent: 5
Sent: 6
Received: 5
Received: 6
Sent: 7
Sent: 8
Received: 7
Received: 8
Sent: 9
Received: 9

When we define a parameter as a write-only channel means that the function can only send data into that channel. It cannot read data from it or close it. This pattern is helpful when you want to make sure that the function is solely responsible for producing data and not consuming or interacting with the channel's current state.

When we define a parameter as a read-only channel, it means that the function can only receive data from that channel. It cannot close the channel or send data into it. This pattern is useful when we want to ensure that the function only consumes data from the channel without modifying it or interfering with the sender's logic.

Additionally, the compiler will catch code trying to send on a read-only channel or receive from a write-only one.

Common Channel Usage Pattern

There are a variety of ways in which channels can be used in Go. In this section, we'll explore some of the most common patterns for using channels in Go. Some of the most useful and idiomatic channel usage patterns include pipelines, fan-in and fan-out, etc.

Async Await pattern for Channels

In Go, goroutines and channels enable an elegant async/await style. A goroutine can execute a task asynchronously, while the main thread awaits the result using a channel.

The async-await pattern in Go involves initiating multiple tasks concurrently, each with its own goroutine, and then awaiting their completion before proceeding. Channels are used to communicate between these goroutines, allowing them to work independently and provide results to the main routine when ready.


go
package main

import (
	"fmt"
	"net/http"
)

func fetchURL(url string, ch chan<- http.Response) {
	go func() {
		res, err := http.Get(url)
		if err != nil {
			panic(err)
		}
		defer res.Body.Close()
		ch <- *res
	}()
}

func task(name string) {
	fmt.Println("Task", name)
}

func main() {
	fmt.Println("Start")

	url := "http://google.com"

	respCh := make(chan http.Response)

	fetchURL(url, respCh)

	task("A")
	task("B")

	response := <-respCh
	fmt.Println("Response Status:", response.Status)

	fmt.Println("Done")
}

bash
$ go run async.go
Start
Task A
Task B
Response Status: 200 OK
Done

In the above example, we have created a function fetchURL which takes a URL and a channel as an argument. The channel respCh is used to communicate between the goroutines. The function fires up a goroutine that fetches the request, we send a GET request to the provided URL and send the response to the provided channel. In the main function, we access the response by receiving the data from the channel as <-respCh. Before doing this, we can do any other task simultaneously, like task("A") and task("B") which just prints some string(it could be anything). But this should be before we pull in from the channel, anything after the access will be blocked i.e. will be executed sequentially.

Pipeline pattern for Channels

The pipeline pattern is used to chain together a sequence of processing stages, each stage consumes input, processes data, and passes the output to the next stage. This type of pattern can be achieved by chaining different channels from one go routine to another.

Pipeline pattern flow using channels in golang

So, the pipeline pattern using channels in Go, data flows sequentially through multiple stages: Stage 1 reads input and sends to Channel A, Stage 2 receives from Channel A and sends to Channel B, and Stage 3 receives from Channel B and produces the final output.


go
package main

import (
	"fmt"
	"sync"
)

func generate(nums []int, out chan<- int, wg *sync.WaitGroup) {
	fmt.Println("Stage 1")
	for _, n := range nums {
		fmt.Println("Number:", n)
		out <- n
	}
	close(out)
	wg.Done()
}

func square(in <-chan int, out chan<- int, wg *sync.WaitGroup) {
	fmt.Println("Stage 2")
	for n := range in {
		sq := n * n
		fmt.Println("Square:", sq)
		out <- sq
	}
	close(out)
	wg.Done()
}

func print(in <-chan int, wg *sync.WaitGroup) {
	for n := range in {
		fmt.Println(n)
	}
	wg.Done()
}

func main() {
	input := []int{1, 2, 3, 4, 5}

	var wg sync.WaitGroup
	wg.Add(3)

	stage1 := make(chan int)
	stage2 := make(chan int)

	go generate(input, stage1, &wg)

	go square(stage1, stage2, &wg)

	go print(stage2, &wg)

	wg.Wait()
}

In the above example, we have created a sequence of processing stages, each stage consumes input, processes data, and passes the output to the next stage. We can consider the functions generate, square, and print as stages 1, 2, and 3 respectively.


$ go run pipeline.go
Stage 1
Number: 1
Stage 2
Square: 1
1
Number: 2
Number: 3
Square: 4
Square: 9
Number: 4
4
9
Square: 16
16
Number: 5
Square: 25
25

So, we can see the order of the execution, both the pipelines started synchronously, However, they perform the operation only when the data is sent from the previous channel. We first print the number from the generate function, then print the squared value in the square function, and finally print it as Square: value in the print function.

Fan-In pattern for Channels

The Fan-In pattern is used for combining data from multiple sources into a single stream for unified processing, often using a shared data structure to aggregate the data. We can create the fan-in pattern by merging multiple input channels into a single output channel.

Fan-in pattern flow using channels in golang

The fan-in pattern is when multiple input channels (A, B, C) are read concurrently, and their data is merged into a single output channel (M).


go
package main

import (
	"fmt"
	"io/ioutil"
	"sync"
)

func readFile(file string, ch chan<- string) {
	content, _ := ioutil.ReadFile(file)
	fmt.Println("Reading from", file, "as :: ", string(content))
	ch <- string(content)
	close(ch)
}

func merge(chs ...<-chan string) string {
	var wg sync.WaitGroup
	out := ""

	for _, ch := range chs {
		wg.Add(1)
		go func(c <-chan string) {
			for s := range c {
				out += s
			}
			wg.Done()
		}(ch)
	}

	wg.Wait()
	return out
}

func main() {
	ch1 := make(chan string)
	ch2 := make(chan string)

	go readFile("data/f1.txt", ch1)
	go readFile("data/f2.txt", ch2)

	merged := merge(ch1, ch2)

	fmt.Println(merged)
}

In the above example, the readFile function reads the contents of the file and sends it to the channels ch1 and ch2 from different go routines. The readFile takes in the channel as send only channel which reads the file and sends the content to the channel as ch <- string(content). The merge function takes in 2 it can also be n number of channels to parse from as indicated as ...<-chan, it iterates over each channel, and for each channel, it reads the contents, and appends as a single string.


bash
$ go run fan-in.go

Reading from data/f1.txt as ::  This is from file 1
Reading from data/f2.txt as ::  This is from file 2

This is from file 1
This is from file 2


$ go run fan-in.go
Reading from data/f2.txt as ::  This is from file 2
Reading from data/f1.txt as ::  This is from file 1

This is from file 2
This is from file 1

So, this is how the fan-in pattern works, We create multiple channels and combine the results into a single stream of data(in this example a single string).

Fan-Out Pattern for Channels

The Fan-Out pattern involves taking data from a single source and distributing it to multiple workers or processing units for parallel or concurrent handling. Fan-out design splits an input channel into multiple output channels, it is used to distribute branches of work or data across concurrent processes.

Fan-Out pattern flow using channels in golang

The fan-out pattern is when data from a single input channel (A) is distributed to multiple worker channels (X, Y, Z) for parallel processing.


go
package main

import (
	"fmt"
	"os"
	"sync"
)

func readFile(file string, ch chan<- string, wg *sync.WaitGroup) {
	defer wg.Done()

	content, err := os.ReadFile(file)
	if err != nil {
		fmt.Printf("Error reading from %s: %v\n", file, err)
		return
	}

	ch <- string(content)
}

func main() {
	files := []string{"data/f1.txt", "data/f2.txt"}

	var wg sync.WaitGroup
	ch := make(chan string)

	for _, f := range files {
		wg.Add(1)
		go readFile(f, ch, &wg)
	}

	go func() {
		wg.Wait()
		close(ch)
	}()

	var fileData []string
	for content := range ch {
		fileData = append(fileData, content)
	}

	fmt.Printf("Read %d files\n", len(fileData))
	fmt.Printf("Contents:\n%s", fileData)
}

In the above example, we create a single channel ch as our single source, we loop over all the files, and create go routines calling the readFile function. The readFile function takes in the filename, channel, and the WaitGroup reference, the function reads the file and sends the content to the channel as ch <- content. The readFile is called concurrently for all the files, Here we have done a fan-out of the task into multiple go routines, then in the main function, we iterate over the channel and receive the content.


bash
$ go run fan-out.go

Read 2 files
Contents:
[This is from file 2
 This is from file 1
]


$ go run fan-out.go

Read 2 files
Contents:
[This is from file 1
 This is from file 2
]

Here's a brief summary of the fan-out pattern from the example provided:

I have a few more patterns to demonstrate that have been provided in the GitHub on the 100 days of Golang repository.

That's it from the 31st part of the series, all the source code for the examples are linked in the GitHub on the 100 days of Golang repository.

References

Conclusion

So, from this part of the series, we were able to understand the fundamentals of channels in golang. By using the core concepts from the previous posts like go routines and wait groups, we were able to work with channels in golang. We wrote a few examples for different patterns using concurrency concepts with channels. Patterns like pipelines, fan-in, fan-out, async, and some usage of select statements for channels were explored in this section.

Hopefully, you found this section helpful, if you have any comments or feedback, please let me know in the comments section or on my social handles. Thank you for reading. Happy Coding :)