Discover the different concurrency primitives in Go and learn common patterns to utilize them effectively.
Tuesday, Jun 18, 2024
golang
Concurrency Primitives
A high-level overview of some of the foundational concurrency primitives in Go. If you are unfamiliar with these concepts, I recommended to dive further into each one.
Goroutine
Independent functions that execute on a set of user-space threads, known as 'lightweight threads,' are managed by the Go runtime.
The Go runtime schedules goroutines, executing them efficiently on top of OS threads. It also determines when these goroutines are run.
The Go Scheduler can spin up several thousand goroutines on the same machine.
Synchronization primitive that Waits for a collection of goroutines to finish. WaitGroups can be used in place of or in conjunction with channels.
Channels
Channels allow for sharing data between goroutines in a seamless fashion without passing data between functions directly. Channels can also be thought of a FIFO (First-In-First-Out) queue.
Behaviors:
Types: Unbuffered (default) and Buffered Channels
Close (close(chan)) channels when the sender is done sending messages. Signals to the receivers that they should stop waiting for messages. Important for receivers when ranging over the channel.
Deadlocks can occur when goroutines are waiting for each other to release resources or communicate over channels. In respect to channels, this can be easily triggered by sending a value over an unbuffered channel without a separate goroutine to read from it.
Unbuffered
The Unbuffered channel has no capacity, where the sender and receiver must be ready to communicate at the same time.
Behavior: Both send and receive blocks until another goroutine performs the inverse operation.
Use Case: Ideal for synchronizing goroutines, ensuring the value sent over the channel is processed immediately.
Buffered
Contrary to Unbuffered channels, Buffered channels do have a specified capacity, allowing the sender to fill up to the limited capacity without requiring a receiver to immediately read from the channel.
Behavior: A send operation only blocks on a buffered channel if the buffer is full and a receive operation only blocks if the buffer is empty.
Use Case: Ideal for scenarios requiring producer and consumers to be decoupled, allowing them to operate at different cadences.
Select
Used to wait on multiple (known) channel operations. Allows goroutines to choose from multiple channels in which to receive from that are ready for synchronization.
Use Cases:
Waiting on multiple channels
Timeouts and cancellation handling
Non-blocking communication with use of default case to perform sends and receives in a non-blocking manner.
The select statement evaluates its cases once and then blocks until one of the cases can proceed. If required to repeatedly evaluate the select statement, must place select inside a loop.
Will continuously receive from both chan1 and chan2 until the quitChan is received.
Mutexes and Atomics
In Go, both sync/mutex and sync/atomic are tools that provide operations for managing concurrent access to shared resources. Both can prevent common race conditions, verified with use of the -race flag.
sync/mutex
sync/atomic
Comparison
sync/mutex:
Use Case: When you need to perform a series of operations atomically or work with non-primitive types.
Pro: Slower due to locking overhead, risk of deadlocks if not used carefully.
Con: Slower due to locking overhead.
sync/atomic:
*Use Case: When you need to perform simple, fast, lock-free operations on primitive types like integers.
Pro: Faster due to the absence of locking.
Con: Limited to specific data types and operations.
Concurrency Patterns
Signal
The Signal pattern in Go used to signal to one or more goroutines that a particular event has occurred. Often used to notify separate goroutines to start, stop, quit, cancel or perform some other action. The signal channel is often used with an empty struct{} since it requires no memory allocation.
In this example, we're implementing graceful shutdown by leveraging the close(chan) operation on the channel to signal to both worker/receiving goroutines that no more messages will be received and to stop processing.
Generator
The Generator pattern in Go involves generating a stream of data produced by one goroutine and consumed by another. Typically, this pattern employs a function that runs in a separate goroutine to generate data and returns a channel through which the data can be received. This allows the producer (generator) and consumer to operate concurrently.
Multiplexing
Multiplexing is often used interchangeably with "Fan-In".
Multiplexing enables a single goroutine to handle multiple inputs from various sources concurrently by utilizing the select {} statement, which allows it to choose between multiple communication operations, such as reading from a channel.
Another approach to achieve multiplexing is by combining multiple input channels into a single output channel.
Fan-In
Fan-In is often used interchangeably with "Multiplexing".
The Fan-In concurrency pattern in Go enables you to merge multiple input channels or sources into a single output channel, which can then be processed by a single consumer. It commonly utilizes the built-in range function to iterate over this consolidated channel.
This pattern is particularly valuable in scenarios where multiple workers produce results that must be gathered and processed into a unified stream of operations.
In the following example, we demonstrate a Fan-In/Multiplex function that merges multiple input channels into a single output channel for iterative processing:
Fan-In can also be accomplished by combining multiple input sources from separate goroutines directly, without the use of merging channels.
Fan-In Sequencing
Fan-In Sequencing is a variant of the Fan-In pattern that involves merging multiple outputs into a single channel while ensuring that the outputs or results are processed in a specific order.
One way to achieve this pattern is by leveraging a signal channel. In the following example, we have a Message struct with a signal channel called "ready." The producer/generator produces the message but then immediately blocks until the consumer/reader signals that it is ready to process the next message.
Another implementation involves leveraging indexed result ordering, where the consumer collects all results in an ordered structure such as a slice or map, allowing for sequential processing.
Fan-Out
The Fan-Out pattern involves distributing or splitting work from a single source into smaller, independent tasks that can be processed in parallel. This pattern is useful for scenarios such as processing large datasets by dividing them into smaller chunks or handling multiple I/O operations simultaneously.
In Go, this can be achieved by spawning multiple workers to process the distributed tasks across multiple channels.
Pipeline
The Pipeline concurrency pattern in Go enables you to process data in stages, with each stage managed by a separate goroutine. In this pattern, each stage receives data via a channel from the previous stage, processes it, and then sends it to the next stage.
This pattern is particularly useful in scenarios such as image processing for transforming images, stream processing for real-time logs, and general data processing.
Worker Pool
The Worker Pool concurrency pattern enables your application to manage and process tasks using a fixed number of worker goroutines. By utilizing a fixed number of workers within a "pool," this pattern efficiently handles a large number of tasks by distributing them among a finite number of workers. This approach improves system resource utilization and prevents overloading.
Queue
The Queue concurrency pattern in Go involves using a queue data structure, typically implemented with channels, to coordinate and manage concurrent tasks.
This pattern follows a producer/consumer model where one or more goroutines (producers) add messages to the queue, and other goroutines (consumers) retrieve and process them.