20 minutes read

While learning about goroutines, you used time.Sleep to wait for data from the goroutine. This approach is acceptable in some cases, but there are a couple of limitations:

  • You can use the data from the goroutine to a very limited extent.

  • You need to make sure you allow enough time for the calculations.

  • There is no guarantee that the scheduler will run your goroutine in the allotted time.

  • You need to know the exact execution time of the subroutine so that you don't have to wait too long.

But still, the question remains — what is the correct way for goroutines to exchange data with each other or return values? That's what channels are for in Go. In this topic, you'll learn about channels and how to work with them; let's go!

What is a channel, and why do we need it?

A channel is a communication object through which you can send and receive values. They ensure that goroutines can communicate with each other. Technically, this is a pipeline (or pipe) from where data can be sent or received. That is, one goroutine can send data to the channel, and the other can receive the data through this channel.

Let's look at an example:

// doWork simulates a long computations with random execution times
func doWork(i int, resCh chan int) {
    fmt.Printf("  doWork %d started\n", i)
    time.Sleep(time.Duration(rand.Int63n(2000)) * time.Millisecond)
    resCh <- i
    fmt.Printf("    doWork %d finished\n", i)
}

func main() {
    fmt.Println("main started")
    resCh := make(chan int)
    start := time.Now()

    for i := 0; i < 3; i++ {
        fmt.Printf("main %d started\n", i)
        go doWork(i, resCh)
    }

    for i := 0; i < 3; i++ {
        fmt.Printf("main %d finished in %.3f second\n", <-resCh, time.Since(start).Seconds())
    }
    fmt.Println("main finished")
}

// Output:
// main started
// main 0 started
// main 1 started
// main 2 started
//   doWork 2 started
//   doWork 0 started
//   doWork 1 started
//     doWork 1 finished
// main 1 finished in 0.780 second
//     doWork 2 finished
// main 2 finished in 1.705 second
//     doWork 0 finished
// main 0 finished in 1.915 second
// main finished
🔍 Click to view additional possible outputs of the above code
// Output 2:
// main started
// main 0 started
// main 1 started
// main 2 started
//   doWork 0 started
//   doWork 2 started
//   doWork 1 started
//     doWork 0 finished
// main 0 finished in 0.898 second
//     doWork 1 finished
// main 1 finished in 1.483 second
//     doWork 2 finished
// main 2 finished in 1.904 second
// main finished

// Output 3:
// main started
// main 0 started
// main 1 started
// main 2 started
//   doWork 1 started
//   doWork 0 started
//   doWork 2 started
//     doWork 0 finished
// main 0 finished in 0.423 second
//     doWork 1 finished
// main 1 finished in 0.471 second
//     doWork 2 finished
// main 2 finished in 1.603 second
// main finished

Be careful when changing the above code. There should not be more receive operations from the channel (second cycle) than sends to the channel (starting goroutines). Otherwise, you will get a fatal error. You will learn the reasons for this behavior later, but for now, use this rule.

Try running this code on your PC and play around with the number of calculations running. You will get the expected result: goroutines run and send data to the channel as they perform calculations. On the other side of the channel, main receives the data. This way, you get data as it becomes available and end processing when the latest data arrives.

Each channel is capable of passing values of a specific type, which is called the channel element type. In the example above, an int channel was used. However, the channel can be of any type. Look at an example of declaring a channel variable with other types:

var floatCh chan float64     // channel of float64
var structCh chan struct{}   // channel of empty structs
var readerCh chan io.Reader  // channel of io.Reader interfaces
var chanCh chan chan int     // channel of channels ints
var sliceCh chan []time.Time // channel of time.Time slices

The channel can be any type of data available in Go. As you already know, a channel simply provides the ability to send data in one goroutine and receive it in another. However, the channel is a reference to a data structure. By copying it or passing it to a function as an argument, you will refer to the same structure.

Creating buffered and unbuffered channels

As with any reference data type in Go, declaring a channel with the var keyword will create a nil channel. You can pass it to a function, but sending to and receiving from it is not possible. How to create a working channel? You've probably already noticed that Go uses the make keyword to do this.

floatCh := make(chan float64)
sliceCh := make(chan []time.Time)

A channel created with a simple call to make is said to be unbuffered.

In the example above with the doWork function, you might notice one thing. While there is no data on one side of the channel, the receiving loop stops and waits for them to arrive. Receiving and sending into an unbuffered channel blocks the goroutine's thread of execution. Execution continues when a goroutine on the other end of a channel performs the corresponding action — sending or receiving. After that, both goroutines continue to work. Thus, communication over an unbuffered channel results in synchronized read and write operations.

You have already seen how main is waiting for writers to a channel. In the example below, you can see how main is waiting for readers from a channel.

The buffered channel has a queue of data elements (FIFO). The queue size is set during channel creation as an optional argument in make. A write operation to such a channel does not block the writing goroutine until the buffer is full. Let's look at the example below:

func chanReader(bufferedCh chan string) {
    for i := 0; i < 4; i++ {
        fmt.Println(<-bufferedCh)
    }
}

func main() {
    bufferedCh := make(chan string, 3)
    fmt.Println("capacity =", cap(bufferedCh))
    fmt.Println("length = ", len(bufferedCh))
    go chanReader(bufferedCh)

    for _, sym := range "ABCD" {
        bufferedCh <- string(sym)
        fmt.Println("length = ", len(bufferedCh))
    }
}

// Output:
// capacity = 3
// length =  0
// length =  1
// length =  2
// length =  3
// A
// B
// C
// D
// length =  0

You may notice the two built-in functions: len and cap. cap returns the size of the channel's buffer, and len is the number of elements in the channel.

As you can see, the channel is initially filled with elements. Once it reaches its capacity limit, the sending operations block until there's space in the buffer, and the sending goroutine waits until there's room for more items. After filling the buffer, elements are sent and received in order; this buffered channel optimization ensures that send operations proceed as long as there's capacity. Finally, the receiving operation takes precedence if the buffer is full or no goroutines are sending.

The choice between buffered and unbuffered channels can affect the program's correct operation. Unbuffered channels provide "receive/send" synchronization guarantees. At the same time, buffered channels allow you to smooth out the difference in operations performance by different program parts. In this case, the larger the buffer size, the stronger the difference can be smoothed out. But remember a few things:

  • If one of the parts of the program runs faster, then the channel will still be constantly filled. And you won't get constant waiting.

  • You will choose the buffer size based on the problem's conditions. Sometimes, a buffer of 1 is enough, and sometimes 1000.

  • You can increase the processing speed at some slow stages by adding handlers.

Note that the instruction below will create a channel with a buffer of 0. This is the unbuffered channel.

strCh := make(chan string, 0)

Reading and writing from channels

You may have already noticed how to write or read data from a channel.

func main() {
    intCh := make(chan int, 3) // buffered channel with capacity = 3
    x := 2

    intCh <- 0   // sending data into a chanel
    intCh <- 1   // sending data into a chanel
    intCh <- x   // sending variable value into a channel

    <-intCh      // receiving data from channel without using result
    y := <-intCh // receiving data with assignment value into variable
    y += <-intCh // receiving data with using value in expression

    fmt.Println(y)
}

// Output:
// 3

Channels also support receiving in a loop using the range keyword.

func channelReader(strCh chan string) {
    for sym := range strCh {
        fmt.Print(sym)
    }
}

func main() {
    hello := "Hello World!"
    strCh := make(chan string)
    go channelReader(strCh)

    for _, sym := range hello {
        strCh <- string(sym)
    }
    close(strCh)

    // this instruction transfers control to other goroutine
    // it's needed for channelReader to be able to finish reading
    runtime.Gosched()
}

// Output:
// Hello World!

This way of receiving the data stream is preferred. It allows you not to be tied to the size of the incoming data but to receive them as they arrive. In this case, the receiving will end when the channel is closed.

Close channels

The previous section mentioned closing a channel. How do you do this, and why should the channel be closed? After all, as you saw in this topic, closing the channel for the first time was used in the example above.

The close keyword is used to close a channel:

close(channelName)

Closing a channel sets a flag that no more values will be transmitted on this channel. After that, any attempts to send data into the channel will end in a panic. However, you can still receive data from a closed channel. In this case, you will first receive all the data that was sent to this channel. After that, you will get the zero value of the channel type.

As you might guess, in the example above, closing a channel was used to stop the range cycle in the channelReader function. It stops receiving data from the channel. However, you need to pay attention to:

  • Closing an unbuffered channel will stop the range loop immediately, even if you have goroutines waiting to send data into the channel

  • From the buffered channel, all the data sent to it before closing will first be received, only then the loop will stop

In the case when you use range, you are guaranteed to receive only the data written to the channel. However, what if you don't know if the channel is closed or not? Go has a special syntax for this, and it is similar to checking a value in a map.

value, ok := <- ch // ok informs that the channel is open (true) or closed (false)
func main() {
    timeCh := make(chan time.Time, 3)

    for i := 0; i < 3; i++ {
        timeCh <- time.Now()
        time.Sleep(time.Second)
    }
    close(timeCh)

    for i := 0; i < 5; i++ {
        if value, ok := <-timeCh; ok {
            fmt.Println(value.Format("15:04:05"), "open")
        } else {
            fmt.Println(value.Format("15:04:05"), "closed")
        }
    }
}

// Output:
// 11:57:12 open
// 11:57:13 open
// 11:57:14 open
// 00:00:00 closed
// 00:00:00 closed

Remember that in most cases, it's better to have one function where you make a channel, send data, and close it. However, this approach might not be necessary for smaller programs when you're sure there won't be any data sent to a channel that's already closed or that hasn't been created.

By closing the channel, you also tell the receiver goroutine that there will be no more data and that you can exit; this way, you avoid goroutine leaks, loop freezes, or fatal errors when trying to receive data from a channel that no longer sends data.

Conclusion

Now you know enough about channels to start using them in your code:

  • What are the channels, and how to work with them.

  • How to send and receive data from a channel.

  • Why and how to close channels.

Now is the time to get down to practice!

19 learners liked this piece of theory. 2 didn't like it. What about you?
Report a typo