• Home
  • Testimonials
  • Blog
  • Contact Us

At a Glance of a Key

Crafting Dreams into Ventures, Code into Excellence: Your Journey to Success

  • Home
  • Testimonials
  • Blog
  • Contact Us

Go Concurrency, Practical Example

2024-12-04 Development No Comments 2 minute read

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

Oh hi there 👋
It’s nice to meet you.

Sign up to receive a notification when new posts are published!

We don’t spam!

Check your inbox or spam folder to confirm your subscription.

ConcurrencyGolangPerformance

Using GORM - Part 3: Models and Idempotency

Building a Delayed Message System with Redis and FastAPI

Leave a Reply Cancel reply

About Me

Principal Software Engineer and an industry leader with startup and FAANG experience. I specialize in distributed systems, storage, data protection services and payment processors.

Beyond technical expertise, I am passionate about supporting fellow engineers in their careers. Through approachable blogs and hands-on guidance, I help navigate the ever-evolving landscape of technology, empowering individuals to thrive in their professional journeys.

Open LinkedIn

Recent Posts

  • Building a Delayed Message System with Redis and FastAPI
  • Go Concurrency, Practical Example
  • Using GORM – Part 3: Models and Idempotency
  • Using GORM – Part 2: Transactions and Save Points
  • Using GORM – Part 1: Introduction

Archives

  • January 2025
  • December 2024
  • March 2023
  • February 2023
  • September 2022
  • July 2022
  • July 2021
  • June 2021
  • February 2021
  • April 2018
  • March 2018
  • January 2018
  • July 2017
  • June 2017
  • May 2017

Categories

  • AWS
  • Career Growth
  • Cyber Security
  • Debugging
  • Development
  • Storage
  • Tips & Tricks

Tags

API AWS Azure Bash Brainfuck C++ Challenge Cloud Cloud Bursting Concurrency Database DevOps Disassembly DLL Documentation DynamoDB Go Golang Guice Java Jenkins Mossad NoSQL OOP Performance Programming Python Redis Security Serverless Singleton Streams Testing Unit Tests WebService

All Rights Reserved 2025 © Sirotin Enterprises Inc.
Proudly powered by WordPress | Theme: Doo by ThemeVS.