author
Kevin Kelche

Golang Channels - A Complete Guide


What is a channel?

A channel is a way for goroutines to communicate with each other. It is a typed conduit through which you can send and receive values with the channel operator,<-. You can think of them like pipes that connect concurrent goroutines. They allow you to pass values between goroutines and synchronize their execution.

Creating a channel

Channels have a type associated with them i.e. chan T where T is the type of values the channel is allowed to transport. You can create a new channel with the make built-in function:

ch := make(chan int)

Copied!

This is an unbuffered channel of type int. Unbuffered channels will block until a receiver is ready to receive the sent value.

Buffered Channels

A buffered channel is a channel that can hold a limited number of values. You can create a buffered channel by passing a second argument to the make function:

ch := make(chan int, 100)

Copied!

This creates a channel that can hold up to 100 values of type int. If you try to send more than 100 values into the channel, the send will block until there is room for the value.

Buffered channels are useful when you want to limit the number of goroutines that can access a resource or service at the same time. For example, if you want to limit the number of concurrent HTTP requests to a server, you can use a buffered channel to limit the number of goroutines that can access the server at the same time.

Sending and Receiving

You can send values into a channel using the channel operator, <-. This sends the value on the left into the channel on the right:

main.go
package main

import "fmt"

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

    go func() {
        ch <- 200
    }()
    fmt.Println(<-ch)
}

Copied!

In this example, we create a channel and send it the value 200 in a goroutine. Then we receive the value in the fmt.Println statement.

Sending and Receiving from a Buffered Channel

Sending and receiving from a buffered channel works the same way as an unbuffered channel. The only difference is that a buffered channel will not block when sending a value if there is room in the buffer.

main.go
package main

import "fmt"

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

    ch <- 200
    fmt.Println(<-ch)
}

Copied!

Closing a Channel

Channels are not closed by default. They need to be closed explicitly with the Close method to indicate that no more values will be sent on the channel. This is important because it allows the receiving goroutine to know when the channel is empty and all values have been received and avoid any panic.

main.go
package main

import "fmt"

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

    go func() {
        ch <- 200
        close(ch)
    }()

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

Copied!

To avoid panic we can use the ok idiom to check if the channel closed gracefully.

main.go
package main

import "fmt"

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

    go func() {
        ch <- 200
        close(ch)
    }()

    for {
        v, ok := <-ch
        if !ok {
            break
        }
        fmt.Println(v)
    }
}

Copied!

Select

Select is a control structure that allows a goroutine to wait on multiple communication operations. A select blocks until one of its cases can run, then it executes that case. It chooses one at random if multiple are ready.

Lets’s use an example with a context to understand how select works.

main.go
package main

import (
    "context"
    "fmt"
    "time"
)

func someFunc(ctx context.Context) {
    for {
        select {
        case <-ctx.Done():
            fmt.Println("Done")
            return
        default:
            fmt.Println("Working...")
            time.Sleep(1 * time.Second)
        }
    }
}

func main() {
    ctx, cancel := context.WithCancel(context.Background())
    go someFunc(ctx)
    time.Sleep(5 * time.Second)
    cancel()
    time.Sleep(1 * time.Second)
}

Copied!

In this example, we use select in the someFunc function to wait for the context to be canceled. When the context is canceled, the Done channel is closed and the select statement will execute the case <-ctx.Done() statement.

To explore more on contexts check out this article

Select with Default Case

The default case in a select is run if no other case is ready. This is useful for a non-blocking select that either does something or does nothing if none of the cases is ready.

main.go
package main

import "fmt"

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

    select {
    case v := <-ch:
        fmt.Println(v)
    default:
        fmt.Println("No value ready")
    }

    ch <- 200

    select {
    case v := <-ch:
        fmt.Println(v)
    default:
        fmt.Println("No value ready")
    }
}

Copied!

In the first select statement, there is no value ready to be received from the channel, so the default case is executed. In the second select statement, there is a value ready to be received from the channel, so the case v := <-ch: is executed.

Synchronization and Concurrency

Channels are a great way to synchronize and coordinate goroutines. This allows you to perform complex operations involving multiple inputs and outputs, without resorting to complex locking mechanisms such as mutexes.

One way to use channels to synchronize goroutines is to use a channel as a signal to indicate when a goroutine has finished. For example, if you have a function that takes a long time to run, you can use a channel to signal when it has finished.

main.go
package main

import (
    "fmt"
    "sync"
)

func main() {
    var wg sync.WaitGroup
    ch := make(chan bool, 1)
    var counter int

    for i := 0; i < 1000; i++{
        wg.Add(1)
        go func() {
            ch <- true
            counter++
            <-ch
            wg.Done()
        }()
    }

    wg.Wait()
    fmt.Println(counter)
}

Copied!

In this example, a channel is used to create a blocking mechanism until the counter is incremented. This ensures that only one goroutine can increment the counter at a time thus preventing a race condition. The buffered channel has a capacity of 1, so only one goroutine can send a value into the channel at a time.

Another synchronization mechanism with channels was mentioned in the select section.

Advanced Channel Usage

Fan In and Fan Out Patterns

The fan-out and fan-in patterns are used to distribute work across multiple workers, then combine the results of the workers. This is a common pattern in concurrent programming when dealing with heavy workloads such as data processing.

In a fan-out pattern, you create a channel that will receive the workload, then create multiple workers that will read from the channel and perform the work. The workers will then write the results to a channel that will be used to combine the results.

In a fan-in pattern, you create a separate channel for receiving the results of each worker, then create a single goroutine that will read from the channels and combine the results.

Here’s an example that generates a series of random numbers, distributes them to multiple worker goroutines that square them, and finally combines the results:

main.go
package main

import (
    "fmt"
    "sync"
    "time"
)

func worker(id int, jobs <-chan int, results chan<- int) {
    for j := range jobs {
        time.Sleep(time.Second)
        results <- j * j
    }
}


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

    for w := 1; w <= 3; w++ {
        go worker(w, jobs, results)
    }

    for j := 1; j <= numJobs; j++ {
        jobs <- j
    }
    close(jobs)

    for a := 1; a <= numJobs; a++ {
        fmt.Println(<-results)
    }
}

func main() {
    var wg sync.WaitGroup
    wg.Add(1)
    go func() {
        fanOutFanIn()
        wg.Done()
    }()
    wg.Wait()
}

Copied!

Implementing pub-sub pattern

A pub-sub (Publisher-Subscriber) pattern allows multiple subscribers to receive updates from a single publisher. In Go, you can implement this pattern using a channel that represents the publisher’s output, and several channels that represent the subscribers’ inputs.

Here is an example that creates a publisher that sends random numbers every second, and three subscribers that receive the numbers and print them to the console:

main.go
package main

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

func publisher(out chan<- int) {
    for {
        time.Sleep(time.Second)
        out <- rand.Intn(100)
    }
}


func subscriber(in <-chan int, id int) {
    for {
        fmt.Printf("Subscriber %d received: %d\n", id, <-in)
    }
}

func pubSub() {
    out := make(chan int)
    for i := 1; i <= 3; i++ {
        go subscriber(out, i)
    }
    publisher(out)
}

func main() {
    var wg sync.WaitGroup
    wg.Add(1)
    go func() {
        pubSub()
        wg.Done()
    }()
    wg.Wait()
}

Copied!

Conclusion

Channels are a powerful tool for synchronizing and coordinating goroutines. In this article, we explored the basics of channels, and how to use them to synchronize and coordinate goroutines. We also looked at some advanced channel usage such as the fan-out and fan-in patterns, and the pub-sub pattern. By leveraging channels, you can create powerful concurrent programs that are easy to reason about.

Subscribe to my newsletter

Get the latest posts delivered right to your inbox.