Skip to main content

Welcome to a Grasp lesson

Create a free account to keep studying and create your own study path.

Lesson 38

Concurrent State Protection with sync.RWMutex

Recommended resources

Hello! Welcome back to our project.

In the last lesson, we laid the essential groundwork for our broker's storage engine by defining the core Topic, Partition, and Message structs. We were forward-thinking and added sync.RWMutex fields to our Topic and Partition structs, anticipating that they would be accessed by multiple goroutines concurrently.

Introduction to the Lesson

Today, we're going to put those mutexes into action. This lesson focuses on the practical application of synchronization primitives to ensure our broker can handle concurrent operations safely.

Your Goal for Today:
Your learning outcome is to protect concurrent access to shared broker state (e.g., topic maps) using sync.RWMutex.

We will create a central Broker struct to manage all topics and implement the logic to add and retrieve topics in a way that is safe from data races. You will learn not just how to use a read-write mutex, but also why it's necessary and the performance trade-offs involved.



1. The Problem: Race Conditions in Go Maps

As you know from your experience, our TCP server will handle each client connection in a separate goroutine. This means multiple producers or consumers could try to access or create topics simultaneously.

Imagine two clients trying to create a topic with the same name at nearly the same time. Both would check if the topic exists, find it doesn't, and then both would try to write to the shared map of topics. This is a classic race condition.

Go's built-in map type is not safe for concurrent access. If one goroutine writes to a map while another is reading or writing, the program may crash with a fatal error, or worse, the map's internal state could become corrupted, leading to unpredictable behavior.

Let's watch a short video that clearly demonstrates this problem and introduces Go's race detector, a powerful tool for identifying these issues.

How You Should Use Mutexes And Atomic Values In Golang?!

The video 'How You Should Use Mutexes And Atomic Values In Golang?!' by Anthony GG provides a fantastic, practical demonstration of a race condition. The first part sets up a simple concurrent program and then uses go test -race to prove that a race condition exists.

Please watch from the beginning of the example at 03:47 until 06:49. Focus on how the program seems to work correctly at first, but the -race flag reveals the hidden danger of concurrent reads and writes to shared state.

This is precisely the problem we need to solve for our broker's central registry of topics.


2. The Solution: sync.RWMutex

Go's standard library provides the sync package with primitives for handling concurrency. The sync.RWMutex (Read-Write Mutex) is the perfect tool for our use case. It allows for two kinds of locks:

  1. Read Lock (RLock, RUnlock): Multiple goroutines can hold a read lock at the same time. This is ideal when you only need to read data without changing it.
  2. Write Lock (Lock, Unlock): Only one goroutine can hold a write lock. While a write lock is held, no other goroutine can acquire a read or write lock. This provides exclusive access for modifying data.

This distinction is crucial for performance. In a system like our broker, retrieving topic information (a read operation) will likely be far more frequent than creating a new topic (a write operation). An RWMutex allows all those read operations to happen in parallel, only serializing access when a write is necessary.

The following diagram illustrates this behavior perfectly.

Read-Write Mutex (RWMutex) Concurrency Flow
This diagram shows that multiple readers (like `G1`) can acquire a read lock and access a resource concurrently. However, when a writer attempts to acquire a write lock, it must wait for all active readers to finish. Once the writer has the lock, all other readers and writers are blocked until it is released.

3. Implementing a Thread-Safe Broker

Let's apply this to our code. First, we need a central component to manage the lifecycle of our topics. We'll create a Broker struct for this.

Open your internal/broker/broker.go file and add the following Broker struct definition and its constructor. It will hold our map of topics and the RWMutex to protect it.

// internal/broker/broker.go

// ... (keep the existing Message, Partition, Topic structs and constructors)

// Broker is the top-level component that manages all topics.
type Broker struct {
	mu     sync.RWMutex
	topics map[string]*Topic
}

// NewBroker creates and returns a new Broker.
func NewBroker() *Broker {
	return &Broker{
		topics: make(map[string]*Topic),
	}
}

Now, let's add methods to this Broker to manage the topics map safely.

Writing to the Map: CreateTopic

When we create a new topic, we are modifying the topics map. This is a write operation, so we must use b.mu.Lock() and b.mu.Unlock(). The defer statement is the idiomatic way to ensure the mutex is always unlocked, even if the function panics or has multiple return paths.

Add this method to Broker:

// internal/broker/broker.go

// ...

// CreateTopic creates a new topic with the given name.
// It is safe for concurrent use.
func (b *Broker) CreateTopic(name string) {
	b.mu.Lock()
	defer b.mu.Unlock()

	// Check if topic already exists while holding the lock
	if _, ok := b.topics[name]; ok {
		return
	}

	b.topics[name] = NewTopic(name)
}

You might recall the AddChannel method from the article "Building a High-Performance Message Queue in Go" that we looked at in the last lesson. It uses the exact same t.mu.Lock() and defer t.mu.Unlock() pattern to safely modify its internal channels map. This is a very common and robust pattern in Go.

Reading from the Map: GetTopic

When we retrieve a topic, we are only reading from the map. This is a perfect use case for a read lock, using b.mu.RLock() and b.mu.RUnlock(). This allows many clients to look up topics concurrently without blocking each other.

Add this method to Broker:

// internal/broker/broker.go

// ...

// GetTopic retrieves a topic by its name.
// It returns the topic and a boolean indicating if it was found.
// It is safe for concurrent use.
func (b *Broker) GetTopic(name string) (*Topic, bool) {
	b.mu.RLock()
	defer b.mu.RUnlock()

	topic, ok := b.topics[name]
	return topic, ok
}

To see this pattern in action in a very similar context, watch the next part of this video.

A trick for safely using Golang Maps more efficiently

The video 'A trick for safely using Golang Maps more efficiently' by Tiago demonstrates refactoring a simple cache to use sync.RWMutex. This example is analogous to our Broker struct managing a map of topics.

Please watch from 02:10 to 04:40. Observe how RLock is used for the get operation (a read) and Lock is used for set and delete operations (writes). This directly mirrors the GetTopic and CreateTopic methods we just wrote.



4. Best Practices and Pitfalls

Using mutexes correctly is key to building reliable concurrent systems. As your goal is to achieve mastery, let's cover some crucial best practices.

Encapsulate Your Locks

A mutex should be treated as a private implementation detail of your struct. Notice that our mu field is unexported (lowercase m). All locking logic is contained within the Broker's methods. Code outside the broker package should not—and cannot—manipulate the lock directly. This prevents callers from misusing the lock, for example, by forgetting to unlock it.

This principle is strongly advocated for in professional Go development.

Production Ready Go Concurrency

The Storj Engineering Blog article 'Production Ready Go Concurrency' offers excellent advice. Let's look at their recommendation for encapsulating locks.

Please read the section titled 'Don’t expose your locks'. It provides a clear 'before' and 'after' example that perfectly matches our approach of keeping the mu field unexported.

Avoid Deadlocks

A deadlock occurs when two or more goroutines are blocked forever, each waiting for the other to release a lock.

Deadlock Achieved Diagram
A classic deadlock: Process 1 has Lock 1 and wants Lock 2. Process 2 has Lock 2 and wants Lock 1. Neither can proceed.

The simplest way to avoid deadlocks is to establish a consistent lock acquisition order. If you ever need to acquire multiple locks, always acquire them in the same order (e.g., always lock A, then lock B). In our current design, we only have one lock in the Broker, so this isn't an issue yet, but it's a vital principle to remember as the system grows.

Keep Lock-Holding Periods Short

A final key principle is to hold a lock for the shortest possible duration. Avoid performing slow operations like I/O, complex computations, or making network calls while holding a lock, as this will block other goroutines from making progress. In our CreateTopic method, we only do a quick map lookup and assignment—this is perfectly fine. The sync.RWMutex section of the Storj blog post also alludes to this, noting that long-lived readers can cause significant delays for writers.



Conclusion

In this lesson, you've taken a crucial step towards building a robust, concurrent message broker. You've moved from simply defining data structures to actively managing shared state in a thread-safe manner.

Key Takeaways:

  • Go's map type is not safe for concurrent use and requires external synchronization.
  • sync.RWMutex is a powerful tool for protecting shared state, offering separate locks for reading and writing to optimize for performance.
  • Write operations (Lock/Unlock) are exclusive, while read operations (RLock/RUnlock) can occur in parallel.
  • Best practices like encapsulating locks, maintaining a consistent lock order, and minimizing the duration a lock is held are essential for preventing bugs like race conditions and deadlocks.

Next Up

We now have a thread-safe, in-memory store for our topics. But the messages themselves are still just transient concepts. In the next lesson, we will focus on the Partition and define a storage interface. This abstraction will be the key to our goal of building swappable backends—allowing us to start with a simple file-based log and later implement a PostgreSQL-backed log without changing our core broker logic.