Exploring the Internals of Channels in Go

Kamna Garg
Women in Technology
10 min readJun 29, 2023

--

Introduction

Channels are a vital component of concurrent programming in Go. They provide a safe and efficient way for goroutines to communicate and share information. Instead of directly sharing memory, Go promotes the use of channels for inter-goroutine communication. In this blog, we will delve into the internals of channels and explore how they work behind the scenes. So, let’s dive in and uncover the mysteries of Go channels!

A goroutine is a lightweight thread managed by the Go runtime, enabling concurrent execution of functions or tasks in Go programs.

Define Channel

To define a channel in Go, you can use the syntax: var channelName chan ElementType. For example, var intChannel chan int creates an unbuffered channel for transmitting integers. If you want to create a buffered channel, use make(chan ElementType, bufferSize) it to specify the capacity of the channel.

ch := make(chan string, 4) // buffered channel
ch := make(chan int) // unbuffered channel

Channels in Go are designed to be goroutine-safe and follow the FIFO (First-In-First-Out) order. To meet these requirements, channels utilize a circular queue with a lock as their underlying implementation. The circular queue allows for efficient enqueueing and dequeuing of values, maintaining the order in which they were sent. The lock ensures that only one goroutine can access the channel at a time, preventing race conditions and ensuring synchronized access to the queue.

So when we define a channel using the above syntax, the channel is created from the hchan struct, which has the following fields.

The internal representation of buffered channel at runtime
type hchan struct {
qcount uint // total data in the queue
dataqsiz uint // size of the circular queue
buf unsafe.Pointer // points to an array of dataqsiz elements
elemsize uint16
closed uint32
elemtype *_type // element type
sendx uint // send index
recvx uint // receive index
recvq waitq // list of recv waiters
sendq waitq // list of send waiters

// lock protects all fields in hchan, as well as several
// fields in sudogs blocked on this channel.
//
// Do not change another G's status while holding this lock
// (in particular, do not ready a G), as this can deadlock
// with stack shrinking.
lock mutex
}

type waitq struct {
first *sudog
last *sudog
}

The hchan struct in Go channels holds several important fields that define the behavior and characteristics of the channel. Here’s a breakdown of these fields:

  1. qcount: It represents the number of items or data currently present in the channel’s queue.
  2. dataqsize: This field indicates the size of the circular queue. It is relevant for buffered channels and is the second parameter provided when creating a channel using the make function.
  3. elemsize: It denotes the size of a single element within the channel.
  4. buf: The buf field refers to the actual circular queue where data is stored in buffered channels.
  5. closed: This field indicates whether the channel is closed. It is initially set to 0 upon channel creation and is set to 1 when the channel is closed using the close() function.
  6. sendx and recvx: These fields track the current index in the buffer or circular queue. sendx increases when data is added to a buffered channel, while recvx increases when data is received from the channel.
  7. recvq and sendq: These fields represent the waiting queues for blocked goroutines that are either waiting to read data from or write data to the channel. It contains reference to another structure sudog, which also plays a role in channel operations but will be explored later in the blog.
  8. lock: The lock field is a mutex used to lock the channel during read or write operations, preventing multiple goroutines from accessing it simultaneously and avoiding potential deadlocks.

Memory allocation of channel

When a channel is created using the make function in Go, memory is allocated on the heap for the hchan struct, and the make function returns a pointer to that memory. As a result, we don't need to pass a pointer to the channel during function calls since the channel itself is a pointer under the hood.

At executing line 20, where we are adding one job to channel, the hchan would be like :

The sendx and recvx fields in the hchan struct point to the next element to be sent or received from the channel. They increment after each operation and are set to 0 when the queue is full or empty, respectively.

For unbuffered channels in Go, the buf field in the hchan struct will be nil. Unbuffered channels do not have a queue or buffer to store values.

Send and receive operations on buffered channel

When a goroutine, such as G(main func in above code snippet), wants to write data to a buffered channel, it follows these steps:

  1. To ensure safe modification of the channel and the underlying hchan struct, G (the goroutine) acquires a lock before writing data. This lock prevents concurrent access and maintains synchronization.
  2. After acquiring the lock, G performs an enqueue operation on the circular queue represented by the buf field. Before enqueuing the data, a memory copy operation is performed to create a copy of the data.
  3. Once the enqueue operation is completed, G1 releases the lock, allowing other goroutines to acquire it and perform their respective operations.

When a goroutine, like G1(taskOne) or G2(taskTwo), reads data from the channel, it goes through similar steps as G but with some variations:

  1. G2 acquires the lock to ensure exclusive access to the channel’s hchan struct.
  2. It performs a dequeue operation on the circular queue (buf) to retrieve the next available data. At the same time, G2 performs a memory copy operation on the data it receives, creating a copy.
  3. Once G2 has copied the data from the buffer, it releases the lock, allowing other goroutines to access the channel.
  4. G2 can now process the copied data as needed, independently of other goroutines.

It’s important to note that the data obtained by G2 is a separate copy, not a shared reference. This means that each goroutine receives its own copy of the data, ensuring data isolation and avoiding issues related to shared memory access.

Buffer Overflow/Underflow

When the buffer capacity of a channel is reached and a goroutine, such as G, attempts to write data, the behavior depends on whether there is a receiver ready to receive the data.

If there is a receiver (e.g., G2) ready to receive the data, G can proceed to send the data without blocking. The data is then received by G2, and both goroutines continue their execution.

However, if there is no receiver ready to receive the data, G is paused. G will remain in a paused state, waiting for a receiver to become available.

How does this pausing and resuming of goroutine works?

Go runtime schedular does the magic here.

Blocking call on buffered channel

Go runtime Schedular

Before diving into schedular, let’s understand a bit about goroutines. As you might already be aware, goroutines in Go are user-space threads that are managed by the Go runtime scheduler. Unlike operating system threads, the lifecycle of goroutines is managed by the Go runtime rather than the operating system itself. This distinction makes goroutines lightweight compared to OS threads, resulting in lower resource consumption and reduced scheduling overhead.

The Go runtime scheduler employs an M:N scheduling model, where M represents the number of goroutines and N represents the number of operating system threads. The scheduler multiplexes or maps these M goroutines onto the available N OS threads. This allows the scheduler to efficiently schedule and switch between goroutines, providing concurrent execution and parallelism on top of the underlying operating system threads.

By utilizing the M:N scheduling model, the Go runtime scheduler achieves a balance between efficient resource utilization and effective concurrency management. Goroutines can be created and executed with low overhead, allowing developers to utilize concurrent programming in a lightweight and efficient manner.

Image source : Google

Go schedular has three structures :

  1. M represents the OS thread, which is managed by the operating system itself.
  2. G represents the goroutine, which is a resizable stack.
  3. P represents a context for scheduling and is responsible for running the Go code. It contains Queue of runnable goroutines.

There must be association between os thread(M) and goroutine(G) for it to be running. The association between an OS thread and a goroutine is dynamic and can change over time.

Since go runtime schedular is pretty much clear now, it’s time to move back to previous example.

  1. Goroutine G tries to send data to channel which is already full.
ch <- "Job3" // Goroutine G sending Job3 on a full channel

2. It calls runtime schedular (gopark function)

3. Schedular changes G to waiting state and remove the association between OS thread (m) and Goroutine (g).

4. Schedular pops the goroutine from runQueue(p) and schedule it to run on OS thread (m). This is context switching. G is blocked but not the OS thread.

Here sudog comes into the picture.

The sudog struct mentioned below is responsible for storing information about a waiting goroutine, such as g in our case. The Go runtime will park or suspend this sending goroutine (g), ensuring that it is temporarily halted until certain conditions are met.

call ch <- “Job3”(blocking send) creates a sudog and add it to waiting sender
type sudog struct {
// The following fields are protected by the hchan.lock of the
// channel this sudog is blocking on. shrinkstack depends on
// this for sudogs involved in channel ops.

g *g

next *sudog
prev *sudog
elem unsafe.Pointer // data element (may point to stack)

// The following fields are never accessed concurrently.
// For channels, waitlink is only accessed by g.
// For semaphores, all fields (including the ones above)
// are only accessed when holding a semaRoot lock.

acquiretime int64
releasetime int64
ticket uint32

// isSelect indicates g is participating in a select, so
// g.selectDone must be CAS'd to win the wake-up race.
isSelect bool

// success indicates whether communication over channel c
// succeeded. It is true if the goroutine was awoken because a
// value was delivered over channel c, and false if awoken
// because c was closed.
success bool

parent *sudog // semaRoot binary tree
waitlink *sudog // g.waiting list or semaRoot
waittail *sudog // semaRoot
c *hchan // channel
}

Now see what happens when goroutine G1 or G2 are scheduled by runtime schedular and they perform receive operation on same channel.

  1. The G1/G2 dequeues an object (JOB1) from its buffer, effectively receiving task from queue. It assigns JOB1 to the variable job.
  2. Additionally, it dequeues the sudog from the sendq (send queue) and enqueues the sudog.elem (“JOB3”) into the buffer. It is a performance optimisation here. It saves few memory operations.
job := <-ch // Goroutine G1/G2 receive data from on a buffered channel

3. Call goready function to move G(main func) to runQueue and make it runnable.

Current state of hchan when G1/G2 receives data from buffered channel which was blocked earlier

What happens when receive comes first and channel is empty?

Let’s say channel is empty and Goroutine G1 tries to read data

job := <-ch // Goroutine G1 try to read data from on an empty channel

G1 is temporarily suspended and will remain paused until it is awakened by a subsequent send operation on the channel.

  1. G1 creates a sudog and puts it in the receq (receive queue),
  2. It calls gopark(G1) to pause the execution of the goroutine.

Now G(main goroutine) gets the schedular and there are two possibilities.

  1. Enqueue the task in the buffer and call goready(G1): In this case, G would put the task in the channel's buffer and then call goready(G1) to make G1 runnable again. This approach involves acquiring the lock and performing additional memory operations.
  2. Directly copy the task to the elem field of the sudog of G1: Instead of enqueuing the task into the channel's buffer, G directly copies the task to the elem field of G1's sudog. This approach avoids the need to acquire the lock and reduces the number of memory operations required.

The Go scheduler opts for the second option as a performance optimization. By directly copying the task, it minimizes the overhead associated with acquiring locks and reduces memory operations, resulting in improved performance and efficiency.

Send/receive in unbuffered channels

In unbuffered channels, the send and receive operations work differently depending on the order of execution:

  1. When a receive operation occurs first, the sender directly writes the value to the receiver’s stack. This means that the value is transferred directly from the sender to the receiver without any intermediate storage or buffering.
  2. Conversely, when a send operation occurs first, the receiver receives the value directly from the sudog (synchronization data structure) of the sender. The value is obtained without the need for buffering or additional intermediate steps.

This direct transfer of data between the sender and receiver in unbuffered channels eliminates the need for a separate buffer, ensuring that the send and receive operations are tightly synchronized. The direct transfer mechanism enables efficient and synchronous communication between goroutines, facilitating a strict one-to-one data exchange pattern.

--

--

Kamna Garg
Women in Technology

Software Developer, Women in tech, Seeker, Love writing, Always a student, IIT Kanpur