all posts
2025-02-17 · 24 min read

Concurrency and synchronization in go

Hello! I'm Vlad and today I want to explore the topic of concurrency in Go and try to practically examine different goroutine synchronization primitives.

Developing multithreaded programs has always been a challenging task that requires a deep understanding of synchronization mechanisms. As for Go, it provides developers with a powerful set of tools for working with concurrency. Synchronization primitives in Go are key components for creating reliable and efficient concurrent programs.

In this article, we'll look at the main synchronization primitives that Go offers and their practical applications. Understanding these tools is critical for every Gopher as they help avoid typical concurrent programming problems and write more reliable code.

Concurrency in Go

Concurrency and parallelism are two different concepts that are often confused.

Concurrency

Concurrency is the ability of a program to handle multiple tasks simultaneously, regardless of whether they are executed in parallel or not. In Go, this is achieved through goroutines - lightweight execution threads (from 2KB) managed by the Go Scheduler.

To explain it briefly and simply - the Go scheduler very quickly (seemingly almost simultaneously) switches contexts between goroutines, ensuring their concurrent execution.

Main characteristics of concurrency in Go:

  • Goroutines can execute independently of each other
  • The Go scheduler decides when and how to allocate resources between goroutines

Parallelism

Parallelism is the simultaneous execution of multiple tasks on different processors or cores. It's a subset of concurrency that is only possible on systems with multiple processors.

Key differences:

  • Parallelism requires multiple processors or cores
  • Parallelism is about simultaneous execution
  • Not all concurrent programs are necessarily parallel

As Rob Pike said:

Concurrency is about dealing with lots of things at once.

Parallelism is about doing lots of things at once.

I don't see much point in diving too deep into this topic in this article, as we're interested in a different question - how can we synchronize goroutines?

Environment Variables Related to Concurrency

Go provides several important environment variables that are related to and affect the operation of concurrent programs:

  • GOMAXPROCS - determines the maximum number of processors (P) that can execute Go code simultaneously.
    • By default equals the number of logical processors in the system
    • Can be changed programmatically through runtime.GOMAXPROCS(n)
  • GOGC - controls the frequency of garbage collector runs
    • By default GOGC=100, which means GC runs when memory usage doubles
    • Setting to off completely disables GC (not recommended)
  • GOTRACEBACK - controls the detail level of stack trace during panic
    • Values: none, single, all, system, crash
    • Useful for debugging concurrent programs
  • GORACE - configuration for race detector
    • Helps detect race conditions in concurrent code
    • Example: GORACE="log_path=/path/to/log"

Goroutine Synchronization Primitives

Synchronization primitives are tools that help control access to shared resources and coordinate work between goroutines.

Let's examine each of them in detail with examples.

Mutexes

Mutex (mutual exclusion) is a synchronization primitive that represents a locking mechanism allowing goroutines to safely access shared resources.

It is implemented in the standard sync package and is the simplest way to protect data from concurrent access. The principle of mutex operation is quite simple and straightforward:

  1. A goroutine that wants to access a shared resource calls Lock()
  2. If the mutex is already locked by another goroutine, the current goroutine blocks and waits
  3. When the resource is released through Unlock(), one of the waiting goroutines gets access to the resource

Mutex is particularly useful in cases where you need to protect critical sections of code from simultaneous execution by different goroutines. It ensures that only one goroutine can access a shared resource at any given time.

The sync package implements 2 types of mutexes:

  • Mutex - locks on every operation, regardless of whether it's reading or writing.
    • To put it in simpler terms, this type of mutex completely blocks data access for all other goroutines until the first goroutine completes its work. It's like a lock on a door - when one person enters a room and locks the door, no one else can enter or even see what's happening inside until the first person leaves and unlocks the door.
  • RWMutex - locks only during data writing, while reading can occur simultaneously by many goroutines.
    • To put it in simpler terms, this type of mutex allows many people to read a book simultaneously, but only one person can edit it.
    • It's like in a library - many visitors can read books simultaneously, but the librarian can update the catalog only when no one is reading.

Let's look at several examples of using these mutexes:

Mutex

go
package main
 
import (
    "fmt"
    "sync"
    "time"
)
 
type Config struct {
    mu   sync.Mutex
    data map[string]any
}
 
func (c *Config) Set(key string, value any) {
    c.mu.Lock()
    // Unlock after function exit
    defer c.mu.Unlock()
    c.data[key] = value
}
 
func (c *Config) Get(key string) (any, bool) {
    c.mu.Lock()
    // Unlock after function exit
    defer c.mu.Unlock()
    val, ok := c.data[key]
    return val, ok
}
 
func main() {
    cfg := &Config{
        data: make(map[string]any),
    }
 
    // safe access from different goroutines
    go cfg.Set("host", "localhost")
    go cfg.Set("port", 8080)
 
    time.Sleep(time.Millisecond)
    if val, ok := cfg.Get("port"); ok {
        fmt.Printf("Port: %v\n", val)
    }
}
 

In this example, we create a simple configuration structure that uses a mutex to protect access to its internal map. The Set and Get methods use Lock() and Unlock() to ensure that only one goroutine can modify or read the map at a time.

Notice the use of defer mu.Unlock() which ensures that the mutex is always unlocked when the function returns, even if a panic occurs. This is a common pattern in Go to prevent deadlocks.

RWMutex

go
package main
 
import (
    "fmt"
    "sync"
    "time"
)
 
type Config struct {
    mu   sync.RWMutex
    data map[string]any
}
 
func (c *Config) Set(key string, value any) {
    c.mu.Lock()
    defer c.mu.Unlock()
    c.data[key] = value
}
 
func (c *Config) Get(key string) (any, bool) {
    c.mu.RLock()
    defer c.mu.RUnlock()
    val, ok := c.data[key]
    return val, ok
}
 
func main() {
    cfg := &Config{
        data: make(map[string]any),
    }
 
    // write data
    go cfg.Set("host", "localhost")
    go cfg.Set("port", 8080)
 
    // concurrent reading from different goroutines
    for i := 0; i < 3; i++ {
        go func(id int) {
            if val, ok := cfg.Get("port"); ok {
                fmt.Printf("Reader %d got port: %v\n", id, val)
            }
        }(i)
    }
 
    time.Sleep(time.Millisecond)
}

In this example, we use RWMutex, which allows multiple goroutines to read data simultaneously (using RLock/RUnlock), while write operations remain mutually exclusive (using Lock/Unlock). This significantly improves performance in cases where read operations occur more frequently than write operations.

Waiting for a Group of Goroutines (WaitGroup)

WaitGroup is a synchronization primitive in Go that allows a goroutine to wait for the completion of a group of other goroutines.

It's like an "active task counter" that helps coordinate work between goroutines.

WaitGroup has three main methods:

  • Add(delta int) - increases the goroutine counter by the specified number. Called before launching new goroutines
  • Done() - decreases the counter by 1. Usually called via defer inside a goroutine
  • Wait() - blocks execution until the counter becomes 0

Think of it as a group of workers, where:

  • Add() - is like assigning tasks to workers
  • Done() - is like a worker reporting task completion
  • Wait() - is like a manager waiting until all workers finish their tasks

Let's look at an example where we want to launch 3 workers (functions that perform certain work) concurrently:

go
package main
 
import (
    "fmt"
    "sync"
    "time"
)
 
func main() {
    // initialize WaitGroup
    var wg sync.WaitGroup
    // mark execution start time
    start := time.Now()
 
    for i := 0; i < 3; i++ { // loop to launch workers
        // add new goroutine to the group in the loop
        wg.Add(1)
        go func(id int) {
            // good practice as defer is called after function exit
            // ensures the goroutine in the group will be marked as completed
            // regardless of possible cases and errors
            defer wg.Done()
 
            // simulate work time
            time.Sleep(1 * time.Second)
            fmt.Printf("Worker %d finished\n", id)
        }(i)
    }
 
    // wait until the number of goroutines in the group becomes 0
    wg.Wait()
    fmt.Printf("All workers completed in %s\n", time.Now().Sub(start))
}

In this example:

  • We create a WaitGroup to track our worker goroutines
  • Each worker is launched in a separate goroutine with wg.Add(1) before launch
  • Workers use defer wg.Done() to signal completion
  • The main goroutine waits for all workers using wg.Wait()

The output will show each worker finishing and the total execution time, which will be approximately 1 second since all workers run concurrently.

go
Worker 0 finished
Worker 2 finished
Worker 1 finished
All workers completed in 1.015099904s

It's also worth noting that the execution order of goroutines in a WaitGroup is not guaranteed, as with any (well, almost any) concurrent code.

Channels

Channels in Go are a powerful mechanism for communication between goroutines that implements the CSP (Communicating Sequential Processes) principle. Channels allow goroutines to safely exchange data and synchronize their work.

I think that basic information about channels can always be found on the official golang website. But I want to bring some clarity for those who don't know or understand yet - why buffered and unbuffered channels exist and why they were designed this way.

If we look under the hood of Go itself, here's what we'll see:

go
// package runtime in go src
type hchan struct {
    qcount   uint           // total data in the queue
    dataqsiz uint           // size of the circular queue
    buf      unsafe.Pointer // points to an array of dataqsiz elements
    elemsize uint16
    closed   uint32
    timer    *timer         // timer feeding this chan
    elemtype *_type         // element type
    sendx    uint           // send index
    recvx    uint           // receive index
    recvq    waitq          // list of recv waiters
    sendq    waitq          // list of send waiters
 
    // lock protects all fields in hchan, as well as several
    // fields in sudogs blocked on this channel.
    //
    // Do not change another G's status while holding this lock
    // (in particular, do not ready a G), as this can deadlock
    // with stack shrinking.
    lock mutex
}
 

This is the internal structure of a channel in Go. Let's break down the key components:

  • qcount and dataqsiz - track the current number of elements and the size of the circular queue (buffer)
  • buf - points to the actual buffer where data is stored
  • sendx and recvx - indices for sending and receiving operations
  • sendq and recvq - queues of goroutines waiting to send or receive
  • lock - mutex to protect the channel's internal state

It's important to understand how channels work in cases when data is being written to and read from them. So, when data is written to a channel, this is what happens:

  1. If there's a goroutine in the recvq queue, the data is transferred directly to it
  2. If the channel is buffered and there's space in the buffer, the data is added to the buffer
  3. Otherwise, the goroutine is blocked and added to the sendq queue

When a goroutine tries to receive data from a channel:

  1. If there's data in the buffer, it gets removed
  2. If there's a goroutine in sendq, the data is received directly from it
  3. Otherwise, the goroutine is blocked and added to recvq

This implementation ensures efficient and safe data transfer between goroutines, guaranteeing FIFO (First-In-First-Out) processing order.

Unbuffered Channels

Unbuffered channels (also known as synchronous) have no internal buffer. This means that sending data to such a channel blocks until another goroutine is ready to receive this data.

Here's an example:

go
package main
 
import (
	"fmt"
	"time"
)
 
func main() {
	// creating an unbuffered channel
	ch := make(chan int)
 
	fmt.Println("send value")
	ch <- 42 // blocks until someone reads
 
	time.Sleep(1 * time.Second)
	fmt.Println("finished")
}

What will this program output? Below, of course, will be the solution and explanation, but try to guess for yourself first.

Solution and explanation

Actually, it's quite simple: we created an unbuffered channel, which means it blocks both during writing and reading. The main function is essentially also a goroutine that remains blocked during program execution, and since we already know that under the hood, unbuffered channels have only one slot for data, meaning when someone writes to an unbuffered channel, it remains blocked until someone reads from it. As a result, all our goroutines are blocked. This is called a deadlock. This can be avoided by simply reading the value from the channel:

go
package main
 
import (
	"fmt"
)
 
func main() {
	// create unbuffered channel
	ch := make(chan int)
 
	go func() {
		fmt.Println("send value")
		ch <- 42 // blocks before someone reads
	}()
 
	<-ch // read from channel
	fmt.Println("got value")
}

Buffered Channels

Buffered channels have an internal buffer of specified size. Sending data only blocks when the buffer is full, and reading blocks when the buffer is empty.

go
package main
 
import "fmt"
 
func main() {
    // creating a buffered channel of size 2
    ch := make(chan int, 2)
 
    ch <- 1 // doesn't block
    ch <- 2 // doesn't block
    // ch <- 3 // would block because buffer is full
 
    fmt.Println(<-ch) // 1
    fmt.Println(<-ch) // 2
}

Main Channel Patterns

In principle, there are certainly more patterns, but I want to focus in detail on 3 of them.

  1. Fan-Out (work distribution) - pattern where one input data stream is distributed among multiple workers
  2. Fan-In (result collection) - pattern where results from many workers are collected into one output channel
  3. Pipeline - pattern of sequential data processing through a chain of channels

Fan-Out & Fan-In patterns

Fan-Out & Fan-In patterns

As we can see from the diagram, these 2 patterns in combination represent a good implementation of concurrent computations. Also, I have prepared an example implementation of these patterns in Go:

go
package main
 
import (
    "fmt"
    "sync"
    "time"
)
 
func fanOut(input <-chan int, workers int) []<-chan int {
    outputs := make([]<-chan int, workers)
 
    for i := 0; i < workers; i++ {
        ch := make(chan int)
        outputs[i] = ch
 
        go func(ch chan int) {
            defer close(ch)
 
            for val := range input {
                ch <- val * 2 // simulate data processing
            }
        }(ch)
    }
 
    return outputs
}
 
func fanIn(inputs ...<-chan int) <-chan int {
    output := make(chan int)
    var wg sync.WaitGroup
 
    // launch goroutine for each input channel
    for _, ch := range inputs {
        wg.Add(1)
        go func(c <-chan int) {
            defer wg.Done()
            for val := range c {
                output <- val
            }
        }(ch)
    }
 
    // close output channel after all goroutines complete
    go func() {
        wg.Wait()
        close(output)
    }()
 
    return output
}
 
func main() {
    start := time.Now()
 
    // initialize input unbuffered channel with data
    input := make(chan int)
    go func() {
        for i := 1; i <= 10; i++ {
            input <- i
        }
        close(input)
    }()
 
    // distribute work between 3 workers
    workers := fanOut(input, 3)
 
    // collect results into a single channel
    results := fanIn(workers...)
 
    // output results
    for result := range results {
        fmt.Println(result)
    }
 
    fmt.Println("Program finished in", time.Now().Sub(start))
}

Let's understand what's happening here:

  • First, an input channel is created with numbers from 1 to 10
  • The fanOut function distributes these numbers between three workers, each of which multiplies the received number by 2
  • The fanIn function collects results from all workers into one output channel
  • At the end, we output all processed numbers and the program execution time

This approach allows for efficient concurrent data processing because:

  • Each worker operates independently in a separate goroutine
  • The use of channels ensures safe data transfer between goroutines

Now let's look at an example implementation of the Pipeline pattern.

Pipeline - a pattern of sequential data processing through a chain of channels:

go
package main
 
import "fmt"
 
func generator() <-chan int {
    out := make(chan int)
 
    go func() {
        defer close(out)
        for i := 0; i < 5; i++ {
            out <- i
        }
    }()
 
    return out
}
 
func multiply(in <-chan int) <-chan int {
    out := make(chan int)
 
    go func() {
        defer close(out)
        for val := range in {
            out <- val * 2
        }
    }()
 
    return out
}
 
func addOne(in <-chan int) <-chan int {
    out := make(chan int)
 
    go func() {
        defer close(out)
        for val := range in {
            out <- val + 1
        }
    }()
 
    return out
}
 
func pipeline() {
    // generate numbers from 0 to 5 (exclusive)
    numbers := generator()
    // multiply each number by 2
    doubled := multiply(numbers)
    // add 1 to each number
    result := addOne(doubled)
 
    // output results
    for val := range result {
        fmt.Println(val)
    }
}
 
func main() {
    // start pipeline
    pipeline()
}

When working with channels, it's important to remember several key rules:

  • Writing to a closed channel will cause a panic
  • Reading from a closed channel returns the zero value of the type
  • Closing a channel should be done by the sender, not the receiver
  • Use select when working with multiple channels

Select is a powerful construct in Go that allows a goroutine to wait for operations on multiple channels simultaneously. It is a key tool for coordinating and synchronizing goroutines.

Main features of select:

  • Can work with multiple channels simultaneously
  • Blocks execution until at least one channel is ready for operation
  • If multiple channels are ready - chooses a random one
  • Can have a default case for non-blocking operations

Let's look at a basic example of using select:

go
func main() {
    ch1 := make(chan string)
    ch2 := make(chan string)
 
    go func() {
        time.Sleep(2 * time.Second)
        ch1 <- "message from first channel"
    }()
 
    go func() {
        time.Sleep(1 * time.Second)
        ch2 <- "message from second channel"
    }()
 
    for i := 0; i < 2; i++ {
        select {
        case msg1 := <-ch1:
            fmt.Println(msg1)
        case msg2 := <-ch2:
            fmt.Println(msg2)
        }
    }
}

Example of non-blocking select with default:

go
select {
case data := <-ch:
    fmt.Println("got:", data)
case ch <- "data":
    fmt.Println("sent")
default:
    fmt.Println("non-blocking operation")
}
 

select is commonly used to implement timeouts:

go
select {
case result := <-ch:
    fmt.Println("got:", result)
case <-time.After(2 * time.Second):
    fmt.Println("timeout")
}
 

Also, select is useful for graceful shutdown of goroutines:

go
package main
 
import (
	"fmt"
	"time"
)
 
func worker(done chan bool) {
	for {
		select {
		case <-done:
			fmt.Println("finishing work")
			return
		default:
			fmt.Println("doing work...")
			time.Sleep(time.Second)
		}
	}
}
 
func main() {
	done := make(chan bool)
	go worker(done)
 
	time.Sleep(3 * time.Second)
	done <- true // signal to finish
	time.Sleep(time.Second)
 
	close(done)
}

select is an indispensable tool when developing concurrent programs in Go, especially when you need to:

  • Handle multiple channels simultaneously
  • Implement operation timeouts
  • Organize non-blocking operations with channels
  • Manage goroutine lifecycles

Once and Cond

Once ensures that code will be executed only once. A perfect example of the singleton design pattern:

go
package main
 
import (
    "fmt"
    "sync"
)
 
type singleton struct{}
 
var (
    instance *singleton
    once     sync.Once
)
 
func getInstance() *singleton {
    once.Do(func() {
        instance = &singleton{}
    })
    return instance
}
 
func main() {
    // get singleton instance twice
    s1 := getInstance()
    s2 := getInstance()
 
    // check that this is the same instance
    fmt.Printf("s1: %p\n", s1)
    fmt.Printf("s2: %p\n", s2)
    fmt.Printf("addresses s1 & s2 equals: %v\n", s1 == s2)
}

Cond is used for waiting for a certain condition:

go
package main
 
import (
    "fmt"
    "sync"
)
 
func main() {
    var (
        condition = sync.NewCond(&sync.Mutex{})
        ready     = false
    )
 
    go func() {
        time.Sleep(time.Second) // imitate work
        condition.L.Lock()
        ready = true
        condition.Signal()
        condition.L.Unlock()
    }()
 
    condition.L.Lock()
    for !ready {
        condition.Wait()
    }
    condition.L.Unlock()
    fmt.Println("Ready!")
}

Atomic Operations sync/atomic

Atomic operations are operations that are executed as a single unit, without the possibility of interruption by other goroutines.

Go has a standard package sync/atomic for working with atomic operations. It's important to understand the difference between a mutex (MX) and an atomic operation (AO):

  • AOs work at the processor level, while MX works at the operating system level
  • AOs are faster than MX since they don't require locking!
  • AOs are suitable for simple operations (increment, decrement, value replacement, etc.), while MX is for complex critical sections

Let's compare the performance of atomic operations and mutexes in practice:

go
package main
 
import (
    "fmt"
    "sync"
    "sync/atomic"
    "testing"
)
 
// structure with AO
type atomicCounter struct {
    counter atomic.Int64
}
 
func (c *atomicCounter) increment() {
    c.counter.Add(1)
}
 
func (c *atomicCounter) getValue() int64 {
    return c.counter.Load()
}
 
// structure with MX
type mutexCounter struct {
    mu      sync.Mutex
    counter int64
}
 
func (c *mutexCounter) increment() {
    c.mu.Lock()
    c.counter++
    c.mu.Unlock()
}
 
func (c *mutexCounter) getValue() int64 {
    c.mu.Lock()
    defer c.mu.Unlock()
    return c.counter
}
 
func BenchmarkAtomic(b *testing.B) {
    counter := &atomicCounter{}
    b.RunParallel(func(pb *testing.PB) {
        for pb.Next() {
            counter.increment()
        }
    })
}
 
func BenchmarkMutex(b *testing.B) {
    counter := &mutexCounter{}
    b.RunParallel(func(pb *testing.PB) {
        for pb.Next() {
            counter.increment()
        }
    })
}
 
func main() {
    // testing with 1 million iterations
    iterations := 1000000
    var wg sync.WaitGroup
 
    // test atomic counter
    atomicStart := testing.Benchmark(BenchmarkAtomic)
    fmt.Printf("Atomic operations: %d ns/op\n", atomicStart.NsPerOp())
 
    // test mutex
    mutexStart := testing.Benchmark(BenchmarkMutex)
    fmt.Printf("Mutex operations: %d ns/op\n", mutexStart.NsPerOp())
 
    ac := &atomicCounter{}
    mc := &mutexCounter{}
 
    for i := 0; i < iterations; i++ {
        wg.Add(2)
        go func() {
            defer wg.Done()
            ac.increment()
        }()
        go func() {
            defer wg.Done()
            mc.increment()
        }()
    }
 
    wg.Wait()
 
    fmt.Printf("Atomic counter final value: %d\n", ac.getValue())
    fmt.Printf("Mutex counter final value: %d\n", mc.getValue())
}
go
Output:
 
Atomic operations: 7 ns/op
Mutex operations: 25 ns/op

As we can see, AO needs an average of 7 nanoseconds per operation, while MX needs 25 nanoseconds per operation. The difference is 3 times, but we shouldn't forget that results may vary somewhat depending on processor architecture and load.

Let's look at an example of using atomic.Pointer[T]:

go
package main
 
import (
    "fmt"
    "sync"
    "sync/atomic"
)
 
// structure for storing data
type Config struct {
    MaxConnections int
    Timeout       int
}
 
func main() {
    // create atomic pointer to Config
    var cfg atomic.Pointer[Config]
 
    // init configuration
    initialConfig := &Config{
        MaxConnections: 100,
        Timeout:       30,
    }
    cfg.Store(initialConfig)
 
    var wg sync.WaitGroup
 
    // read configuration
    wg.Add(1)
    go func() {
        defer wg.Done()
        // atomically read current configuration
        config := cfg.Load()
        fmt.Printf("Reader: MaxConnections=%d, Timeout=%d\n",
            config.MaxConnections,
            config.Timeout)
    }()
 
    // update configuration
    wg.Add(1)
    go func() {
        defer wg.Done()
        // create new configuration
        newConfig := &Config{
            MaxConnections: 200,
            Timeout:       45,
        }
        // atomically update configuration
        cfg.Store(newConfig)
        fmt.Println("Writer: Configuration updated")
    }()
 
    wg.Wait()
 
    // check final state
    finalConfig := cfg.Load()
    fmt.Printf("Final config: MaxConnections=%d, Timeout=%d\n",
        finalConfig.MaxConnections,
        finalConfig.Timeout)
}

In this example, we use atomic.Pointer[T] for safe access to the structure from different goroutines. This ensures that reading and writing the structure happens atomically, without race conditions.

Why map+mutex is better than sync.Map in many cases

In the sync package, Go developers have provided us with another primitive which, it would seem, could be implemented simply with map+mutex, but for some reason this variant exists too. Let's dig a little deeper:

Let's continue our exploration of synchronization primitives in Go. Today I want to tell you about sync.Map - a thread-safe map implementation that has two main advantages:

  • When we have immutable keys that are written once but read many times (for example, for caching)
  • When different goroutines work with different keys in parallel

Let's look at a simple usage example:

go
package main
 
import (
    "fmt"
    "sync"
)
 
func main() {
    var m sync.Map
 
    // write values
    m.Store("key1", "value1")
    m.Store("key2", "value2")
 
    // read values
    value, ok := m.Load("key1")
    if ok {
        fmt.Printf("Value: %v\n", value)
    }
 
    // LoadOrStore will return existing value or store new one
    actual, loaded := m.LoadOrStore("key1", "new value")
    fmt.Printf("Value: %v, already existed: %v\n", actual, loaded)
 
    // loop through all elements
    m.Range(func(key, value interface{}) bool {
        fmt.Printf("Key: %v, Value: %v\n", key, value)
        return true
    })
 
    // delete value
    m.Delete("key1")
}

Let's run some benchmarks?

go
package main
 
import (
    "fmt"
    "sync"
    "testing"
)
 
type MutexMap struct {
    sync.RWMutex
    m map[int]int
}
 
func BenchmarkMutexMap(b *testing.B) {
    mm := &MutexMap{m: make(map[int]int)}
    b.RunParallel(func(pb *testing.PB) {
        i := 0
        for pb.Next() {
            if i%2 == 0 {
                mm.Lock()
                mm.m[i] = i
                mm.Unlock()
            } else {
                mm.RLock()
                _ = mm.m[i]
                mm.RUnlock()
            }
            i++
        }
    })
}
 
func BenchmarkSyncMap(b *testing.B) {
    var sm sync.Map
    b.RunParallel(func(pb *testing.PB) {
        i := 0
        for pb.Next() {
            if i%2 == 0 {
                sm.Store(i, i)
            } else {
                sm.Load(i)
            }
            i++
        }
    })
}
 
func main() {
    mutexResult := testing.Benchmark(BenchmarkMutexMap)
    syncMapResult := testing.Benchmark(BenchmarkSyncMap)
 
    fmt.Printf("MutexMap: %d ns/op\n", mutexResult.NsPerOp())
    fmt.Printf("SyncMap: %d ns/op\n", syncMapResult.NsPerOp())
}
go
Output:
 
MutexMap: 228 ns/op
SyncMap: 1018 ns/op

Let's summarize the main advantages of sync.Map:

  • Built-in thread safety - no need to think about additional synchronization
  • Optimized specifically for parallel access
  • Convenient API for working with data

But there are several points to keep in mind:

  • For single-threaded operations, regular map+mutex will be faster
  • No built-in support for len() to get the map size
  • Need to work with any (interface{}) and perform type assertions

Efficient Object Reuse with sync.Pool

Another useful synchronization primitive in Go is sync.Pool. It allows storing and reusing temporary objects, which helps reduce the load on the GC (Garbage Collector).

Let's break down how sync.Pool works internally in simple terms:

  1. Each processor (P) has local pools
  2. Each processor (P) in Go has its own local object pool. This is designed to reduce contention between goroutines - when a goroutine requests an object, it first checks the local pool of its P
  3. Private and shared caches:
    1. Private cache - accessible only to the current goroutine
    2. Shared cache - accessible to all goroutines on this P
  4. How object Get works:
    1. First checks the private cache
    2. If empty - checks the shared cache
    3. If that's empty too - checks pools of other P
    4. If no objects are found anywhere - calls the New() function
  5. How object Put works:
    1. Object first tries to enter the private cache
    2. If private cache is full - object goes to shared cache
    3. If shared cache is also full - object may be transferred to pools of other Ps
  6. Pool cleanup

During each GC pass, all objects in pools (except those in private caches) can be collected. This prevents excessive object accumulation, but also means you can't rely on objects being permanently available in the pool.

This architecture is very efficient for cases where many goroutines frequently create and delete objects of the same type, while minimizing contention between goroutines for access to shared resources.

I would highlight these main advantages of using sync.Pool:

  • Reduced GC load through object reuse
  • Thread-safe object acquisition and return
  • Automatic pool cleanup during garbage collection

Let's look at a real test from the sync/pool_test.go package regarding sync.Pool operation:

go
// Copyright 2013 The Go Authors. All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.
 
// Pool is no-op under race detector, so all these tests do not work.
//
//go:build !race
 
package sync_test
 
import (
	"runtime"
	"runtime/debug"
	"slices"
	. "sync"
	"sync/atomic"
	"testing"
	"time"
)
 
func TestPool(t *testing.T) {
	// disable GC so we can control when it happens.
	defer debug.SetGCPercent(debug.SetGCPercent(-1))
	var p Pool
	if p.Get() != nil {
		t.Fatal("expected empty")
	}
 
	// Make sure that the goroutine doesn't migrate to another P
	// between Put and Get calls.
	Runtime_procPin()
	p.Put("a")
	p.Put("b")
	if g := p.Get(); g != "a" {
		t.Fatalf("got %#v; want a", g)
	}
	if g := p.Get(); g != "b" {
		t.Fatalf("got %#v; want b", g)
	}
	if g := p.Get(); g != nil {
		t.Fatalf("got %#v; want nil", g)
	}
	Runtime_procUnpin()
 
	// Put in a large number of objects so they spill into
	// stealable space.
	for i := 0; i < 100; i++ {
		p.Put("c")
	}
	// After one GC, the victim cache should keep them alive.
	runtime.GC()
	if g := p.Get(); g != "c" {
		t.Fatalf("got %#v; want c after GC", g)
	}
	// A second GC should drop the victim cache.
	runtime.GC()
	if g := p.Get(); g != nil {
		t.Fatalf("got %#v; want nil after second GC", g)
	}
}

Conclusion

In this article, we've examined in detail what concurrency in Go is and the main synchronization primitives that help create efficient concurrent programs. I hope you're convinced that Go provides powerful tools for working with concurrency, such as sync.Map and sync.Pool, each with its own characteristics and use cases. It is important to understand that choosing the right synchronization primitive depends on the specific use case - where sync.Map might be slower than a regular map+mutex in a single-threaded environment, it can significantly improve performance with parallel access. Similarly, sync.Pool becomes an indispensable tool for optimizing memory usage and reducing garbage collector load in cases of frequent creation and deletion of objects of the same type.

In my humble opinion, Go makes parallel programming more accessible, but at the same time requires understanding the internal mechanisms of synchronization primitives for their effective use.