iTranslated by AI
Exploring the Mechanics of sync.Mutex
Introduction
You can perform operations on values atomically by using the Lock and Unlock methods of sync.Mutex. I investigated how these mechanisms work and summarized my findings in this article.
Summary
The following sequence diagram summarizes the lock/unlock process using sync.Mutex across two threads. sync.Mutex has two fields, state and sema, and maintains its state through these values.
If the mutex is already locked when the Lock method is executed, it waits via the runtime_SemacquireMutex function. When the Unlock method is executed in another thread and the runtime_Semrelease function is called within it, the thread waiting in the runtime_SemacquireMutex function is notified and resumes processing.
In the next section, we will look at how to use sync.Mutex and explore the details.

Usage
Let's briefly review how to use sync.Mutex.
In this case, we'll look at the code from A Tour of Go as an example.
Suppose we define a SafeCounter struct that has a field of type sync.Mutex as shown below.
The Inc method operates on values atomically; you use it by wrapping the critical section with Lock and Unlock methods. It is also common to write it as defer c.mu.Unlock().
// SafeCounter is safe to use concurrently.
type SafeCounter struct {
mu sync.Mutex
v map[string]int
}
// Inc increments the counter for the given key.
func (c *SafeCounter) Inc(key string) {
c.mu.Lock()
// Lock so only one goroutine at a time can access the map c.v.
c.v[key]++
c.mu.Unlock()
}
Mutex Struct
Previously, I showed an example of using sync.Mutex with its zero value. The fields of the struct are as follows. Since the zero value of sync.Mutex represents an unlocked state, it is unlocked when both state and sema are 0.
type Mutex struct {
state int32
sema uint32
}
How Lock Works
Looking at the sync package, the Lock method is implemented as follows:
// Lock locks m.
// If the lock is already in use, the calling goroutine
// blocks until the mutex is available.
func (m *Mutex) Lock() {
// Fast path: grab unlocked mutex.
if atomic.CompareAndSwapInt32(&m.state, 0, mutexLocked) {
if race.Enabled {
race.Acquire(unsafe.Pointer(m))
}
return
}
// Slow path (outlined so that the fast path can be inlined)
m.lockSlow()
}
At first glance, it might not be clear what CompareAndSwapInt32 does. This is an atomic instruction called Compare-and-swap (CAS). It compares the value at a specified pointer and, if they are equal, swaps it with a new value.
The pseudo-code for CAS is as follows. If the value at the address pointed to by the pointer is equal to old, it assigns the new value and returns true; otherwise, it returns false without doing anything.
function cas(p: pointer to int, old: int, new: int) is
if *p ≠ old
return false
*p ← new
return true
In other words, atomic.CompareAndSwapInt32(&m.state, 0, mutexLocked) updates state to mutexLocked and returns true if the value of the state field is 0.
By the way, the following defined constants are used for the state value:
const (
mutexLocked = 1 << iota // mutex is locked
mutexWoken
mutexStarving
mutexWaiterShift = iota
)
The subsequent race.Enabled is a constant false, so it can be ignored. Thus, in the first Lock method call, the mutexLocked flag is assigned to state, and the operation completes.
Next, let's consider a scenario where another thread tries to lock while it is still locked.
In this case, atomic.CompareAndSwapInt32(&m.state, 0, mutexLocked) returns false because the value of state is not 0. Therefore, lockSlow is executed in the second run.
lockSlow
lockSlow performs complex processing, but to put it very simply, it works as follows. After updating m.state to a new value, it executes runtime_SemacquireMutex. When this method is executed, it waits until the value pointed to by the first argument becomes greater than 0. If you are interested in the details of runtime_SemacquireMutex, please read sema.go in the runtime package.
Assuming the Lock method has been called twice and it hasn't been unlocked yet, the value of m.sema will be 0, so it will wait here for the value to change.
func (m *Mutex) lockSlow() {
for {
...
if atomic.CompareAndSwapInt32(&m.state, old, new) {
...
runtime_SemacquireMutex(&m.sema, queueLifo, 1)
...
}
...
}
}
How Unlock Works
The code for Unlock is as follows. Since the return value of AddInt32 is the result of the calculation, if the value of m.state was mutexLocked, the value of m.state becomes 0 and the process ends.
In other cases, the unlockSlow method is executed. That is, if m.state was updated to a value other than mutexLocked by the aforementioned lockSlow method, this unlockSlow is executed.
func (m *Mutex) Unlock() {
if race.Enabled {
_ = m.state
race.Release(unsafe.Pointer(m))
}
// Fast path: drop lock bit.
new := atomic.AddInt32(&m.state, -mutexLocked)
if new != 0 {
// Outlined slow path to allow inlining the fast path.
// To hide unlockSlow during tracing we skip one extra frame when tracing GoUnblock.
m.unlockSlow(new)
}
}
unlockSlow
To put it very simply, the unlockSlow method works as follows.
It increments the value of &m.sema in runtime_Semrelease and notifies the goroutines waiting in Semacquire.
If you are interested in the details of this method, please read sema.go.
Therefore, executing unlockSlow sends a notification to the thread waiting in runtime_SemacquireMutex within lockSlow, allowing the process to resume.
func (m *Mutex) unlockSlow(new int32) {
...
runtime_Semrelease(&m.sema, false, 1)
...
}
Final Thoughts
Now that we have examined the details of Lock and Unlock, I will show the diagram again. I hope this article makes it even slightly easier to visualize how sync.Mutex works.

Discussion