MK
摩柯社区 - 一个极简的技术知识社区
AI 面试

Go语言Mutex锁的公平性和性能之间的权衡

2022-09-065.4k 阅读

Go语言Mutex锁概述

在Go语言的并发编程中,sync.Mutex是一种常用的同步原语,用于保护共享资源,防止多个 goroutine 同时访问造成数据竞争。它的实现相对简单,但其内部机制却涉及到公平性和性能之间的微妙权衡。

Mutex 本质上是一个二元信号量,它只有两个状态:锁定(locked)和未锁定(unlocked)。当一个 goroutine 调用 Lock 方法时,如果锁处于未锁定状态,它将获取锁并将其状态设置为锁定;如果锁已被锁定,调用者将被阻塞,直到锁被释放。Unlock 方法则用于将锁的状态从锁定改为未锁定,并唤醒一个等待的 goroutine。

公平性的概念

在并发编程的场景下,公平性是指等待时间最长的 goroutine 有优先获取锁的权利。公平的锁调度策略确保所有等待的 goroutine 都有机会按照它们到达的顺序获取锁,不会出现某个 goroutine 一直被饿死(即长时间无法获取锁)的情况。

公平锁的优点

  1. 可预测性:对于需要确定性执行顺序的场景,公平锁能保证各个 goroutine 按照一定顺序获取锁,这对于一些依赖顺序的操作非常重要,比如数据库事务按顺序执行以保证数据一致性。
  2. 避免饥饿:公平锁可以防止某些 goroutine 因为其他 goroutine 频繁获取锁而长时间无法执行,提高了系统的整体公平性和可靠性。

公平锁的缺点

  1. 性能开销:实现公平性通常需要额外的开销,例如维护等待队列,每次锁的获取和释放都需要操作这个队列,这增加了时间复杂度,降低了锁的获取和释放速度。
  2. 上下文切换:公平锁可能导致更多的上下文切换。因为等待队列中的 goroutine 在被唤醒后需要重新调度执行,这会增加 CPU 的负担,特别是在高并发场景下,频繁的上下文切换会严重影响系统性能。

性能的考量

在并发编程中,性能是一个关键指标,特别是在高并发场景下,锁的性能直接影响整个系统的吞吐量和响应时间。

影响性能的因素

  1. 锁的获取和释放时间:锁的获取和释放操作应该尽可能快,减少锁的持有时间可以降低其他 goroutine 的等待时间,从而提高系统的并发性能。
  2. 锁争用:当多个 goroutine 频繁竞争同一个锁时,会导致锁争用。高锁争用会增加等待队列的长度,使得更多的 goroutine 处于阻塞状态,降低系统的并发度。
  3. 缓存一致性:在多处理器系统中,锁的操作可能会影响缓存一致性。频繁的锁操作可能导致缓存行的频繁失效和重新加载,增加了内存访问的开销。

Go语言Mutex锁的实现

Go语言的 sync.Mutex 实现采用了一种混合的策略,在公平性和性能之间进行了权衡。

数据结构

type Mutex struct {
    state int32
    sema  uint32
}

state 字段用于表示锁的状态,它是一个 32 位整数,低 2 位用于表示锁的锁定状态(0 表示未锁定,1 表示锁定),高 30 位用于表示等待队列中等待的 goroutine 数量。sema 字段是一个信号量,用于阻塞和唤醒等待的 goroutine。

Lock 方法

func (m *Mutex) Lock() {
    // 快速路径:尝试直接获取锁
    if atomic.CompareAndSwapInt32(&m.state, 0, mutexLocked) {
        return
    }
    // 慢速路径:获取锁失败,进入等待队列
    m.lockSlow()
}

Lock 方法首先尝试通过 atomic.CompareAndSwapInt32 原子操作快速获取锁。如果当前锁处于未锁定状态(state 为 0),则将其设置为锁定状态(mutexLocked)并返回。如果快速获取锁失败,说明锁已被其他 goroutine 持有,调用 lockSlow 方法进入慢速路径。

func (m *Mutex) lockSlow() {
    var waitStartTime int64
    starving := false
    awoke := false
    iter := 0
    old := m.state
    for {
        // 检查锁是否可用且当前 goroutine 应该获取锁
        if old&(mutexLocked|mutexStarving) == 0 &&
            atomic.CompareAndSwapInt32(&m.state, old, old|mutexLocked) {
            if starving && old&mutexWaiterShift != 0 {
                // 如果有饥饿的 goroutine,唤醒一个
                runtime_Semrelease(&m.sema, false, 1)
            }
            return
        }
        // 更新等待状态
        new := old
        if old&mutexStarving == 0 {
            new |= mutexWaiterShift
        }
        if starving || old&mutexLocked == 0 {
            new |= mutexStarving
        }
        if awoke {
            new &^= mutexWoken
        }
        old = atomic.SwapInt32(&m.state, new)
        if old&(mutexLocked|mutexStarving) != 0 {
            // 等待锁
            runtime_SemacquireMutex(&m.sema, queueLifo, 1)
            starving = starving || runtime_nanotime()-waitStartTime > starvationThresholdNs
            awoke = true
            iter++
        }
    }
}

lockSlow 方法中,首先检查锁是否可用且当前 goroutine 是否应该获取锁。如果锁可用且当前 goroutine 可以获取锁(例如,没有饥饿的 goroutine 或者当前 goroutine 是饥饿的且锁可用),则获取锁并返回。否则,更新锁的状态,将自己加入等待队列,并通过 runtime_SemacquireMutex 阻塞等待锁的释放。

Unlock 方法

func (m *Mutex) Unlock() {
    // 快速路径:尝试直接释放锁
    new := atomic.AddInt32(&m.state, -mutexLocked)
    if new != 0 {
        // 慢速路径:释放锁失败,处理等待队列
        m.unlockSlow(new)
    }
}

Unlock 方法首先尝试通过 atomic.AddInt32 原子操作快速释放锁。如果释放成功(new 为 0),说明没有等待的 goroutine,直接返回。如果释放失败,说明有等待的 goroutine,调用 unlockSlow 方法处理等待队列。

func (m *Mutex) unlockSlow(new int32) {
    if (new+mutexLocked)&mutexLocked == 0 {
        throw("sync: unlock of unlocked mutex")
    }
    if new&mutexStarving == 0 {
        old := new
        for {
            // 尝试唤醒一个非饥饿的 goroutine
            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 = atomic.LoadInt32(&m.state)
        }
    } else {
        // 唤醒一个饥饿的 goroutine
        runtime_Semrelease(&m.sema, true, 1)
    }
}

unlockSlow 方法中,如果当前锁没有处于饥饿状态,尝试唤醒一个非饥饿的 goroutine。如果当前锁处于饥饿状态,则唤醒一个饥饿的 goroutine。

公平性和性能的权衡

Go语言的 sync.Mutex 在公平性和性能之间进行了巧妙的权衡。

性能优先策略

在大多数情况下,sync.Mutex 采用性能优先的策略。Lock 方法首先尝试快速获取锁,如果成功则立即返回,避免了进入慢速路径的开销。这种策略使得在锁争用较低的情况下,锁的获取和释放非常高效,提高了系统的并发性能。

公平性机制

然而,为了避免 goroutine 饥饿,sync.Mutex 也引入了一定的公平性机制。当一个 goroutine 等待锁的时间超过一定阈值(starvationThresholdNs)时,它会被标记为饥饿状态。在这种情况下,锁的释放会优先唤醒饥饿的 goroutine,保证了公平性。

代码示例

package main

import (
    "fmt"
    "sync"
    "time"
)

var mu sync.Mutex
var sharedResource int

func worker(id int) {
    for i := 0; i < 5; i++ {
        mu.Lock()
        fmt.Printf("Worker %d acquired the lock, sharedResource: %d\n", id, sharedResource)
        sharedResource++
        time.Sleep(time.Millisecond * 100)
        fmt.Printf("Worker %d released the lock, sharedResource: %d\n", id, sharedResource)
        mu.Unlock()
        time.Sleep(time.Millisecond * 100)
    }
}

func main() {
    var wg sync.WaitGroup
    for i := 0; i < 3; i++ {
        wg.Add(1)
        go func(id int) {
            defer wg.Done()
            worker(id)
        }(i)
    }
    wg.Wait()
}

在这个示例中,多个 goroutine 竞争同一个 Mutex 锁来访问共享资源 sharedResource。通过观察输出,可以看到不同 goroutine 获取锁的顺序并非严格按照启动顺序,这体现了 sync.Mutex 在性能优先的情况下,并非完全公平的调度策略。

应用场景分析

  1. 高并发且读多写少场景:在这种场景下,由于读操作不修改共享资源,可以允许多个 goroutine 同时进行读操作,而写操作需要独占锁。sync.Mutex 的性能优先策略可以很好地满足这种需求,因为读操作可以快速获取锁,提高系统的并发读性能。
  2. 对公平性要求较高场景:如果应用程序对公平性有较高的要求,例如某些任务需要按照顺序执行,或者需要避免特定 goroutine 长时间等待,可以考虑使用其他同步机制,如 sync.Cond 结合 sync.Mutex 来实现更公平的调度。
  3. 性能敏感且公平性要求适中场景sync.Mutex 的混合策略在大多数情况下能够满足这类场景的需求。它在保证一定公平性的同时,尽可能地提高了性能,适用于一般的并发编程场景。

优化建议

  1. 减少锁的粒度:将大的锁保护区域分解为多个小的锁保护区域,降低锁争用的概率,提高系统的并发性能。例如,在一个包含多个独立数据结构的结构体中,可以为每个数据结构单独使用一个锁。
  2. 合理安排锁的持有时间:尽量缩短锁的持有时间,将不需要锁保护的操作移到锁之外执行,减少其他 goroutine 的等待时间。
  3. 使用读写锁:对于读多写少的场景,可以使用 sync.RWMutex 读写锁,允许多个 goroutine 同时进行读操作,提高并发读性能。

总结

Go语言的 sync.Mutex 锁在公平性和性能之间进行了精心的权衡。通过采用性能优先的策略,并引入一定的公平性机制,它能够满足大多数并发编程场景的需求。在实际应用中,开发者需要根据具体的业务需求和性能要求,合理选择和使用同步机制,以达到最佳的并发性能和公平性。同时,通过优化锁的使用方式,如减少锁的粒度、合理安排锁的持有时间等,可以进一步提高系统的并发性能。