3

Mutex is the most basic means to control access to critical resources in concurrent programs. Mutex is the native implementation of mutex in Go language.

data structure

The data structure of Mutex is defined in the source package src/sync/mutex.go :

type Mutex struct {
    state int32
    sema  uint32
}

The state field represents the state of the mutex, which is a 32-bit integer. In the internal implementation, the variable is divided into four parts to record four states:

image.png

  • Locked: Indicates whether the mutex has been locked;
  • Woken: Indicates whether to wake up from normal mode;
  • Starving: Indicates whether the Mutex is starving;
  • Waiter: Indicates the number of coroutines waiting for blocking on the mutex.

The sema field represents the semaphore, the coroutine that fails to lock blocks and waits for the semaphore, and the unlocked coroutine releases the semaphore to wake up the coroutine waiting for the semaphore.

normal mode and starvation mode

Mutex has two modes - normal mode and starvation mode. The starvation mode is an optimization introduced in version 1.9 to ensure the fairness of the mutex and prevent the coroutine from starving. By default, the mode of the Mutex is normal mode.

In normal mode, if the lock is unsuccessful, the coroutine will not be immediately transferred to the waiting queue, but will determine whether the spin condition is met, and if so, it will spin.

When the coroutine holding the lock releases the lock, a semaphore will be released to wake up a coroutine in the waiting queue, but if a coroutine is in the process of spinning, the lock will often be acquired by the spinning coroutine arrive. The awakened coroutine has to block again, but before blocking, it will judge how long it has passed since the last blocking to this blocking. If it exceeds 1ms, the Mutex will be marked as starvation mode.

In starvation mode, newly locked coroutines will not enter the spin state, they will only wait at the end of the queue, and after the mutex is released, it will be directly handed over to the coroutine at the front of the waiting queue. If a coroutine acquires the mutex and it is at the end of the queue or it waits less than 1ms, the mutex switches back to normal mode.

Method

The mutex lock Mutex provides two methods Lock and Unlock: the Lock method is called before entering the critical section, and the Unlock method is called when exiting the critical section.

Lock

func (m *Mutex) Lock() {
    if atomic.CompareAndSwapInt32(&m.state, 0, mutexLocked) {
        return
    }
    m.lockSlow()
}

The simplest case is to set mutexLocked to 1 when the state of the lock is 0. If the state of the mutex is not 0, the lockSlow method will be called, which is divided into several parts to introduce the process of acquiring the lock:

  1. Determine whether the current Goroutine can enter the spin;
  2. Waiting for the release of the mutex by spinning;
  3. Calculate the latest state of the mutex;
  4. Update the state of the mutex and acquire the lock.
determines whether the current Goroutine can enter the spin
func (m *Mutex) lockSlow() {
    var waitStartTime int64
    starving := false // 此 goroutine 的饥饿标记
    awoke := false // 唤醒标记
    iter := 0 // 自旋次数
    old := m.state
    for {
        if old&(mutexLocked|mutexStarving) == mutexLocked && runtime_canSpin(iter) {
            if !awoke && old&mutexWoken == 0 && old>>mutexWaiterShift != 0 &&
                atomic.CompareAndSwapInt32(&m.state, old, old|mutexWoken) {
                awoke = true
            }
            runtime_doSpin()
            iter++
            old = m.state
            continue
        }

The conditions for the goroutine to enter the spin are very strict:

  • The mutex can only enter the spin in normal mode;
  • runtime.sync_runtime_canSpin needs to return true:

    • running on a multi-CPU machine;
    • The current Goroutine spins less than 4 times in order to acquire the lock;
    • There is at least one running processor P on the current machine and the run queue for processing is empty.
waits for release of mutex by spinning

Once the current Goroutine is able to spin, it will call runtime.sync_runtime_doSpin and runtime.procyield to execute the PAUSE instruction 30 times, which will only occupy the CPU and consume CPU time:

func sync_runtime_doSpin() {
    procyield(active_spin_cnt)
}

TEXT runtime·procyield(SB),NOSPLIT,$0-0
    MOVL    cycles+0(FP), AX
again:
    PAUSE
    SUBL    $1, AX
    JNZ    again
    RET
Calculate the latest state of the mutex

After processing the spin-related logic, the latest state of the current mutex will be calculated according to the context. Several different conditions update different information stored in the state field—mutexLocked, mutexStarving, mutexWoken, and mutexWaiterShift:

        new := old
        if old&mutexStarving == 0 {
            new |= mutexLocked // 非饥饿状态,加锁
        }
        if old&(mutexLocked|mutexStarving) != 0 {
            new += 1 << mutexWaiterShift // 加锁或饥饿状态,waiter 数量加 1
        }
        if starving && old&mutexLocked != 0 {
            new |= mutexStarving // 设置饥饿状态
        }
        if awoke {
            if new&mutexWoken == 0 {
                throw("sync: inconsistent mutex state")
            }
            new &^= mutexWoken // 新状态清除唤醒标记
        }
updates the state of the mutex and acquires the lock

After the new mutex state is calculated, the state is updated through the CAS function. If the lock is not acquired, runtime.sync_runtime_SemacquireMutex will be called to ensure that the resource will not be acquired by two Goroutines through the semaphore. runtime.sync_runtime_SemacquireMutex will keep trying to acquire the lock in the method and fall into sleep waiting for the release of the semaphore. Once the current Goroutine can acquire the semaphore, it will return immediately and continue to execute the remaining code.

  • In normal mode, this code sets the wakeup and starvation flags, resets the iteration count, and re-executes the loop that acquires the lock;
  • In starvation mode, the current goroutine will acquire the mutex, and if only the current goroutine exists in the waiting queue, the mutex will also exit from starvation mode.
        if atomic.CompareAndSwapInt32(&m.state, old, new) {
            // 原来锁的状态已释放,且不是饥饿状态,获取到锁然后返回
            if old&(mutexLocked|mutexStarving) == 0 {
                break
            }
            
            // 处理饥饿状态
            
            // 如果之前就在队列里面,加入到队列头
            queueLifo := waitStartTime != 0
            if waitStartTime == 0 {
                waitStartTime = runtime_nanotime()
            }
            // 阻塞等待
            runtime_SemacquireMutex(&m.sema, queueLifo, 1)
            // 唤醒之后检查锁是否应该处于饥饿状态
            starving = starving || runtime_nanotime()-waitStartTime > starvationThresholdNs
            old = m.state
            // 如果锁已经处于饥饿状态,直接抢到锁,返回
            if old&mutexStarving != 0 {
                if old&(mutexLocked|mutexWoken) != 0 || old>>mutexWaiterShift == 0 {
                    throw("sync: inconsistent mutex state")
                }
                // 加锁并且将 waiter 数减 1
                delta := int32(mutexLocked - 1<<mutexWaiterShift)
                // 清除饥饿标记
                if !starving || old>>mutexWaiterShift == 1 {
                    delta -= mutexStarving
                }
                atomic.AddInt32(&m.state, delta)
                break
            }
            awoke = true
            iter = 0
        } else {
            old = m.state
        }
    }
}

Unlock

    func (m *Mutex) Unlock() {
        new := atomic.AddInt32(&m.state, -mutexLocked)
        if new != 0 {
            m.unlockSlow(new)
        }
    }
    
    func (m *Mutex) unlockSlow(new int32) {
        if (new+mutexLocked)&mutexLocked == 0 {
            throw("sync: unlock of unlocked mutex")
        }
        if new&mutexStarving == 0 {
            old := new
            for {
                if old>>mutexWaiterShift == 0 || old&(mutexLocked|mutexWoken|mutexStarving) != 0 {
                    return
                }
                new = (old - 1<<mutexWaiterShift) | mutexWoken
                if atomic.CompareAndSwapInt32(&m.state, old, new) {
                    runtime_Semrelease(&m.sema, false, 1)
                    return
                }
                old = m.state
            }
        } else {
            runtime_Semrelease(&m.sema, true, 1)
        }
    }

The unlocking process of the mutex is relatively simple. The process will first use the sync.atomic.AddInt32 function to unlock quickly, and then execute the slow unlocking process after failure. unlockSlow will first verify the validity of the lock state - if the current mutex has been unlocked, an exception will be thrown directly. Then according to the current state of the mutex, it is processed separately in normal mode and starvation mode:

  • In normal mode, the above code would use the processing shown below:

    • If there is no waiter for the mutex or the mutexLocked, mutexStarving, and mutexWoken states of the mutex are not all 0, the current method can return directly without waking up other waiters;
    • If the mutex has a waiter, it will wake up the waiter through sync.runtime_Semrelease and transfer the ownership of the lock;
  • In starvation mode, the above code will directly call sync.runtime_Semrelease to hand over the current lock to the next waiter who is trying to acquire the lock. The waiter will get the lock after being woken up, and the mutex will not exit the starvation state at this time.

prone scene

There are four types of common error scenarios when using Mutex, namely Lock/Unlock is not paired, Copy used Mutex, reentrancy and deadlock. The other three are relatively simple, and here we focus on reentrancy.

reentrant

The standard library Mutex is not a reentrant lock, which means that the same lock cannot be acquired multiple times in a goroutine. If you want to implement a reentrant lock based on Mutex, you can have the following two solutions:

  • The goroutine id is obtained by hacker, and the goroutine id that acquires the lock is recorded. It can implement the Locker interface.
  • When calling the Lock/Unlock method, the goroutine provides a token to identify itself instead of obtaining the goroutine id through the hacker method. However, this does not satisfy the Locker interface.

Reentrant locks solve the deadlock problem caused by code reentrancy or recursive calls. At the same time, it also brings another benefit, that is, we can require that only the goroutine holding the lock can unlock the lock. This is also easy to implement, because in the above two schemes, which goroutine holds the lock has been recorded.

Scheme 1: goroutine id

The key first step of this scheme is to obtain the goroutine id. There are two ways, the simple way and the hacker way.

The simple way is to obtain the stack frame information through the runtime.Stack method, and the stack frame information contains the goroutine id. The runtime.Stack method can get the current goroutine information.

Next, let's look at the hacker method. We obtain the g pointer at runtime, and decipher the corresponding g structure. The g pointer to the structure of each running goroutine is stored in an object called TLS in the current goroutine.

  1. We first get the TLS object;
  2. Then get the g pointer of the goroutine structure from TLS;
  3. Then take the goroutine id from the g pointer.

We don't need to reinvent the wheel, just use a third-party library to get the goroutine id. There are already many mature libraries, such as pettermattis/goid . Next we implement a reentrant lock that can be used:

// RecursiveMutex 包装一个Mutex,实现可重入
type RecursiveMutex struct {
    sync.Mutex
    owner     int64 // 当前持有锁的goroutine id
    recursion int32 // 这个goroutine 重入的次数
}

func (m *RecursiveMutex) Lock() {
    gid := goid.Get()
    // 如果当前持有锁的goroutine就是这次调用的goroutine,说明是重入
    if atomic.LoadInt64(&m.owner) == gid {
        m.recursion++
        return
    }
    m.Mutex.Lock()
    // 获得锁的goroutine第一次调用,记录下它的goroutine id,调用次数加1
    atomic.StoreInt64(&m.owner, gid)
    m.recursion = 1
}

func (m *RecursiveMutex) Unlock() {
    gid := goid.Get()
    // 非持有锁的goroutine尝试释放锁,错误的使用
    if atomic.LoadInt64(&m.owner) != gid {
        panic(fmt.Sprintf("wrong the owner(%d): %d!", m.owner, gid))
    }
    // 调用次数减1
    m.recursion--
    if m.recursion != 0 { // 如果这个goroutine还没有完全释放,则直接返回
        return
    }
    // 此goroutine最后一次调用,需要释放锁
    atomic.StoreInt64(&m.owner, -1)
    m.Mutex.Unlock()
}
scheme 2: token

The first solution is to use the goroutine id as the goroutine identifier, or we can let the goroutine provide the identifier itself. Anyway, Go developers don't expect users to do something indeterminate with goroutine id, so they don't expose a way to get goroutine id.

We can design it in such a way that the caller provides a token by itself, which is passed in when acquiring the lock, and also needs to be passed in when releasing the lock. The goroutine id in scheme 1 is replaced by the token passed in by the user, and the other logic is the same as scheme 1.

extension

TryLock

We can add a TryLock method to the Mutex, which is to try to acquire the lock. When a goroutine calls the TryLock method to request a lock, if the lock is not held by other goroutines, then the goroutine holds the lock and returns true. If the lock is already held by another goroutine, or is preparing to be handed over to a wake-up goroutine, then return false directly without blocking the method call.

// 复制Mutex定义的常量
const (
    mutexLocked = 1 << iota // 加锁标识位置
    mutexWoken              // 唤醒标识位置
    mutexStarving           // 锁饥饿标识位置
    mutexWaiterShift = iota // 标识waiter的起始bit位置
)

// 扩展一个Mutex结构
type Mutex struct {
    sync.Mutex
}

// 尝试获取锁
func (m *Mutex) TryLock() bool {
    // 如果能成功抢到锁
    if atomic.CompareAndSwapInt32((*int32)(unsafe.Pointer(&m.Mutex)), 0, mutexLocked) {
        return true
    }
    // 如果处于唤醒、加锁或者饥饿状态,这次请求就不参与竞争了,返回false
    old := atomic.LoadInt32((*int32)(unsafe.Pointer(&m.Mutex)))
    if old&(mutexLocked|mutexStarving|mutexWoken) != 0 {
        return false
    }
    // 尝试在竞争的状态下请求锁
    new := old | mutexLocked
    return atomic.CompareAndSwapInt32((*int32)(unsafe.Pointer(&m.Mutex)), old, new)
}

Line 17 is a fast path. If you are lucky and no other goroutines contend for the lock, then the lock will be acquired by the requested goroutine and returned directly.

If the lock is already held by other goroutines, or ready to be held by other wake-up goroutines, then return false directly and no longer request, the code logic is in line 23.

If it is not held, there are no other wake-up goroutines competing for the lock, and the lock is not starved, try to acquire the lock (line 29), and return the result whether successful or not. Because, at this time, there may be other goroutines also competing for the lock, so there is no guarantee that the lock will be successfully acquired.

Get indicators such as the number of

Mutex's data structure contains two fields: state and sema. The first four bytes (int32) are the state field. The state field in the Mutex structure has many meanings. Through the state field, you can know whether the lock has been held by a certain goroutine, whether it is currently in a starvation state, whether any waiting goroutines are awakened, and the number of waiters. However, the state field is not exposed. How to get the unexposed field? Very simple, we can do it in an unsafe way.

const (
    mutexLocked = 1 << iota // mutex is locked
    mutexWoken
    mutexStarving
    mutexWaiterShift = iota
)

type Mutex struct {
    sync.Mutex
}

func (m *Mutex) Count() int {
    // 获取state字段的值
    v := atomic.LoadInt32((*int32)(unsafe.Pointer(&m.Mutex)))
    v = v >> mutexWaiterShift //得到等待者的数值
    v = v + (v & mutexLocked) //再加上锁持有者的数量,0或者1
    return int(v)
}

Line 14 of this example uses the unsafe operation to get the value of the state field. On line 15, the current number of waiters is obtained by shifting right by three places (where the constant mutexWaiterShift has a value of 3). If the current lock is already held by another goroutine, then we adjust this value a little bit, add a 1 (line 16), and basically think of it as the goroutine currently holding and waiting for the lock total.


与昊
225 声望636 粉丝

IT民工,主要从事web方向,喜欢研究技术和投资之道