Understanding Go Channels: An Overview for Beginners

On a server infrastructure, Go code can be quickly deployed and updated across different installations as needed. It also supports a variety of operating systems and processor designs, which is a significant advantage.

Some of the biggest and most well-known projects have been written completely in Go, which has emerged as the preferred language for open-source infrastructure software. For instance, Docker was created completely in Go.

A channel is a paradigm for message-based interprocess synchronization and communication in computing. A message can be sent over a channel, and another process or application that has a reference to the channel can receive messages sent over it as a stream.

Channels are a way for various goroutines to communicate in Golang, or Go. Consider them as pipes that you can use to link to various concurrent goroutines. By default, the communication is bidirectional, allowing you to transmit and receive data over the same channel.

Table of Contents

  1. What are Go channels?
  2. Working of Go channel
  3. Types of Go channel
  4. How do you read/write to a go channel?
  5. What is a Closed Go channel?
  6. Go channels as function parameters
  7. Uses of Go channels

What are Go channels?

A Go channel is a means of communication that enables data sharing between goroutines. The easiest method for goroutines to communicate with one another when many Goroutines are running at once, is through channels.

Developers frequently use Go channels for managing concurrency in apps and notifications.

Go channels are used to transmit and receive data of a particular element type between concurrently running functions. Channels are the most practical method for Goroutines to interact with one another when many of them are running concurrently.

Creating a Go Channel

In Go, you can create a channel using the built-in make function. The make function takes a type parameter and returns a channel of that type. Here's an example:

var channel chan float64 = make(chan float64)
Go channel declaration
Go channel declaration

In this example, we create a channel of type float and assign it to the variable channel.

You can also specify the buffer size of a channel when creating it. A buffered channel allows for multiple values to be stored in the channel at once before they shall be read. Here's an example of creating a buffered channel with a buffer size of 3:

ch := make(chan int, 3)

Working of Go Channel (sending and receiving data)

A channel is essentially a conduit through which values of a specified type can be passed from one goroutine to another.

To create a channel, you use the built-in make() function and specify the type of the channel:

ch := make(chan int)

This creates an unbuffered channel of type int. An unbuffered channel is one that can only hold one value at a time, and sends and receives on an unbuffered channel will block until the corresponding operation is ready to proceed.

Once you have a channel, you can send and receive values on it using the <- operator. For example, to send a value on a channel, you would use:

ch <- 42

This sends the value 42 to the channel ch.

To receive a value from a channel, you would use:

x := <-ch

This receives a value from the channel ch and assigns it to the variable x.

If there is no value currently available on the channel, the receive operation will block until a value is sent on the channel.

Similarly, if you try to send a value on an unbuffered channel and there is no goroutine ready to receive the value, the send operation will block until a receive operation is ready to proceed.

ch := make(chan int)

// This send operation will block until there is a receive operation
// ready to proceed.
ch <- 42

// This receive operation will block until there is a send operation
// ready to proceed.
x := <-ch

Buffered channels allow you to send and receive multiple values without blocking, up to the size of the buffer. You can create a buffered channel by passing the buffer size as a second argument to make():

ch := make(chan int, 10)

This creates a buffered channel of type int with a buffer size of 10. You can send and receive values on a buffered channel as you would with an unbuffered channel, but sending a value will only block if the buffer is full, and receiving a value will only block if the buffer is empty.

Channels are often used to coordinate the execution of multiple goroutines. For example, you can use a channel to signal that a goroutine has finished its work:

ch := make(chan bool)

go func() {
    // Do some work
    ch <- true
}()

// Wait for the goroutine to finish
<-ch

In this example, a goroutine is created to do some work, and when it's finished, it sends a boolean value true on the channel ch. The main goroutine waits for the value to be sent on the channel before proceeding.

Channels can be closed using the built-in close() function:

close(ch)

After closing a channel, no more values can be sent on the channel. Receivers can detect that a channel has been closed using the two-value form of the receive operation:

x, ok := <-ch

If ok is false, it means that the channel has been closed and there are no more values to receive.

Types of Go Channel

Channels can be categorized into different types based on their behavior and how they are used in Go programs. Here are the two most common types of Go channels:

  1. Buffered channels
  2. Unbuffered channels

1. Buffered Channel

These channels have a fixed capacity to hold values. When a value is sent through a buffered channel, the sending goroutine does not block until the channel is full. Similarly, when a value is received from a buffered channel, the receiving goroutine does not block until the channel is empty.

Here's an example:

package main

import "fmt"

func main() {
    ch := make(chan int, 1)
    ch <- 42
    val := <-ch
    fmt.Println(val) // Output: 42
}

2. Unbuffered Channel

These channels do not have any capacity to hold values. When a value is sent through an unbuffered channel, the sending goroutine blocks until another goroutine receives the value. Similarly, when a value is received from an unbuffered channel, the receiving goroutine blocks until another goroutine sends a value.

Here's an example:

package main

import "fmt"

func main() {
    ch := make(chan int)
    go func() {
        ch <- 42
    }()
    val := <-ch
    fmt.Println(val)
}

How do you read/write to a go channel?

In Go, you can both read from and write to a channel using the <- operator in both directions. Here's an example:

package main

import (
    "fmt"
)

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

    // Write to the channel
    go func() {
        ch <- 42
    }()

    // Read from the channel
    value := <-ch
    fmt.Println(value)

    // Write to the channel again
    go func() {
        ch <- 99
    }()

    // Read from the channel again
    value = <-ch
    fmt.Println(value)
}

In this example, we create an unbuffered channel of type int using the make function. We then write the value 42 to the channel using a goroutine that runs in the background. We read the value from the channel and print it to the console. We then write the value 99 to the channel using another goroutine, and read it from the channel and print it to the console.

Note that reading from and writing to the same channel in the same goroutine will block until another goroutine writes to or reads from the channel. To prevent deadlock, you can either create a buffered channel or ensure that there is another goroutine ready to read from or write to the channel.

What is a Closed Go channel?

A closed channel is a channel that has been closed by its sender using the built-in close() function. When a channel is closed, no more values can be sent on it, but the receiver can still receive the remaining values that were sent before the channel was closed.

When a receiver receives a value from a closed channel, it immediately gets the zero value of the channel's element type. For example, if the channel is of type int, then the receiver will get 0 when it receives from a closed channel.

Closed channels are useful for signaling the completion of a task or notifying a receiver that no more values will be sent on the channel. It's important to note that closing a channel is a one-way operation that can only be done by the sender. The receiver should never attempt to close a channel.

Here's an example of a closed channel:

package main

import "fmt"

func main() {
    ch := make(chan int, 2)
    ch <- 1
    ch <- 2
    close(ch)

    for {
        val, ok := <-ch
        if !ok {
            fmt.Println("Channel closed!")
            break
        }
        fmt.Println(val)
    }
}

In this example, we create a buffered channel ch with a capacity of 2. We then send two values on the channel using the <- operator. After that, we close the channel using the built-in close() function.

We then use a for loop to receive values from the channel. We use a comma-ok idiom to check if the channel is still open or has been closed. If the channel is closed, ok will be false, and we break out of the loop. If the channel is still open, ok will be true, and we print the value received from the channel.

The output of this program will be:

When a channel is closed, any subsequent receive operation on the channel will immediately return the zero value of the channel's element type.

So, in the above example, when we try to receive values from the closed channel using val, ok := <-ch, val will be set to 0, and ok will be set to false. The ok value tells us whether the channel is still open or has been closed. In the case of a closed channel, ok will be false.

Therefore, when we detect that ok is false, we know that the channel is closed, and we can break out of the loop.

Here are some possible use cases for a closed Go channel:

  1. Signaling the end of a stream: A common use case for a closed Go channel is to signal the end of a stream of data. For example, if you have a channel that is used to receive data from a network connection, you might close the channel when the connection is closed to signal that there is no more data to be read.
  2. Broadcasting events: Another use case for a closed Go channel is to broadcast events to multiple listeners. You can have multiple goroutines listening on the same channel, and when the channel is closed, all of the listeners will receive a zero value and know that the event has occurred.
  3. Avoiding deadlocks: Closing a channel can be a way to avoid deadlocks in your program. If a channel is blocked waiting for a value to be sent, but there is no more data to be sent, you can close the channel to unblock the receiver and prevent the program from getting stuck.
  4. Garbage collection: When a channel is closed, any values that were sent on the channel but not yet received will still be in memory. However, the garbage collector can free up that memory once the channel is closed, which can be useful in some cases.

Go channels as function parameters

Go does enable us to specify the direction of a channel when using it as a function parameter, meaning whether it's used for reading or writing, even though we didn't use them when working with readCh.go or writeCh.go.

Channels are bidirectional by default, but there are two kinds of unidirectional channels. Look at the Go code for these two functions:

package main

import "fmt"

func f1(c chan int, x int) {
    fmt.Println(x)
    c <- x
}

func f2(c chan<- int, x int) {
    fmt.Println(x)
    c <- x
}

func main() {
    c1 := make(chan int)
    c2 := make(chan int)

    go f1(c1, 1)
    go f2(c2, 2)

    x := <-c1
    y := <-c2

    fmt.Println("Received", x, y)
}

The definitions of the two functions differ slightly even though they both perform the same functionality. The <- symbol, which can be found in the definition of the f2() function to the right of the keyword chan, is what causes the distinction.

This indicates that the c channel is a write-only channel. The Go compiler produces the following error message if the code of a Go function tries to receive from a write-only channel (also referred to as a send-only channel) parameter:

Uses of Go channels

1. Synchronization between goroutines

Channels can be used to ensure that one goroutine doesn't proceed until another goroutine has completed its work. This is particularly useful in scenarios where data needs to be shared between two or more goroutines, and it's important to ensure that the data isn't modified by multiple goroutines at the same time.

package main

import (
    "fmt"
    "time"
)

func worker(done chan bool) {
    fmt.Print("working...")
    time.Sleep(time.Second)
    fmt.Println("done")

    done <- true
}

func main() {
    done := make(chan bool, 1)
    go worker(done)

    <-done
}

In this example, we create a worker goroutine that does some work and then sends a true value to a channel called done to indicate that it has completed its work.

In the main goroutine, we wait for a value to be sent to the done channel using the <-done syntax. This ensures that the main goroutine won't exit until the worker goroutine has completed its work.

2. Fan-in/Fan-out

It can be used to distribute work among multiple goroutines, allowing for parallel processing of data. This is particularly useful when dealing with large datasets that can be processed independently.

package main

import (
    "fmt"
    "sync"
)

func worker(id int, jobs <-chan int, results chan<- int) {
    for j := range jobs {
        fmt.Println("worker", id, "processing job", j)
        results <- j * 2
    }
}

func main() {
    numJobs := 10
    jobs := make(chan int, numJobs)
    results := make(chan int, numJobs)

    // Create three worker goroutines
    numWorkers := 3
    var wg sync.WaitGroup
    for i := 1; i <= numWorkers; i++ {
        wg.Add(1)
        go func(workerId int) {
            defer wg.Done()
            worker(workerId, jobs, results)
        }(i)
    }

    // Send jobs to the workers
    for j := 1; j <= numJobs; j++ {
        jobs <- j
    }
    close(jobs)

    // Collect results from the workers
    wg.Wait()
    close(results)

    // Print the results
    for r := range results {
        fmt.Println(r)
    }
}

In this example, we create a channel called jobs to send work to the worker goroutines, and a channel called results to receive the results. We create three worker goroutines and start them in parallel. The worker function receives jobs from the jobs channel, processes them, and sends the results to the results channel.

In the main goroutine, we send 10 jobs to the jobs channel, close the channel to indicate that no more jobs will be sent, and wait for the worker goroutines to complete using a sync.WaitGroup. Once all the workers have completed, we close the results channel and print the results.

3. Futures and promises

Futures and promises are frequently used by developers in Go for requests and replies. For instance, we must include the following if we want to apply the async/await pattern:

package main

import (
	"fmt"
	"math/rand"
	"time"
)

func longTimedOperation() <-chan int32 {
	ch := make(chan int32)
	run := func() {
		defer close(ch)
		time.Sleep(time.Second * 1)
		ch <- rand.Int31n(300)
	}
	go run()
	return ch
}

func main() {
	ch := longTimedOperation()
	fmt.Println(<-ch)
	fmt.Println("Program exit")
}

We can easily send a random integer value to a channel, wait for it, and receive it by simulating a lengthy process with a 5-second delay.

4. Notifications

Unique requests or responses that deliver values are known as notifications. Since the size of a blank struct type is zero and its values don't require any RAM, we typically use it as the notification channel element type.

Implementing a one-to-one message with a channel, for instance, results in the following notification value:

package main

import (
    "fmt"
    "time"
)

type T = struct{}

func main() {
    completed := make(chan T)
    go func() {
        fmt.Println("ping")
        time.Sleep(time.Second * 5) // heavy process simulation
        completed <- struct{}{}     // send a value to completed channel
    }()
    <-completed // blocked waiting for a notification
    fmt.Println("pong")
}

This lets us use a value received from a channel to alert another Goroutine waiting to submit a value to the same channel.

Channels can also schedule notifications:

package main

import (
    "fmt"
    "time"
)

func scheduledNotification(t time.Duration) <-chan struct{} {
    ch := make(chan struct{}, 1)
    go func() {
        time.Sleep(t)
        ch <- struct{}{}
    }()
    return ch
}

func main() {
    <-scheduledNotification(time.Second)
    fmt.Println("send first")
    <-scheduledNotification(time.Second)
    fmt.Println("secondly send")
    <-scheduledNotification(time.Second)
    fmt.Println("lastly send")
}

5. Counting semaphores

Developers frequently use counting semaphores to lock and unlock concurrent processes to control resources and implement mutual exclusions in order to enforce a maximum number of concurrent requests. Developers, for instance, can manage read and write tasks in a database.

Similar to using channels as mutexes, there are two methods to gain ownership of a channel semaphore:

1. Taking possession after sending something and relinquishing it after receiving it

2. Taking control by sending a receive and relinquishing it by sending a send

When owning a channel semaphore, there are a few particular guidelines to follow. First off, each channel supports a specific data format for exchange, also known as the channel's element type.

Second, for a channel to function correctly, something needs to be received through the channel.

For instance, the chan keyword can be used to open a new channel, and the close() function can be used to end an existing channel. So, once the channel has been read from, if we block the code using the < - channel syntax, we can end it.

Finally, we can indicate a channel's direction when using it as a function parameter, indicating whether the channel will be used for sending or receiving.

Use this capability if we are aware of a channel's purpose in preparation because it makes programs more durable and secure. This implies that we cannot unintentionally send data to a channel that only gets data or unintentionally receive data from a channel that only sends data.

As a result, we receive an error message if we attempt to write to a channel function parameter after declaring that it will be used for reading only. This error message will likely prevent us from running into dangerous flaws.

Example:

package main

import (
    "fmt"
)

type Semaphore struct {
    count chan struct{}
}

func NewSemaphore(n int) *Semaphore {
    s := &Semaphore{
        count: make(chan struct{}, n),
    }
    for i := 0; i < n; i++ {
        s.count <- struct{}{}
    }
    return s
}

func (s *Semaphore) Acquire() {
    <-s.count
}

func (s *Semaphore) Release() {
    s.count <- struct{}{}
}

func main() {
    sem := NewSemaphore(2)

    sem.Acquire()
    fmt.Println("Acquired")
    sem.Acquire()
    fmt.Println("Acquired")

    go func() {
        sem.Acquire()
        fmt.Println("Acquired from goroutine")
        sem.Release()
    }()

    sem.Release()
    fmt.Println("Released")
    sem.Release()
    fmt.Println("Released")

    // Wait for goroutine to finish
    fmt.Scanln()
}

In this example, we define a Semaphore struct that contains a buffered channel count with capacity n, which represents the maximum number of resources that can be acquired at the same time. We then define two methods, Acquire and Release, that acquire and release a resource, respectively. Acquiring a resource means blocking until there is an available resource to use, while releasing a resource means adding it back to the pool of available resources.

In main, we create a Semaphore with capacity 2, which means we can acquire up to 2 resources at the same time. We then acquire two resources and print a message to the console for each one. We also spawn a goroutine that tries to acquire another resource and prints a message to the console when it succeeds. Finally, we release two resources and print a message to the console for each one.

Note that we use a buffered channel with capacity n to implement the counting semaphore, because channels with capacity behave like semaphores with n resources. We initialize the channel with n values to represent the available resources, and then use the channel to acquire and release resources.

6. Use of range in a Go channel

In Golang, we can iterate over a channel and retrieve its values by using range syntax. This iteration uses the first-in, first-out (FIFO) principle, which allows us to receive from the channel buffer as if it were a queue as long as we add data to it:

package main

import "fmt"

func main() {
    ch := make(chan string, 2)
    ch <- "one"
    ch <- "two"
    close(ch)

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

As previously stated, the FIFO concept is used when iterating from a channel using range. (reading from a queue). Thus, running the prior code produces the following results:

7. Select statement

The select statement allows a goroutine to wait for communication on multiple channels at the same time.

Here's a simple example. Let's say you have two channels, ch1 and ch2, and you want to wait for a value to be sent on either channel:

package main

import (
    "fmt"
)

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

    // Send values to channels
    go func() {
        ch1 <- 1
    }()
    go func() {
        ch2 <- 2
    }()

    // Wait for and print values received from channels
    select {
    case x := <-ch1:
        fmt.Println("Received from ch1:", x)
    case y := <-ch2:
        fmt.Println("Received from ch2:", y)
    }
}

The above code will wait until a value is sent on either ch1 or ch2. Whichever channel sends a value first will cause the corresponding case block to execute. If both channels have values ready at the same time, the select statement will choose one of them randomly.

The select statement can also be used to wait for communication on a channel with a timeout:

select {
case x := <-ch:
    fmt.Println("Received:", x)
case <-time.After(time.Second):
    fmt.Println("Timeout")
}

This code will wait for a value to be sent on the channel ch for up to one second. If no value is received within one second, the timeout case will execute.

Conclusion

A channel is a lock-free communication channel used by goroutines to interact with one another in the go programming language. Or, to put it another way, a channel is a method that enables data to be sent from one goroutine to another. The fact that a channel is by nature bidirectional means that goroutines can send and receive data through it.

Data of a particular element type can be shared between concurrently running functions using go channels. Channels are the most practical means of communication between goroutines when many of them are running concurrently.

In this blog, we have seen how to create go channels, and their uses like notifications and counting semaphores. We have also seen how to write and read in go channels. We also discussed the ways of using a go channel as function parameters.


Monitor Your Go Applications with Atatus

Atatus provides developers with insights into the performance of their Golang applications. By tracking requests and backend performance, Atatus helps identify bottlenecks in the application and enables developers to diagnose and fix errors more efficiently.

Go performance monitoring captures errors and exceptions that occur in Golang applications and provides a detailed stack trace, enabling developers to quickly pinpoint the exact location of the error and address it.

We provide flexible alerting options, such as email, Slack, PagerDuty, or webhooks, to notify developers of Golang errors and exceptions in real-time. This enables developers to address issues promptly and minimize any negative impact on the end-user experience.

Try Atatus’s entire features free for 14 days.

Vaishnavi

Vaishnavi

CMO at Atatus.
Chennai

Monitor your entire software stack

Gain end-to-end visibility of every business transaction and see how each layer of your software stack affects your customer experience.