Mutex is a common concurrency primitive in Golang. It is not only often used during the development process, but also relies on Mutex to implement the concurrency structure with golang characteristics.
type Mutex struct { // The state of the mutex, such as whether it is locked state int32 // Indicates semaphore. The blocked coroutine will wait for the semaphore, and the unlocked coroutine will release the semaphore sema int32 }
const ( // Is it currently locked mutexLocked = 1 << iota // 1 // Is there a wake-up goroutine currently mutexWoken // 2 // Is it currently in a hungry state mutexStarving // 4 // state >> mutexWaiterShift gets the number of waiters mutexWaiterShift = iota // 3 starvationThresholdNs = 1e6 // Threshold for determining whether to enter a hunger state)
Mutex has normal hunger mode.
- Normal mode: The waiter will join the team, but an awakened waiter cannot hold the lock and compete with the newly arrived goroutine. New goroutines have an advantage - they are already running on the CPU.
If the lock is not obtained for more than 1ms, it will enter hunger mode - Hunger Mode: The ownership of the lock is handed over directly to the queue header goroutine. The new goroutine will not try to acquire the mutex, and will not try to rotate even if the mutex looks unlocked. Instead, they themselves are at the end of the waiting queue.
If the waiter is the last one, or if the waiter is less than 1ms, it will switch back to normal mode.
Get the lock
Unlocked - Get it directly
func (m *Mutex) Lock() { // Fast path. Get the unlocked mutex directly // The initial state is 0, so as long as the state has any other state bits, it cannot be directly retrieved. if atomic.CompareAndSwapInt32(&, 0, mutexLocked) { if { ((m)) } return } // Slow path (outlined so that the fast path can be inlined) () }
Try spinning without being hungry and not being spinning much
// As long as the original state is locked and not in a hungry state, the spin condition is met if old&(mutexLocked|mutexStarving) == mutexLocked && runtime_canSpin(iter) { // In the current goroutine is not awakened, and no other goroutine is trying to wake up, and there is a waiting period, the cas flag has a goroutine trying to wake up. If the mark is successful, the current goroutine has been awakened. if !awoke && old&mutexWoken == 0 && old>>mutexWaiterShift != 0 && atomic.CompareAndSwapInt32(&, old, old|mutexWoken) { awoke = true } // Spin runtime_doSpin() // Add one spin iter++ // Update the original status old = continue }
The specific spin conditions are as follows
- Spin count less than 4
- Multi-core CPU
- The number of p is greater than 1
- At least one p queue is empty
const ( locked uintptr = 1 active_spin = 4 active_spin_cnt = 30 passive_spin = 1 ) func sync_runtime_canSpin(i int) bool { // is cooperative, so we are conservative with spinning. // Spin only few times and only if running on a multicore machine and // GOMAXPROCS>1 and there is at least one other running P and local runq is empty. // As opposed to runtime mutex we don't do passive spinning here, // because there can be work on global runq or on other Ps. if i >= active_spin || ncpu <= 1 || gomaxprocs <= int32(+)+1 { return false } if p := getg().(); !runqempty(p) { return false } return true }
What exactly is spin doing?
The spin is executed by the method runtime_doSpin(), and the actual call to procyield() is called.
# Define a text segment, and does not use stack splitting through NOSPLIT. $0-0 means that the function does not require any input parameters and return values.TEXT runtime·procyield(SB),NOSPLIT,$0-0 # Read cycles entry value from stack frame and store it in register R0 MOVWU cycles+0(FP), R0 # Make an infinite loop. In each loop, the CPU is told to put the current thread into sleep state through YIELD# YIELD: On x86, it is implemented as a PAUSE instruction, which will pause processor execution, switch CPU to low power mode and wait for more data to arrive. Usually used for busy waiting mechanism to avoid unnecessary CPU usage# On ARM, it is implemented as WFE (Wait For Event), which is used to wait for interrupts or other events to occur. In some cases, it may cause the CPU to fall into a dead loop, so special processing logic is required to solve it.again: YIELD # Decrement R0 value by 1 SUBW $1, R0 # CBNZ (Compare and Branch on Non-Zero) Check whether the remaining clock cycles are 0. If not 0, jump to the tag again and call YIELD again, otherwise exit the function CBNZ R0, again RET
Thanks to chatgpt for its strong support for the above assembly code analysis process
From the code, you can see that the number of spins is 30 times
const active_spin_cnt = 30 func sync_runtime_doSpin() { procyield(active_spin_cnt) }
Calculate the expected state
1. The original state is not in the hungry state, and the new state is set to the locked state bit
The original state is in locked or hungry mode, the new state is set to wait for the number to increase
The current goroutine is the latest goroutine to acquire the lock. In normal mode, it is expected to acquire the lock, so the new state locked state bit should be set.
If the lock has been seized or is in hunger mode, then you should queue up
2. If the hunger threshold time has exceeded when trying to obtain it, and the original state is locked, then the new state sets the hunger state bit
3. If goroutine is awake, the new state clears the wake-up state bit
The expectation is that the lock has been acquired, so naturally the status bit being acquired must be cleared.
new := old // Don't try to acquire starving mutex, new arriving goroutines must queue. // If the original state is not in a hungry state, set the new state to be locked if old&mutexStarving == 0 { new |= mutexLocked } // As long as the original state is in locked or hungry mode, the number of new states waits for if old&(mutexLocked|mutexStarving) != 0 { new += 1 << mutexWaiterShift } // If you have waited for the time of hunger exceeding the threshold and the original state is locked, set the new state to hunger // This also means that if it is no longer locked, you can switch back to normal mode. if starving && old&mutexLocked != 0 { new |= mutexStarving } // If it has been awakened (that is, there is no other goroutine being preempted), clear the wake-up status bit in the new state if awoke { // The goroutine has been woken from sleep, // so we need to reset the flag in either case. if new&mutexWoken == 0 { throw("sync: inconsistent mutex state") } new &^= mutexWoken }
Try to achieve the lock expectation
cas attempts to update from the original state to the new expected state
If it fails, update the latest status and continue to try to acquire the lock
It means that the lock has been seized during this period
If it turns out that it is neither locked nor in hunger mode, then you will get the lock and return directly
queue. If you have been waiting before, you will be queued to the head of the queue
Get the semaphore. There will be blockage and waiting here
Wake up and determined to have held the lock. And do the following hunger-related treatments
- Calculate the waiting time. If the hunger threshold is exceeded, it marks that the current goroutine is hungry.
- If the lock is in hunger mode, the number of waits is reduced, and when there is only one wait, switch the lock back to normal mode.
if atomic.CompareAndSwapInt32(&, old, new) { // If the original state is neither locked nor hungry // Then it means that the lock has been obtained and exit directly if old&(mutexLocked|mutexStarving) == 0 { break // locked the mutex with CAS } // If you are already waiting, queue to the head of the queue queueLifo := waitStartTime != 0 if waitStartTime == 0 { waitStartTime = runtime_nanotime() } // Try to get the semaphore. Get a semaphore here to achieve mutual exclusion // There will be blockage here runtime_SemacquireMutex(&, queueLifo, 1) // After being awakened by the semaphore, if the waiting time exceeds the hunger threshold, switch to hunger mode starving = starving || runtime_nanotime()-waitStartTime > starvationThresholdNs old = // In hunger mode if old&mutexStarving != 0 { // If this goroutine was woken and mutex is in starvation mode, // ownership was handed off to us but mutex is in somewhat // inconsistent state: mutexLocked is not set and we are still // accounted as waiter. Fix that. // If you are not locked and trying to wake up, or if you wait for the queue to be empty, it means that an inconsistent state has occurred. if old&(mutexLocked|mutexWoken) != 0 || old>>mutexWaiterShift == 0 { throw("sync: inconsistent mutex state") } // The current goroutine has acquired the lock, and the waiting queue is reduced by 1; if there are only one waiter, switch to normal mode. quit delta := int32(mutexLocked - 1<<mutexWaiterShift) if !starving || old>>mutexWaiterShift == 1 { delta -= mutexStarving } atomic.AddInt32(&, delta) break } // Not in hunger mode, set the current goroutine to wake up, reset the number of times you want to try to acquire the lock awoke = true iter = 0 } else { // If the lock is occupied by other goroutines, update the original status and continue to try to acquire the lock old = }
Consider several scenarios
- If there is currently only one goroutine g1 to acquire the lock, then the path will be directly fast, and the cas updates the locked status bit and obtains the lock.
- If the lock is already held by g1,
- At this time, g2 will spin 4 times first.
- Then calculate that the expected state is locked, the waiting number is 1, and the wake-up status bit is cleared
- When cas is updated, try to update the lock status successfully. Then, because the original state itself is locked, the lock cannot be obtained. You can only queue up and the semaphore is blocked.
- After g1 releases the lock, g2 is awakened, and then the expected state is calculated again, and the cas updates the state successfully, and the lock is directly obtained.
- If the lock is already held by g1 and g2 exceeds 1ms (that is, the hunger threshold) when it first attempts to obtain it, then
- Calculate the expected state as locked, hungry, clear wakeup status bits
- Cas update status successfully, is ranked at the head of the queue, and is blocked by the semaphore
- After g1 releases the lock, g2 wakes up and directly acquires the lock, subtracts the number of queues and clears the hunger position
Release the lock
Only locked - directly released
If there is no queued goroutine, not in a hungry state, and no goroutine that really tries to acquire the lock, then you can directly update the status to 0
func (m *Mutex) Unlock() { // Fast path: drop lock bit. new := atomic.AddInt32(&, -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. (new) } }
Slow release
- If the original lock is not locked, an error will be reported
- If the original state is not in a hungry state, try to wake up the waiter
- If the lock has been acquired, is being acquired, is hungry, or is not waiting, return directly
- The number of expected state waiting is reduced by 1 and the wake-up status bit is set
- cas tries to update the expected state, if successful, release
- The failure means that there is another goroutine trying to obtain during this process, so continue to the next round of release.
- In a state of hunger, release the semaphore directly, hand over the lock ownership
func (m *Mutex) unlockSlow(new int32) { // If the original state does not have a locked state bit at all if (new+mutexLocked)&mutexLocked == 0 { throw("sync: unlock of unlocked mutex") } // If the original state is not in a hungry state if new&mutexStarving == 0 { old := new for { // If there is no waiting, or the goroutine has been awakened or has been locked, there is no need to wake up anyone, return if old>>mutexWaiterShift == 0 || old&(mutexLocked|mutexWoken|mutexStarving) != 0 { return } // Grab the right to wake someone. // Set the expected state to be fetching the status bit and subtract a waiter new = (old - 1<<mutexWaiterShift) | mutexWoken // Try to update the expected new state. If it succeeds, release the semaphore, and if it fails, update the original state and perform the next round of release. // Failure means that there are goroutines trying to obtain in the process, such as having already obtained, becoming hungry, spin, etc. if atomic.CompareAndSwapInt32(&, old, new) { runtime_Semrelease(&, false, 1) return } old = } } else { // In hunger mode, hand over the lock ownership to the next waiter and give up the time slice so that the waiter can start quickly runtime_Semrelease(&, true, 1) } }
This is the end of this article about the specific methods of Golang Mutex to achieve mutual exclusion. For more relevant Golang Mutex content, please search for my previous articles or continue browsing the related articles below. I hope everyone will support me in the future!