Go’s Concurrency and Channel Internals

Go is implementing CSP (Communicating Sequential Processing): processes are communicating through channels, they can block each other while waiting for read/writes to channels.
Actor model makes inter-process communications more explicit and non-blocking.
CSP vs Actor explained — https://dev.to/karanpratapsingh/csp-vs-actor-model-for-concurrency-1cpg.

Channels requirements

  • goroutine-safe
  • store and pass data across goroutines
  • FIFO
  • can block/unblock goroutines


Channels are created in the heap as they have to be safely shared across goroutines.

It lives in runtime/chan.go

Important fields are shown below:


  • buf is a link to a ring queue of stored elements in the channel’s buffer
  • sendx, recvx are indices in that buffer to store/get a value (in FIFO manner)
  • sendq, recvq are the linked lists of sudog of goroutines and values waiting for sending/receiving values
  • lock is a mutex guarding all operations related to the channel data manipulations

Sender goroutine will copy data to position sendx (unless qcount is reached) and increment sendx (or set it to 0).
Receiver goroutine will copy data from position recvx (unless qcount is 0) and increment recvx (or set it to 0).

If there are no values in buf, receiver is paused. If buf is full, sender is paused.

Pausing/resuming goroutines

Sender blocking

When a goroutine G1 tries to send, but the buffer is full, channel (chansend() function) does the context switch: it calls gopark() runtime function to set G1’s state to «waiting». Scheduler unlinks G1 from OS thread (more on scheduler — https://blog.bullgare.com/2022/11/go-scheduler-details/) and G1 waits until someone is reading from the channel.

G1 is stored in sendq together with the value it wants to send.

Eventually, another goroutine, G2, decides to receive from the channel. It reads from the buffer, increases sendx index, and then the magic happens. chanrecv() function checks the sendq and sees the sudog with G1 there. G2 copies elem from the sudog to the buf and calls runtime’s goready() to set G1’s state to runnable.

Receiver blocking

When a goroutine G3 is willing to receive from the channel, and there is no data yet, chanrecv() function pauses G3 and adds it to recvq together with the pointer to memory where it expects the data to be copied to. Then G3 is goparked as we discussed before.

When G4 decides to send to the channel, chansend() function first check if recvq is not empty, notices the sudog with G3 and elem pointer in it, and copies the value DIRECTLY to G3’s elem or G3’s stack. Then it also sets G3’s state to runnable.

Unbuffered channels

They do not use a buffer at all, and sender is always copies directly to the receiver’s stack, which is faster as it does not involve extra locks.


With default

It’s non-blocking, which means that this goroutine checks in random order if there is any value in chA‘s buffer, or if chB‘s buffer is not full. If any of those is true, it will run that case. Otherwise it will run the default case and continue the execution.

Without default

It’s blocking. It first checks if chA or chB are ready to send/receive. If they are, it executes the case.
But if they are not ready, it will add current goroutine in chA‘s receiver queue and chB‘s sender queue and will wait until any of them is ready. Then it is removed from all other queues and continues the execution.



More detailed talk in Russian


Similar Posts