Channels in Go
1. How channel was invented?
The communication in the Go channel is inspired by CSP and guarded command.
CSP stands for “Communicating Sequential Processes,” which is both a technique and the name of the paper that introduced it. In this paper, Hoare suggests that input and output are two overlooked primitives of programming—particularly in concurrent code. CSP was only a simple programming language constructed solely to demonstrate the power of communicating sequential processes.
A guarded command, which Edgar Dijkstra had introduced in a previous paper written in 1974, “Guarded commands, nondeterminacy and formal derivation of programs”, is simply a statement with a left and righthand side, split by a →
. The lefthand side served as a conditional, or guard for the righthand side in that if the lefthand side was false or, in the case of a command, returned false or had exited, the righthand side would never be executed.
writeCh := make(chan<- interface{})
readCh := make(<-chan interface{})
<-writeCh
readCh <- struct{}{}
This will cause error.
invalid operation: <-writeCh (receive from send-only type
chan<- interface {})
invalid operation: readCh <- struct {} literal (send to receive-only
type <-chan interface {})
2. How channels are created in Go?
When the Go compiler encounters the statement ch := make(chan int)
, it leads to the creation of a channel that is capable of transmitting integers. The process involves several steps under the hood, both at compile time and at runtime, to set up and initialize this channel for use in your Go program. Here's a simplified view of what happens:
2.1. Compile Time
- Type Checking: The compiler verifies that the make function is called with a valid channel type, in this case, chan int. This ensures type safety, meaning the channel will only accept integers.
- Code Generation: The compiler generates the necessary instructions to allocate and initialize a channel at runtime. This includes setting up any internal data structures required for the channel's operation.
2.2. Runtime
When the compiled code reaches the make(chan int) statement during execution, the Go runtime performs the following steps:
- Channel Allocation: The runtime allocates memory for the channel. This memory includes not just the channel itself but also the internal data structures needed to manage the channel's state and the messages it will pass.
- Initialization: The runtime initializes the channel's internal data structures. These structures include:
- A queue for storing sent values (for buffered channels, this queue has a capacity; for unbuffered channels, the capacity is effectively zero).
- Synchronization primitives to manage access to the channel, ensuring that send and receive operations are safe to use across multiple goroutines.
- Status flags or similar mechanisms to track whether the channel is open or closed.
- Setting Zero Capacity: For an unbuffered channel like
ch := make(chan int)
, the channel is set up with zero capacity. This means that send operations will block until another goroutine is ready to receive the value, facilitating direct handoff and synchronization between goroutines.
- Returning a Reference: The runtime returns a reference to the newly created channel, which is assigned to the variable ch in your Go program. This reference is what you use to send and receive values through the channel.
2.3. Internal Data Structures
Although the exact implementation details can vary and may evolve over time, Go typically uses complex data structures to manage channels, including:
- Send and Receive Queues: To manage goroutines that are waiting to send to or receive from the channel.
- Locks or Atomic Operations: To ensure that concurrent access to the channel by multiple goroutines is safe and does not lead to race conditions.
3. When to use channels in Go?
4. Go’s Philosophy on Concurrency
Share memory by communicating; don’t communicate by sharing memory.
This phrase, "Share memory by communicating; don’t communicate by sharing memory", encapsulates a fundamental principle of concurrent programming in Go. It contrasts two approaches to concurrency:
4.1. Communicate by Sharing Memory
This traditional approach involves multiple threads accessing and modifying shared data structures. Synchronization primitives such as mutexes, semaphores, or locks are typically used to prevent race conditions and ensure data consistency. While effective in certain contexts, this model can be error-prone and difficult to reason about, especially as the complexity of the concurrency increases. The challenges include deadlocks, race conditions, and the cognitive load of tracking which parts of the code are accessing shared resources.
You would typically protect the counter with a mutex to prevent simultaneous updates.
var (
counter int
mutex sync.Mutex
)
func Increment() {
mutex.Lock()
counter++
mutex.Unlock()
}
4.1. Share Memory by Communicating
Go advocates for a different model of concurrency where goroutines communicate with each other through channels to pass data. In this model, instead of multiple goroutines accessing shared data, the data is sent from one goroutine to another. This passing of data ensures that only one goroutine has access to the particular piece of data at any time. By using channels as the primary means of synchronization and communication, the need for explicit locks is reduced, and the program becomes easier to understand and maintain.
In this model, CounterManager runs in its own goroutine, listening for increment requests. Other goroutines send an increment request through the channel. This design ensures that only one goroutine updates the counter at a time, based on messages received, thus "sharing memory by communicating."
var (
counter int
ch = make(chan bool)
)
func CounterManager() {
for range ch {
counter++
}
}
func Increment() {
ch <- true
}
Go’s philosophy on concurrency can be summed up like this: aim for simplicity, use channels when possible, and treat goroutines like a free resource.