channel.png

Overview

Golang or Go is a programming language that supports concurrency through goroutines, lightweight threads of execution. Goroutines can communicate and synchronize with each other using channels, which are built-in data structures that act as pipes between them. Channels are a fundamental feature of Go that enable safe and efficient communication and synchronization between goroutines. Goroutine

This article will explore the following:

  • How are go channels implemented?
  • How does channel handle concurrency?
    • Buffered channels
      • Blocking when sending data
      • Blocking when receiving data
    • Unbuffered channels
    • Select statement

How are go channels implemented?

This is the source code of the Golang channel

// https://go.dev/src/runtime/chan.go
type hchan struct {
	...
	buf      unsafe.Pointer // points to an array of dataqsiz elements
	...
	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
}

The channel is implemented as a struct with a buffer, a send index, a receive index, and two wait queues.

  • The buffer is a pointer to an array of dataset (data queue size) elements.
  • The send index and receive index are used to keep track of the next position to send and receive data.
  • The wait queues are used to store goroutines that are waiting to send or receive data.

How does channel handle concurrency?

To handle concurrency, the channel uses a lock to protect all fields in hchan, as well as several fields in the goroutines blocked on this channel. The lock ensures that only one goroutine can access the channel at a time.

Buffered channels

Buffered channels are channels that have a buffer size greater than 0. we can create a buffered channel bypassing the buffer size as the second argument to the make function. The buffer size specifies the number of elements that can be sent to the channel without blocking.

ch := make(chan int, 10)

Blocking when sending data

When a goroutine sends data to a full buffered channel, Go scheduler will set the status of the goroutine sender to waiting.

At this point, The scheduler will detach the M (Machine) from the P (Processor) with the blocking Goroutine still attached. Then, when the goroutine is available, it will bring it back to the P (Processor) and continue to execute. If an M already exists because of a previous swap, this transition is quicker than having to create a new M.

golangchannel.drawio(8).svg

When channel is not full, the scheduler will choose a goroutine from the send queue and set its status to ready. It also dequeues the goroutine from the send queue. The goroutine will be put into the M queue and waiting for execution.

Blocking when receiving data

This case is similar to the previous case. When a goroutine receives data from an empty buffered channel, Go scheduler will set the status of the goroutine to waiting and put it into the receive queue. The goroutine will be blocked until another goroutine sends data to the channel.

In case goroutine resumes, having a little different. Along with awakening the goroutine, Data will be sent directly to the goroutine without going through the channel. This helps to optimize performance.

This is not officially mentioned in the golang documentation, but data sent directly by set to the stack of goroutine receiver. This is the reason why the receiver goroutine can receive data even though it is not in the M queue.

Unbuffered channels

Unbuffered channels are channels that have a buffer size of 0. We can create an unbuffered channel by passing 0 as the second argument to the make function. Unbuffered channels provide synchronization between goroutines

ch := make(chan int)

It is similar to synchronous communication. When a goroutine sends data to an unbuffered channel, the goroutine will be blocked until another goroutine receives data from the channel. The same thing happens when a goroutine receives data from an unbuffered channel.

In case of receiver block, sender will send data directly to the stack of receiver goroutine.

In case of sender block, receiver will receive data directly from the stack of sender goroutine.

Select statement

The select statement is used to wait for multiple channel operations. It blocks until one of the cases can be executed. If multiple cases can be executed, one is chosen at random. It is often used with channels to implement timeouts, non-blocking sends and receives, and other forms of communication.

select {
    case <-ch1:
    // handle ch1
    case <-ch2:
    // handle ch2
}

When a select statement is executed, it sets the current goroutine to the waiting queue of all the channels in the statement.

When a channel receives data, the scheduler will choose a goroutine from the waiting queue of that channel and set its status to ready. The goroutine will then be placed into the M queue, waiting for execution.

This process continues until at least one channel in the select statement has data available to be received.

Once data is available on a channel, the corresponding case the statement will be executed, and the goroutine associated with that case will be removed from the waiting queue of its channel and added to the M queue for execution.

If multiple channels have data available at the same time, one of the cases will be selected randomly. If no channels have data available, and there is a default case, then that case will be executed. If there is no default case, the select statement will block until data becomes available on at least one of the channels.

References