Go Concurrency, Practical Example
Concurrency is one of Go’s standout features, offering an elegant way to write efficient, parallel programs. Unlike traditional multi-threaded programming, Go makes concurrency approachable with lightweight and easy-to-use goroutines and channels.
A goroutine is a lightweight thread of execution in Go. When you call a function with the go keyword, Go schedules it to run concurrently, but unlike traditional threads, goroutines are much cheaper regarding memory and system resources. Go uses an M:N scheduling model, where many goroutines are multiplexed onto a smaller number of system threads. This allows Go to efficiently manage thousands or even millions of goroutines concurrently. Behind the scenes, the Go runtime handles the scheduling and switching of goroutines, often using goroutine preemption to ensure fair execution without the need for developer intervention. The stack of each goroutine starts small and grows dynamically as needed, making them more memory-efficient than traditional threads.
A channel in Go is a built-in, lock-free data structure that allows goroutines to communicate and synchronize with each other. It is implemented using a ring buffer and provides two basic operations: send and receive. When a goroutine sends data on a channel, it blocks until another goroutine is ready to receive the data, and vice versa. Channels ensure safe communication between goroutines without explicit locking mechanisms, but they are internally implemented using mutexes to synchronize access to the channel’s buffer. While channels can be buffered or unbuffered, their design makes it easier to coordinate concurrent operations without manual locking, helping to avoid race conditions and deadlocks.
As an example, let’s create a simple program that uses a channel to distribute messages to 3 workers who will print them:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 | package main import ( "log" "time" ) func worker(id int, ch <-chan string) { log.Printf("WORKER-%d - Starting", id) for msg := range ch { log.Printf("WORKER-%d - Received: %s\n", id, msg) } log.Printf("WORKER-%d - Done!", id) } func main() { // Create a channel to send messages ch := make(chan string) // Start 3 worker goroutines for i := 0; i < 3; i++ { go worker(i, ch) } // Send 5 random messages into the channel messages := []string{"Hello", "World", "Go", "Concurrency", "Goroutine"} for _, msg := range messages { ch <- msg } // Close the channel to signal that no more messages will be sent close(ch) // Wait a moment for goroutines to process the messages time.Sleep(2 * time.Second) } |
Running this program prints the following:
1 2 3 4 5 6 7 8 9 10 11 12 | ➜ playgound go run concurrency.go 2024/12/04 14:34:56 WORKER-2 - Starting 2024/12/04 14:34:56 WORKER-2 - Received: Hello 2024/12/04 14:34:56 WORKER-0 - Starting 2024/12/04 14:34:56 WORKER-0 - Received: Go 2024/12/04 14:34:56 WORKER-0 - Received: Concurrency 2024/12/04 14:34:56 WORKER-0 - Received: Goroutine 2024/12/04 14:34:56 WORKER-2 - Received: World 2024/12/04 14:34:56 WORKER-2 - Done! 2024/12/04 14:34:56 WORKER-1 - Starting 2024/12/04 14:34:56 WORKER-1 - Done! 2024/12/04 14:34:56 WORKER-0 - Done! |
We can see that each string is handled in a random worker, and the worker implementation uses the range iteration operator on the channel. Once the channel is closed, the worker for loop ends, and the goroutine completes.
Bonus Question: From the program flow, we should get all the “Starting” log messages first, then the actual five words we send, and only then the “Done!” log messages. Why is it mixed in the output? The answer is a bit complex because it is a combination of things:
1. OS Buffering – The operating system won’t immediately send everything from stdout to the screen.
2. Delayed Start and Context Switching – There may be a delay during goroutine execution, and context switches in between, so technically, we may have already finished firing the three workers, but some of them may not start executing before we send the first message to the channel. Similarly, when we finish sending all the messages to the channel, it doesn’t mean they were processed when we closed the channel.
It may not look like this, but the channel acts as a queue. To test the hypothesis, we can aggregate the messages into a single string using a mutex and print it to see the result. Changing the code would look like this:
1 2 3 4 5 6 7 8 9 10 11 12 | var builder strings.Builder var mu sync.Mutex func worker(id int, ch <-chan string) { log.Printf("WORKER-%d - Starting", id) for msg := range ch { mu.Lock() builder.WriteString(fmt.Sprintf("%s ", msg)) mu.Unlock() } log.Printf("WORKER-%d - Done!", id) } |
The output looks like this:
1 2 3 4 5 6 7 8 | ➜ playgound go run concurrency.go 2024/12/04 14:48:37 WORKER-2 - Starting 2024/12/04 14:48:37 WORKER-1 - Starting 2024/12/04 14:48:37 WORKER-1 - Done! 2024/12/04 14:48:37 WORKER-2 - Done! 2024/12/04 14:48:37 WORKER-0 - Starting 2024/12/04 14:48:37 WORKER-0 - Done! 2024/12/04 14:48:39 Hello World Go Concurrency Goroutine |
Our next step is to remove the time.Sleep from the code as it is an anti-pattern. We want to keep “main” alive until all workers are done. For that, we can use WaitGroup. We first initiate it with the number of active workers, and each time a worker finishes its execution, it will decrease one from the waiting group. Then, we can block the “main” function until the WaitGroup reaches zero. A simple implementation would look like this:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 | package main import ( "log" "sync" ) func worker(id int, ch <-chan string, wg *sync.WaitGroup) { defer wg.Done() log.Printf("WORKER-%d - Starting", id) for msg := range ch { log.Print(msg) } log.Printf("WORKER-%d - Done!", id) } func main() { // Create a WaitGroup to wait for all workers to finish var wg sync.WaitGroup // Create a channel to send messages ch := make(chan string) // Start 3 worker goroutines for i := 0; i < 3; i++ { wg.Add(1) go worker(i, ch, &wg) } // Send 5 random messages into the channel messages := []string{"Hello", "World", "Go", "Concurrency", "Goroutine"} for _, msg := range messages { ch <- msg } // Close the channel to signal that no more messages will be sent close(ch) // Wait for all goroutines to process the messages wg.Wait() } |
Now that we understand the fundamentals, let’s move on to a more complex example. Let’s assume we want to perform a specific operation X times per second for a duration of Y seconds. Go’s native “time” library provides a Ticker functionality that “ticks” at a specified time interval and allows us to listen to these ticks by reading from a channel. A simple example would be:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 | package main import ( "log" "sync" "sync/atomic" "time" ) const CallsPerSecond = 5 const DurationInSeconds = 3 func onTick() { log.Print("onTick completed processing") } func main() { // Create the waiting group to ensure all workers complete gracefully var wg sync.WaitGroup // Create the ticker and the stop channel ticker := time.NewTicker(time.Second / time.Duration(CallsPerSecond)) stop := time.After(time.Duration(DurationInSeconds) * time.Second) defer ticker.Stop() attempts := int64(0) startTime := time.Now() for { select { case <-stop: // Is there anything to read from the "stop" channel? wg.Wait() elapsed := time.Since(startTime) log.Printf("Runner completed with %v attempts in %v seconds", attempts, elapsed.Seconds()) return case <-ticker.C: // Is there a "tick" on the ticker? wg.Add(1) go func() { defer wg.Done() onTick() atomic.AddInt64(&attempts, 1) }() } } } |
Note: The select keyword in Go is used to wait on multiple channels and handle whichever channel is ready first. It’s commonly used when you have multiple channels and want to execute code based on which one receives data without blocking the other channels. In a select statement, Go will block until one of its case conditions is ready (i.e., a channel is ready to send or receive data). If multiple channels are prepared at the same time, one of them is chosen at random. You can also use a default case to execute code when no channels are ready.
The output looks like this:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 | ➜ playgound go run ticker.go 2024/12/04 15:15:25 onTick completed processing 2024/12/04 15:15:25 onTick completed processing 2024/12/04 15:15:26 onTick completed processing 2024/12/04 15:15:26 onTick completed processing 2024/12/04 15:15:26 onTick completed processing 2024/12/04 15:15:26 onTick completed processing 2024/12/04 15:15:26 onTick completed processing 2024/12/04 15:15:27 onTick completed processing 2024/12/04 15:15:27 onTick completed processing 2024/12/04 15:15:27 onTick completed processing 2024/12/04 15:15:27 onTick completed processing 2024/12/04 15:15:27 onTick completed processing 2024/12/04 15:15:28 onTick completed processing 2024/12/04 15:15:28 onTick completed processing 2024/12/04 15:15:28 onTick completed processing 2024/12/04 15:15:28 Runner completed with 15 attempts in 3.001266083 seconds |
As we can see, we ran the program with five calls per second for 3 seconds – resulting in precisely 15 calls. Does it maintain accuracy if we increase the concurrency? The simple answer depends on your system and CPU. For instance, I am running on a MacBook Pro with the new M4 Pro CPU, and this is what I get:
1 2 3 4 5 6 7 | CallsPerSec = 1000, DurationSeconds = 5 ➜ playgound go run ticker.go 2024/12/04 15:17:53 Runner completed with 5000 attempts in 5.00013425 seconds CallsPerSec = 5000, DurationSeconds = 5 ➜ playgound go run ticker.go 2024/12/04 15:19:57 Runner completed with 24935 attempts in 5.000032375 seconds |
One thousand calls per second gave us 100% accuracy, while the 5,000 calls per second gave us 99.74% accuracy. Also, depending on the OnTick function duration, results may vary during high concurrency. For instance, if I add time, these are the results on my system.Sleep(3) to the onTick() function:
1 2 3 4 5 6 7 | CallsPerSec = 1000, DurationSeconds = 5 ➜ playgound go run ticker.go 2024/12/04 15:22:20 Runner completed with 4995 attempts in 7.999261708 seconds CallsPerSec = 5000, DurationSeconds = 5 ➜ playgound go run ticker.go 2024/12/04 15:22:47 Runner completed with 24793 attempts in 7.999874541 seconds |
In this case, the 1,000 calls per second gave us 99.9% accuracy, while the 5,000 calls per second gave us 99.172% accuracy.
The biggest reason for the degraded accuracy is the context switching that occurs in high frequency. 5,000 ticks per second is roughly 0.2ms between ticks. The recommended way to use it in a high-frequency above your CPU performance, is to make it horizontally scalable by expanding the code into multiple machines and maintaining a centralized control system – but that’s a topic for another post 🙂
In this post, we’ve explored the powerful concurrency features of Go, particularly focusing on goroutines, channels, and the select statement. By leveraging these tools, you can efficiently handle concurrent tasks, synchronize data between goroutines, and manage complex workflows. Whether you’re building scalable systems or processing tasks in parallel, Go’s concurrency model offers a robust and simple way to implement efficient, concurrent code. Understanding and using these patterns, along with their limitations, will help you write cleaner and more performant Go applications.
– Alexander