Skip to content
Francisco Cidade
9 min read

Concurrency and Parallelism in Go - Channels

Summary

Channels

What are Channels?

Channels are structures that allow communication and synchronization between goroutines. They work like "pipes" through which data can be sent from one part of the code to another. They let you transfer data safely, without directly accessing shared variables.

Why use them?

Declaring and Creating Channels

You can declare a channel with the syntax:

var ch chan int

This defines a channel of integers, but doesn’t initialize it yet.

In Go, when you define a channel of integers, you are only declaring that the channel exists, but you are not creating an instance of it yet. To actually initialize the channel and make it usable, you need to use the make function.

To create (initialize) a channel, use the make function:

ch := make(chan int)

This creates an unbuffered channel that can be used to send and receive values of type int.

Basic Operations with Channels

Sending data

Use the syntax:

ch <- value

This sends “value” to the channel, blocking the goroutine’s execution if there is no receiving goroutine (in the case of an unbuffered channel).

Receiving data

Use the syntax:

value := <-ch

This waits for and receives a value from the channel. If no value is available, the goroutine stays blocked until a value is sent.

Simple Example

package main

import (
	"fmt"
	"time"
)

func main() {
	// Creates and initializes the channel.
	ch := make(chan int)
	
	// Launches a goroutine to send a value.
	go func() {
		ch <- 42
	}()
	
	// Receives and prints the sent value.
	value := <-ch
	fmt.Println("Received:", value)
}

Notice that we didn’t need a sync.WaitGroup or a time.Sleep here, because using channels in Go allows direct synchronization between goroutines.

In the example, the anonymous goroutine sends the value 42 to the channel ch, and the main function waits for that value to arrive before continuing.

This happens because the send operation (ch <- 42) blocks the goroutine until another goroutine receives the value, and the receive operation (<-ch) blocks the main function until a value is sent.

This way, the channel guarantees that communication and synchronization between goroutines happen efficiently and without the need for additional synchronization mechanisms.

Unbuffered vs. Buffered Channels

In Go there are two kinds of channels, and we have already used one of them. Let’s dig into the differences between them.

Unbuffered channel

Buffered channel

Demonstration

Imagine you have a buffered channel and you send three values in a row:

ch := make(chan int, 3)
ch <- 10
ch <- 20
ch <- 30

In this situation, the three send operations don’t block, because there is enough room in the buffer. If you try to send a fourth value, the operation will be blocked until some value is received.

Closing Channels

When to close a channel

You should close a channel when there are no more values to send. This tells the receivers that no further information will be sent.

How to close

Use the close function:

close(ch) // ch is the name of the variable where your channel is stored

Receiving after closing

When trying to receive from a closed channel, the operation returns the zero value of the type, plus an “ok” flag that indicates whether the channel is open.
Example:

value, ok := <-ch
if !ok {
	fmt.Println("Channel closed, no reads available")
}
// If `ch` is a channel of integers, the variable `value` will be `0`,
// for a string it will be "", etc...

Iterating over Channels

Just like maps, arrays and slices, you can also iterate over the values of a channel.

Using the for…range loop

It lets you iterate over the values sent on a channel until it is closed.
Example:

for v := range ch {
	fmt.Println("Value received:", v)
}

Complete practical example

package main

import (
	"fmt"
)

func main() {
	ch := make(chan int, 5)
	// Launches a goroutine that sends data and closes the channel.
	go func() {
		for i := 1; i <= 5; i++ {
			ch <- i
		}
		close(ch)
	}()
	// The for...range loop keeps going while the channel is open.
	for value := range ch {
		fmt.Println("Received:", value)
	}
}

Considerations and Care with Channels

Preventing deadlocks

Closing properly

Good practices

Exercises

  1. Simple Send with an Unbuffered Channel

    • Requirement: Create a channel of integers, launch a goroutine that sends the value 100 to the channel and, in main, receive and print that value.
  2. Send and Receive with a Buffered Channel

    • Requirement: Create a buffered channel (capacity 3), send three values in a row without causing blocking and then receive and print all the values in main.
  3. Closing a Channel with Iteration

    • Requirement: Launch a goroutine that sends the numbers 1 to 5 to a channel, closes the channel and, in main, iterates with a for…range loop to print all the received values.
  4. Checking for a Closed Channel

    • Requirement: Create a channel, send a value, close it and then, in main, use the receive form value, ok := <-ch to print the value and indicate whether the channel is still open.
  5. Combination: Goroutine and Channel for a Calculation

    • Requirement: Launch a goroutine that computes the sum of the numbers 1 to 5 and sends the result through a channel; in main, receive and print the result.
  6. Combination: WaitGroup and Channel

    • Requirement: Launch 2 goroutines that perform simple operations (for example, an addition and a multiplication) and send the results through a channel; use sync.WaitGroup to synchronize the goroutines and, after they finish, receive and print the results in main.
    • Bonus Challenge: Can you solve this exercise without using WaitGroups?
  7. Complete Example: Several Goroutines with WaitGroup and Channel

    • Requirement: Create a function that launches 5 goroutines, each sending its identifier (from 1 to 5) to a channel; use a WaitGroup to wait for all of them to finish and then iterate in main (using for...range) to print all the values from the channel. Remember to close the channel after the goroutines finish.
    • Bonus Challenge: Can you solve this exercise without using WaitGroups?

Common mistakes with Channels

alt text

Blocking Operations on Unbuffered Channels

Forgetting to Close a Channel

Trying to Send Data on a Closed Channel

Exercise solutions

So I wouldn’t have to run each exercise by hand, I used tests and testable examples.

Exercise 1:

// main.go
func Ex1() {
	ch := make(chan int)
	go func() {
		ch <- 100
	}()
	fmt.Println(<-ch)
}

// main_test.go
func ExampleEx1() {
	Ex1()
	// output: 100
}

Exercise 2:

// main.go
func Ex2() {
	ch := make(chan string, 3)
	go func() {
		ch <- "one"
		ch <- "two"
		ch <- "three"
	}()
	fmt.Println(<-ch)
	fmt.Println(<-ch)
	fmt.Println(<-ch)
}

// main_test.go
func ExampleEx2() {
	Ex2()
	// output:
	// one
	// two
	// three
}

Exercise 3:

// main.go
func Ex3() {
	ch := make(chan int)
	go func() {
		for i := 1; i <= 5; i++ {
			ch <- i
		}
		close(ch)
	}()

	for v := range ch {
		fmt.Println(v)
	}
}

// main_test.go
func ExampleEx3() {
	Ex3()
	// output:
	// 1
	// 2
	// 3
	// 4
	// 5
}

Exercise 4:

// main.go
func Ex4() {
	ch := make(chan float64)
	go func() {
		ch <- 10.0
		close(ch)
	}()

	if v, ok := <-ch; ok {
		fmt.Printf("%.1f", v)
	}
}

// main_test.go
func ExampleEx4() {
	Ex4()
	// output:
	// 10.0
}

Exercise 5:

// main.go
func Ex5() {
	ch := make(chan int)
	go func() {
		n := 5
		sum := (n * (n + 1)) / 2
		ch <- sum
		close(ch)
	}()

	fmt.Println("Sum:", <-ch)
}

// main_test.go
func ExampleEx5() {
	Ex5()
	// output:
	// Sum: 15
}

Exercise 6:

// main.go
func Ex6() {
	ch := make(chan int, 2)

	wg := &sync.WaitGroup{}

	wg.Add(1)
	go func() {
		defer wg.Done()
		ch <- 20 + 5
	}()

	wg.Add(1)
	go func() {
		defer wg.Done()
		ch <- 10 * 10
	}()

	wg.Wait()

	fmt.Println("First:", <-ch)
	fmt.Println("Second:", <-ch)
}

// main_test.go
func ExampleEx6() {
	Ex6()
	// output:
	// First: 100
	// Second: 25
}

Exercise 7:

// main.go
func Ex7() {
	const repetitions = 5
	ch := make(chan int, repetitions)

	wg := &sync.WaitGroup{}

	wg.Add(repetitions)
	for i := range repetitions {
		go func(i int) {
			defer wg.Done()
			ch <- i + 1
		}(i)
	}

	wg.Wait()
	close(ch)

	for i := range ch {
		fmt.Println("Goroutine:", i)
	}
}

// main_test.go
func TestEx7(t *testing.T) {
	out := captureOutput(Ex7)
	expectedOutputs := []string{
		"Goroutine: 1",
		"Goroutine: 2",
		"Goroutine: 3",
		"Goroutine: 4",
		"Goroutine: 5",
	}
	for _, expected := range expectedOutputs {
		if !strings.Contains(out, expected) {
			t.Fatal("Output not generated: ", expected, " got: ", out)
		}
	}
}

// main_test.go
func captureOutput(f func()) string {
	orig := os.Stdout
	r, w, _ := os.Pipe()
	os.Stdout = w
	f()
	os.Stdout = orig
	w.Close()
	out, _ := io.ReadAll(r)
	return string(out)
}

Next steps!

Next we will cover Select Statements! Stay tuned for the coming days!

References