DEV Community

Eyo
Eyo

Posted on • Updated on

A straightforward guide for Go channel

Channel is one of Go's most powerful features and serve as the foundation for many concurrency patterns. In this article, I'll introduce channel in a straightforward way, aiming to give you a solid understanding of how it works and why it's so useful.

Before we dive in, I assume you have a basic grasp of concurrency concepts and Go fundamentals. We won't be covering the basics of goroutines or Go syntax here.

All the code examples in this article can be found here.

 

Overview

Before diving into the technical details, let's understand channels from a high-level perspective using a simple analogy.

Imagine two friends, John and Emma, living in a world without internet or postal services. John wants to send a gift to Emma. How can he do it?

A basic approach might be for John and Emma to share a box in a neutral location. John would place the gift in the box, and Emma would retrieve it later. However, this method has a significant drawback: synchronization. Emma doesn't know exactly when John will leave the gift, so she'd have to check the box repeatedly, potentially wasting time and effort.

To solve this problem, let's introduce a more efficient solution: a magic pipe. This pipe can instantly transport the gift from John to Emma. John simply puts the gift into his end of the pipe, and it appears at Emma's end, ready for her to collect.

simple channel analogy

In this analogy, the magic pipe represents a channel in Go. John and Emma represents different goroutines, while the gift represents the data being transferred. Just as the magic pipe provides a direct, synchronized way to send gifts, a channel in Go offers a synchronized method for sending data between goroutines.

This is the essence of channels in Go: they provide a way to send data from one goroutine to another, ensuring smooth communication and synchronization in concurrent programs.

 

Simple Example

Let's examine a simple example to see channels in action:

func main() {
    // create a channel
    ch := make(chan int)

    // start a goroutine to send data to the channel
    go func() {
        // send data to the channel
        ch <- 1
    }()

    // receive data from the channel
    val := <-ch
    fmt.Println(val)
}

/*
Output:
1
*/
Enter fullscreen mode Exit fullscreen mode

In this code, we:

  • Create a channel ch
    • Just like map and slice, channel is also a built-in type in Go. We use make function to create a channel.
    • Because channel is a built-in type, we can pass or return it like other types.
    • For each channel, we need to specify the type of the data it will carry. In this case, the channel will carry int data.
  • Send data to the channel
    • When <- operator is on the right side of the channel, it's sending operation.
  • Receive data from the channel
    • When <- operator is on the left side of the channel, it's receiving operation.

To better visualize this process, let's look at a diagram:Channel Code Example DiagramThe diagram illustrates the flow of data between goroutines through the channel. Notice the brief waiting period in the main goroutine between attempting to receive data and successfully receiving it. This waiting is due to the nature of channel, which we'll explore in more detail later.
For now, the key takeaway is that channels provide a powerful tool for sending data between goroutines, enabling smooth communication in concurrent Go programs.

 

Unbuffered vs. Buffered Channel

Go offers two types of channels: unbuffered and buffered. Let's explore each type to understand their unique characteristics and behaviors.

Unbuffered Channels

Unbuffered channels are created without specifying a buffer size, like in our earlier example. The key feature of unbuffered channels is their synchronous nature: both sending and receiving operations block until the other side is ready.

To better understand this, let's revisit our John and Emma analogy. If John wants to send a gift to Emma using the magic pipe (unbuffered channel), but Emma isn't ready to receive it, John must wait until Emma is prepared. Similarly, if Emma wants to receive a gift, she has to wait until John puts one in the pipe.

Let's see this in action with a code example:

func main() {
    // create an unbuffered channel
    ch := make(chan int)

    go func() {
        fmt.Println("Sending value 1 to channel")
        ch <- 1
        fmt.Println("After sending value 1")
    }()

    // sleep for 3 seconds to ensure the goroutine has time to send the value
    time.Sleep(3 * time.Second)

    fmt.Println("Receiving value from channel")
    val := <-ch
    fmt.Println(val)

    // sleep 1 second to ensure goroutine has time to finish goroutine
    time.Sleep(1 * time.Second)
}

/*
Output:
Sending value 1 to channel
(... waits for 3 seconds ...)
Receiving value from channel
1
After sending value 1
*/
Enter fullscreen mode Exit fullscreen mode

Here's what's happening:

  1. We create an unbuffered channel ch.
  2. A goroutine is started to send data to the channel.
  3. The main goroutine sleeps for 3 seconds.
  4. While the main goroutine is sleeping, the other goroutine tries to send data to the channel.
    • Because the main goroutine isn't ready to receive the data (it's sleeping), the sending operation is blocked.
  5. After 3 seconds, the main goroutine wakes up and receives the data from the channel.

This process is visualized in the following diagram:unbuffered channel diagramThe red line in the diagram shows that the child goroutine (sender) must wait until the main goroutine (receiver) is ready to receive the data (after sleeping for 3 seconds).

Let's examine a more complex example:

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

    go receiver(ch)
    fmt.Println("Sending value 1 to channel")
    ch <- 1

    fmt.Println("Sending value 2 to channel")
    ch <- 2

    fmt.Println("Sending value 3 to channel")
    ch <- 3

    // sleep 3 seconds to ensure the goroutine has time to finish goroutine
    time.Sleep(3 * time.Second)
}

func receiver(ch chan int) {
    val := 0
    for val != 3 {
        val = <-ch

        // sleep 5 seconds if the received value is 1
        if val == 1 {
            time.Sleep(5 * time.Second)
        }

        fmt.Println("Received:", val)
    }
}
Enter fullscreen mode Exit fullscreen mode

Can you predict when does this program sleep?

 

 

The output of this program is:

Sending value 1 to channel
Sending value 2 to channel
(... waits for 5 seconds ...)
Received: 1
Received: 2
Sending value 3 to channel
Received: 3
Enter fullscreen mode Exit fullscreen mode

A key point to note is that the sending operation ch <- 1 completes as soon as the value is handed off to the receiver. It doesn't wait for the receiver to finish processing the value. In other words, once a sender successfully sends a value, it can continue its goroutine without waiting for the receiver to process the value.

Here's a diagram illustrating this process:unbuffered channel diagramIn this example, the child goroutine(receiver) blocks the main goroutine(sender) because it sleeps for 5 seconds after receiving value 1. Consequently, the main goroutine must wait until the child goroutine wakes up and is ready to receive the next value.

Buffered Channels

Now that we understand unbuffered channels, let's explore buffered channels.

Buffered channels are created by specifying a buffer size. The key feature of buffered channels is their asynchronous nature: sending and receiving operations are not blocking until the buffer is full or empty.
However, when the buffer is full, the sending operation is blocked until the receiver makes some space in the buffer by receiving data. Same as when the buffer is empty, the receiving operation is blocked until the sender fills some data into the buffer.

It's important to note that messages are received in the same order they were sent (FIFO - First In, First Out).

Let's look at an example of a buffered channel:

func main() {
    // create a buffered channel with capacity 2
    ch := make(chan int, 2)

    // start timing to calculate the time elapsed
    start := time.Now()

    go sender(ch, start)
    go receiver(ch, start)

    // delay for two goroutines to complete
    time.Sleep(6 * time.Second)
}

func sender(ch chan<- int, start time.Time) {
    for i := 1; i <= 3; i++ {
        fmt.Printf("Sending %d at %s\n", i, time.Since(start))
        ch <- i
        fmt.Printf("Sent %d at %s\n", i, time.Since(start))
    }
}

func receiver(ch <-chan int, start time.Time) {
    // delay start to demonstrate buffer filling up
    time.Sleep(2 * time.Second)

    for i := 1; i <= 3; i++ {
        val := <-ch
        fmt.Printf("Received %d at %s\n", val, time.Since(start))

        // sleep to simulate slow receiver
        time.Sleep(1000 * time.Millisecond)
    }
}

/*
Output:
Sending 1 at 9.958µs
Sent 1 at 266.166µs
Sending 2 at 285.833µs
Sent 2 at 288.166µs
Sending 3 at 289.458µs
Sent 3 at 2.002000333s
Received 1 at 2.001790041s
Received 2 at 3.003317541s
Received 3 at 4.004426375s
*/
Enter fullscreen mode Exit fullscreen mode

In this example, we create a buffered channel with a capacity of 2. We then start two goroutines: sender and receiver.

  • The sender goroutine sends data to the channel:
    • It sends values 1, 2, and 3 to the channel without blocking for the first two values.
  • The receiver goroutine receives data from the channel:
    • It first delays for 2 seconds to allow the buffer to fill up.
    • Then, it receives values 1, 2, and 3 from the channel.
    • For each value, it sleeps for 1 second to simulate a slow receiver.

As we can see from the output, the first two values are sent without blocking. However, when it comes to value 3, the sender has to wait until the receiver makes some space in the buffer by receiving value 1. After that, the sender can send value 3 to the channel.

Here's a diagram visualizing this process:buffered channel diagramThe main takeaway from this diagram is that the sender goroutine has to wait after sending value 2 because the buffer is full. After the receiver receives value 1, it makes some space in the buffer, allowing the sender to send value 3 to the channel.

 

Direction Channel

Let's revisit our previous example where we created two goroutines: sender and receiver. As their names suggest, sender sends data to the channel, while receiver receives data from it. This scenario introduces us to the concept of directional channels in Go.

Directional channels are channels that are restricted to either sending or receiving operations. Go allows us to specify the direction of a channel, making it either send-only or receive-only. This feature enhances type safety and clarifies the intended use of a channel within a function.

We can specify the direction of a channel using the following syntax:

  • chan<- for a send-only channel
  • <-chan for a receive-only channel

Just like the last example, we specify the direction at the function signature.

func sender(ch chan<- int, start time.Time) {}
func receiver(ch <-chan int, start time.Time) {}
Enter fullscreen mode Exit fullscreen mode

In the sender function, ch chan<- int indicates that ch is a send-only channel. Similarly, in the receiver function, ch <-chan int specifies that ch is a receive-only channel.

Specifying the direction of a channel is crucial for maintaining type safety in your Go programs. When you design a function to only send data, you can pass a send-only channel to that function. This prevents accidental attempts to receive data from the channel within the function, which would be a compile-time error.

By using directional channels, you make your code's intentions clearer and reduce the likelihood of errors related to channel operations. This practice is particularly valuable in larger codebases where multiple functions might interact with the same channel in different ways.

 

Close Channel

In Go, we can close a channel using the close function. The primary purpose of closing a channel is to notify receivers that no more data will be sent, allowing them to stop waiting for additional data.

Let's examine the behavior of closing a channel for both senders and receivers:

For senders, attempting to send data on a closed channel will cause a panic:

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

    fmt.Println("Attempting to send to a closed channel...")
    ch <- 1
}

/*
Output:
Attempting to send to a closed channel...
panic: send on closed channel
*/
Enter fullscreen mode Exit fullscreen mode

 

For receivers, reading from a closed channel immediately returns the zero value of the channel's type without blocking. We can use the "comma ok" idiom to check whether a channel is closed:

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

    close(ch)

    fmt.Println("Reading from a closed channel:")
    value, ok := <-ch
    fmt.Printf("Value: %v, Channel open: %v\n", value, ok)
}

/*
Output:
Reading from a closed channel:
Value: 0, Channel open: false
*/
Enter fullscreen mode Exit fullscreen mode

The "comma ok" idiom is crucial because it's the only way to distinguish between receiving a zero value from an open channel and receiving from a closed channel.

 

From the above example, we can see that the sender can't send data to a closed channel, but the receiver can still receive data from a closed channel. Therefore, it's important to note that the responsibility of closing a channel lies with the sender, not the receiver. In Go concurrency patterns, it's common to see the function have following patterns:

  1. Create a channel
  2. Start a goroutine to send data to the channel
    • Inside the goroutine, close the channel after sending all data
  3. Return the channel as receive-only channel to the caller

Here's a simple example of this pattern:

func foo() <-chan int {
  // create a channel
  ch := make(chan int)

  // start a goroutine to send data to the channel
  go func() {
    // close the channel after sending all data
    defer close(ch)

    // send data to the channel
    for i := 0; i < 3; i++ {
      ch <- i
    }
  }()

  // return the channel
  return ch
}
Enter fullscreen mode Exit fullscreen mode

This pattern, though simple, is widely used in the Go concurrency patterns. The key takeaway is that this function creates a channel, and sends data to the channel, so it's responsible for closing the channel.

 

In Go concurrency patterns, it's common to see receivers continuously reading from a channel until it's closed. Here's an example:

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

    go sender(ch)
    go receiver(ch)

    time.Sleep(2 * time.Second)
}

func sender(ch chan<- int) {
    for i := 0; i < 2; i++ {
        ch <- i
    }
    close(ch)
}

func receiver(ch <-chan int) {
    // keep receiving data from the channel in an infinite loop
    for {
        value, ok := <-ch

        // if the channel is closed, break the loop
        if !ok {
            fmt.Println("Channel closed")
            break
        }

        fmt.Printf("Received value: %v\n", value)
    }
}
Enter fullscreen mode Exit fullscreen mode

In this example, the sender sends two values to the channel and then closes the channel. The receiver keeps receiving data from the channel in a infinite loop until the channel is closed.

However, Go provides a more idiomatic way to achieve this using the range keyword:

func receiver(ch <-chan int) {
    for value := range ch {
        fmt.Printf("Received value: %v\n", value)
    }
}
Enter fullscreen mode Exit fullscreen mode

When using range, the loop automatically breaks when the channel is closed, making the code more concise and readable.

 

When using range to receive data from the channel, always remember to close the channel if the receiver continuously receiving data, otherwise, the program will deadlock.

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

    go func() {
        for i := 0; i < 3; i++ {
            ch <- i
        }
        // oops, forgot to close the channel after sending all data
    }()

    // keep receiving data from the channel
    for v := range ch {
        fmt.Println(v)
    }
}

/*
Output:
0
1
2
fatal error: all goroutines are asleep - deadlock!
*/
Enter fullscreen mode Exit fullscreen mode

In this example, after sending three values, the goroutine exits without closing the channel. The main goroutine continues waiting for more data, resulting in a deadlock. Go's runtime is smart enough to detect that no other goroutine can send data to the channel, leading to this error.

 

Implementing Channel

Throughout this article, we've been using Go's built-in channels. However, we can implement our own channel to solidify our understanding.

Note: The implementation below involves other concurrency primitives, such as condition variables. If you're not familiar with these concepts, feel free to skip this section. However, I'll do my best to explain everything as clearly as possible.

 

Before diving into the implementation, let's take a step back and consider what behaviors our channel needs to satisfy:

  • A data structure to hold the queue of messages
  • An initialization function to create a channel with a given capacity and specific type
  • A send function to add data to the channel (with waiting behavior when the channel is unbuffered or full)
  • A receive function to retrieve data from the channel (with waiting behavior when the channel is unbuffered or empty)
  • A close function to close the channel

Now that we've outlined the requirements, let's break down the implementation into a series of questions we need to answer:

  1. What information should we track about the channel?
  2. How do we implement the send function?
  3. How do we implement the receive function?
  4. How do we implement the close function?

By answering these questions, we'll have successfully implemented our own channel.

In the following sections, we'll address each of these questions one by one, building our channel implementation step by step.

 

1. What information should we keep track of the channel?

To implement our own channel, we need to consider several key pieces of information:

  • Data Structure
    • We need a data structure that exhibits FIFO (First In, First Out) behavior. This could be a slice or a linked list. For our implementation, we'll use a linked list.
  • Capacity
    • We need to keep track of the channel's capacity. This is crucial because senders must wait when the channel is full, and receivers must wait when it's empty.
  • Closed Flag
    • We need a flag to indicate whether the channel has been closed.
  • Synchronization Mechanism:
    • To properly synchronize the send and receive operations, there are three key behaviors we need to implement:
      • Ensures only one goroutine can send or receive data at a time, avoiding race conditions.
      • Allows goroutines to wait until certain conditions are met (e.g., a sender waiting until the channel isn't full).
      • Enables goroutines to notify others when conditions change (e.g., a receiver notifying senders after receiving data).
    • We'll use sync.Cond to implement this mechanism. For now, don't worry about the details of sync.Cond - just focus on the behaviors it provides.

With these requirements in mind, let's implement our channel struct and initialization function:

type Channel[M any] struct {
    queue    *list.List
    capacity int
    cond     *sync.Cond
    closed   bool
}

func NewChannel[M any](capacity int) *Channel[M] {
    return &Channel[M]{
        queue:    list.New(),
        capacity: capacity,
        cond:     sync.NewCond(&sync.Mutex{}),
        closed:   false,
    }
}
Enter fullscreen mode Exit fullscreen mode

Let's break down each component:

  • queue: This is our linked list that will hold the messages.
  • capacity: This tracks the channel's capacity, set during initialization.
  • cond: This is our synchronization mechanism, providing the behaviors we described earlier.
  • closed: This flag indicates whether the channel has been closed, initially set to false.

Note that we're using a generic type M to specify the type of data the channel will carry. This allows our channel to work with any data type, providing flexibility in its usage.

 

2. How to implement the send function?

The main purpose of the send function is to add data to the channel. However, we need to consider several scenarios to ensure proper functionality:

  • Before any operation on the channel, we need to make sure only one goroutine can access the channel at a time.
  • If channel is closed, we need to return an error.
  • If channel is full, we need to make the sender wait(sleep).
  • If the channel is not full, we can add the data to the channel.
  • After sending the data, we need to notify all other goroutines to wake up

Before diving into the code implementation, let's visualize the send function's process with a diagram:Send processThis diagram illustrates the step-by-step process of the send function. I would like to point out two important things in this diagram.

The first important point is how we handle a full channel. When the channel reaches capacity, the sender must wait. But what does "waiting" mean in this context? Essentially, we put the current goroutine to sleep, allowing other goroutines (receivers or other senders) to access the channel. Once awakened, the goroutine rechecks the channel's status and proceeds if space is available. This process repeats in a loop until the channel has room for new data.

The second key aspect is what happens after a successful send operation. After successfully sending data to the channel, the sender wakes up other goroutines. This step is crucial because we know that sender might sleep for a while, that means other goroutines might also enter a sleep state. Therefore, we need to wake up other goroutines after execution. This is the exact same action we see in the bottom-right corner of the diagram where other goroutines wake it up. It basically means "Hey, I've done my job, you can continue your work!" This ensures smooth coordination between all goroutines interacting with the channel.

Let's start by writing pseudo-code for the send function:

func (c *Channel[M]) Send(message M) error {
  // Acquire exclusive access to the channel

  // If channel is closed, return an error

  // If channel is full, wait for the channel to have space

  // Add the data to the queue

  // Notify all other goroutines to wake up
}
Enter fullscreen mode Exit fullscreen mode

With this structure in mind, let's implement the actual code

func (c *Channel[M]) Send(message M) error {
    c.cond.L.Lock()
    defer c.cond.L.Unlock()

    if c.closed {
        return errors.New("send on closed channel")
    }

    for c.queue.Len() == c.capacity {
        c.cond.Wait()
    }

    c.queue.PushBack(message)
    c.cond.Broadcast()
    return nil
}
Enter fullscreen mode Exit fullscreen mode

This implementation directly translates our pseudo-code into concrete Go syntax. Let's walk through it step by step:

  1. We start by acquiring exclusive access to the channel with c.cond.L.Lock().
  2. We use defer c.cond.L.Unlock() to ensure the lock is released when the function exits, regardless of how it exits.
  3. We check if the channel is closed. If it is, we return an error immediately.
  4. If the channel is full, we call c.cond.Wait(). This puts the goroutine to sleep until the channel has space.
  5. Once we can proceed, we add the message to the queue.
  6. Finally, we call c.cond.Broadcast() to notify all waiting goroutines to wake up.

Let's break down the key concurrency primitives we used above:

  • c.cond.L.Lock() and defer c.cond.L.Unlock():
    • We can think of Lock as a function to require exclusive access to the channel. Only one goroutine can acquire the lock at a time. Other goroutines that require the lock have to wait until the lock is released.
    • Unlock is the counterpart of Lock. It releases the lock so that other goroutines can acquire the lock.
  • c.cond.Wait():
    • This function performs two atomic operations:
      • (a) Releases the lock
      • (b) Pauses the goroutine
    • We can think of Wait as a function to say "Hey, I give up my exclusive access for now, I'll sleep for a while, and wait for others to wake me up."
  • c.cond.Broadcast():
    • This function is used to signal all other waiting(sleeping) goroutines to wake up.

Let's take a closer look at the send function's waiting mechanism:

    for c.queue.Len() == c.capacity {
        c.cond.Wait()
    }
Enter fullscreen mode Exit fullscreen mode

The process of this code is as follows:

  1. Because the channel is full, I'll give up my exclusive access and wait until other goroutines wake me up.
  2. Now I wake up, let's check again if the channel is still full.
  3. Uh-oh, the channel is still full, let me wait again. I give up my exclusive access and go back to sleep.
  4. Now I wake up again, let's check again if the channel is still full.
  5. Yay, the channel is no longer full, I can break out of the loop and continue my execution.

From this process, we can see that Wait() is a mechanism to make the goroutine wait until other goroutines wake it up. Also, note that Broadcast() is simply to notify all the goroutines waiting on the channel to wake up, so that they can re-check the conditions and proceed with their execution.

By using these primitives, we ensure that our send function operates correctly in a concurrent environment, handling full channels, closed channels, and notifying other goroutines when the channel's state changes.

 

3. How to implement the receive function?

Having implemented the send function, we might assume that the receive function would follow a similar pattern. Initially, we might consider the following requirements:

  • Before any operation on the channel, we need to make sure only one goroutine can access the channel at a time.
  • If channel is closed, we simply return the default value of the channel's type and a boolean indicating closure without waiting.
  • If channel is empty, we need to make the receiver wait.
  • If the channel is not empty, we can successfully receive data.
  • After receiving the data, we need to notify all other goroutines to wake up.

Based on these considerations, let's draft an initial pseudo-code:

func (c *Channel[M]) Receive() (M, bool) {
  // Acquire exclusive access to the channel

  // If channel is closed, immediately return the default value and false

  // If channel is empty, wait for the channel to have a message

  // Remove the data from the queue

  // Notify all other goroutines to wake up
}
Enter fullscreen mode Exit fullscreen mode

However, before implementing the actual code, let's consider a potential issue with this approach.

Suppose the channel is unbuffered, we first send a value to the channel, then we receive the value from the channel.
In this case, it will cause a deadlock, like following:deadlock exampleWhen sender sends a value to the channel, it has to wait because the queue size is equal to the channel's capacity. Then, the receiver also has to wait because the queue is empty. Both of them are waiting for each other, causing a deadlock.

To resolve this issue, we can modify our approach. We'll temporarily increase the channel's capacity by 1 and notify the sender to wake up. This allows the sender to successfully add data to the channel. After receiving the data, we'll decrease the capacity back to its original value and notify the sender again.

Before correcting the pseudo-code, let's first visualize the process using a diagram:Receive processWhile the process appears complex, it shares some similarities with the send function. For instance, the receiver must wait when the channel is empty, which is similar to the sender waiting when the channel is full. However, there are three things we need to pay attention to.

Firstly, upon detecting a closed channel, the function immediately returns the default value and false without waiting.

Secondly, after confirming the channel is open, we take a unique step: increasing the channel's capacity by 1 and notifying any waiting senders. This allows a sender to add data to the channel, effectively preventing potential deadlocks.

Lastly, once the receiver successfully retrieves data from the channel, it reverses the earlier capacity increase. It decreases the capacity by 1 and again notifies any waiting senders. This maintains the channel's original capacity while ensuring smooth communication between senders and receivers.

Let's update our pseudo-code with this improved logic:

func (c *Channel[M]) Receive() (M, bool) {
  // Acquire exclusive access to the channel

  // If channel is closed, immediately return the default value and false

  // Increase the capacity of the channel by 1
  // Notify all other goroutines to wake up

  // If channel is empty, wait for the channel to have a message

  // Retrieve and remove first item from queue

  // Decrease the capacity of the channel by 1

  // Notify all other goroutines to wake up
}
Enter fullscreen mode Exit fullscreen mode

Now, let's implement the actual code based on this pseudo-code:

func (c *Channel[M]) Receive() (M, bool) {
    c.cond.L.Lock()
    defer c.cond.L.Unlock()

    if c.closed {
        var zero M
        return zero, false
    }

    c.capacity++
    c.cond.Broadcast()

    for c.queue.Len() == 0 {
        c.cond.Wait()
    }

    message := c.queue.Remove(c.queue.Front()).(M)
    c.capacity--
    c.cond.Broadcast()
    return message, true
}
Enter fullscreen mode Exit fullscreen mode

This implementation translates our pseudo-code into concrete Go syntax. Let's walk through it step by step:

  1. We start by acquiring exclusive access to the channel with c.cond.L.Lock().
  2. We use defer c.cond.L.Unlock() to ensure the lock is released when the function exits, regardless of how it exits.
  3. We check if the channel is closed. If it is, we return the zero value of type M and false.
  4. We temporarily increase the channel's capacity and broadcast to wake up any waiting senders.
  5. If the channel is empty, we enter a loop where we wait for data to arrive.
  6. Once we have data, we remove the first item from the queue using Remove(c.queue.Front()).
  7. We decrease the capacity back to its original value.
  8. We broadcast again to notify any waiting senders that there's now space in the channel.
  9. Finally, we return the received message and true to indicate successful reception.

Let's check again to see if we solve the deadlock problem we mentioned before:deadlock solved

  1. The sender attempts to send data to the channel but finds it full, as the size equals the capacity. So, sender has to wait.
  2. Before the receiver receives data, it first increments the channel's capacity by 1 and broadcasts to wake up any waiting senders. Then, it also has wait because the channel is empty.
  3. After waking up, the sender rechecks the condition and finds space available (thanks to the increased capacity). It successfully sends data to the channel and broadcasts to wake up the waiting receiver.
  4. The receiver wakes up and finds the channel no longer empty. It successfully receives data from the channel, decreases the capacity back to its original value, and broadcasts to wake up any waiting senders.

Now, we can see that the deadlock problem is solved.

 

4. How to implement the close function?

The close function is relatively straightforward compared to our send and receive operations. Its main tasks are:

  1. Acquire exclusive access to the channel
  2. Return an error if attempting to close an already closed channel
  3. Set the closed flag to true
  4. Notify all waiting goroutines to wake up

Since the logic is straightforward, let's implement the code directly:

func (c *Channel[M]) Close() error {
    c.cond.L.Lock()
    defer c.cond.L.Unlock()

    if c.closed {
        return errors.New("close on closed channel")
    }

    c.closed = true
    c.cond.Broadcast()
    return nil
}
Enter fullscreen mode Exit fullscreen mode

 

Testing the implementation

Now that we've answered all the questions we initially listed, we've successfully implemented our custom channel. Let's verify its functionality with a simple example:

func main() {
    ch := NewChannel[int](0)

    go func() {
        for i := 1; i <= 5; i++ {
            err := ch.Send(i)
            if err != nil {
                fmt.Printf("Send error: %v\n", err)
                return
            }
            fmt.Printf("Sent: %d\n", i)
        }
        ch.Close()
    }()

    for {
        value, ok := ch.Receive()
        if !ok {
            fmt.Println("Channel closed")
            break
        }
        fmt.Printf("Received: %d\n", value)
    }
}

/*
Output:
Sent: 1
Received: 1
Received: 2
Sent: 2
Sent: 3
Received: 3
Sent: 4
Received: 4
Sent: 5
Channel closed
*/
Enter fullscreen mode Exit fullscreen mode

This test creates an unbuffered channel of integers. A goroutine sends five values through the channel and then closes it. The main goroutine receives and prints these values until the channel is closed.

The complete code for implementation can be found here.

Feel free to experiment with this implementation by adjusting the channel capacity or modifying the number of senders and receivers.

Note: The implementation is only for demonstration purposes. The actual implementation in Go's source code is significantly more complex and optimized for performance and edge cases.

 

Summary

Throughout this article, we've explored the concept of channels in Go, their features, types, and even delved into implementing our own channel. Let's recap the key points we've covered:

  • Channels provide a way to send data between goroutines, facilitating communication in concurrent programs.
  • They offer built-in synchronization mechanisms, helping manage the flow of data between goroutines.
  • Go supports two types of channels:
    • Unbuffered channels: Synchronous in nature, where the sender waits until the receiver has received the data.
    • Buffered channels: Asynchronous, allowing the sender to continue without waiting for the receiver, up to the buffer's capacity.
  • Specifying the direction of a channel (send-only or receive-only) enhances type safety and clarifies the intended use within functions.
  • Closing a channel serves as a useful signal to receivers that no more data will be sent, allowing for proper termination of operations.
  • While Go provides built-in channel implementations, understanding how to create a custom channel deepens our grasp of concurrency concepts.

While this article covers a lot, it's important to note that there's much more to explore about channels in Go. For the sake of conciseness and focus, some advanced topics have been intentionally omitted. However, I hope this article has provided you with a solid foundation in understanding Go channels, helping you explore more complex concurrency patterns on your own.

 

Reference

 

As I'm not an experienced Go developer, I welcome any feedback. If you've noticed any mistakes or have suggestions for improvement, please leave a comment. Your feedback is greatly appreciated and will help enhance this resource for others.

Top comments (0)