iTranslated by AI

The content below is an AI-generated translation. This is an experimental feature, and may contain errors. View original article
🔒

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.

Sequence diagram of Lock/Unlock using Mutex

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.

Sequence diagram of Lock/Unlock using Mutex

GitHubで編集を提案

Discussion