go快速上手:并发编程之锁机制

403 阅读6分钟

Go 语言并发编程中的锁机制:守护数据一致性的利剑

在 Go 语言的并发编程领域,锁(Locks)是保障数据一致性和防止竞态条件(Race Conditions)的重要工具。随着 goroutines 的轻量级与灵活性日益受到开发者们的青睐,如何在并发环境下安全地访问和修改共享数据成为了不可忽视的问题。本文将深入探讨 Go 语言中的锁机制,包括互斥锁(Mutex)、读写锁(RWMutex)以及它们的使用场景与最佳实践。

一、锁的基本概念与重要性

在并发编程中,多个 goroutines 可能会同时尝试访问或修改同一份数据。如果没有适当的同步机制,就可能导致数据不一致、丢失更新或脏读等问题。锁作为一种同步机制,通过限制同一时间内只有一个 goroutine 能访问共享资源,从而避免了这些问题。

二、Go 语言中的锁机制

2.1 互斥锁(Mutex)

互斥锁是 Go 语言中最基本也是最常用的锁类型。它通过sync包提供,确保同一时间只有一个 goroutine 能执行被锁保护的代码块(临界区)。

  • • 基本用法
    • • 使用sync.Mutex类型声明一个锁变量。
    • • 在访问共享资源前调用Lock()方法加锁。
    • • 访问完成后调用Unlock()方法解锁。
var mu sync.Mutex

func accessSharedResource() {
    mu.Lock()
    // 临界区:访问共享资源
    mu.Unlock()
}
  • • 注意事项
    • • 避免死锁:确保每个Lock()调用都有对应的Unlock()调用,且解锁操作在加锁的代码块内完成(通常使用defer语句)。
    • • 减少锁的范围:尽量缩小锁的保护范围,只将必要的操作放在锁的保护下。

2.2 读写锁(RWMutex)

读写锁是互斥锁的一种优化,它允许多个 goroutine 同时读取共享资源,但在写入时会阻塞其他读写操作。这对于读多写少的场景非常有用,因为它能显著提高程序的并发性能。

  • • 基本用法
    • • 使用sync.RWMutex类型声明一个读写锁变量。
    • • 读取数据时调用RLock()方法加读锁。
    • • 写入数据时调用Lock()方法加写锁(注意,写锁会阻塞读锁和写锁)。
    • • 完成后分别调用RUnlock()Unlock()方法解锁。
var rwmu sync.RWMutex

func readSharedResource() {
    rwmu.RLock()
    // 读取操作
    rwmu.RUnlock()
}

func writeSharedResource() {
    rwmu.Lock()
    // 写入操作
    rwmu.Unlock()
}
  • • 注意事项
    • • 读写锁虽然能提高读操作的并发性,但写操作仍然需要独占访问权,因此在写操作频繁的场景下,其性能可能并不优于互斥锁。
    • • 同样需要注意避免死锁,并确保每个加锁操作都有对应的解锁操作。

三、锁的使用场景与最佳实践

3.1 使用场景

  • • 互斥锁:适用于需要严格保证数据一致性的场景,如修改全局变量、更新数据库记录等。
  • • 读写锁:适用于读多写少的场景,如缓存数据的读取与更新。

3.2 例子

3.2.1 互斥锁

package main

import (
    "fmt"
    "sync"
)

func main() {
    var (
        count int
        mu    sync.Mutex
    )

    for i := 0; i < 100000; i++ {
        go func() {
            // 获取锁
            mu.Lock()
            // 临界区:访问共享资源
            count++
            // 释放锁
            mu.Unlock()

        }()
    }

    for {
        if count == 100000 {
            break
        }
    }

    fmt.Println("Final count:", count)
}

3.2.2 互斥锁易错点

  • • Lock/Unlock 不是成对出现
func foo() {
    var mu sync.Mutex
    defer mu.Unlock()
    // mu.Lock()  // 缺少lock
    fmt.Println("hello world!")
}
  • • Copy 已使用的 Mutex
type Counter struct {
    sync.Mutex
    Count int
}


func main() {
    var c Counter
    c.Lock()
    defer c.Unlock()
    c.Count++
    foo(c) // 复制锁
}

// 这里Counter的参数是通过复制的方式传入的
func foo(c Counter) {
    c.Lock()
    defer c.Unlock()
    fmt.Println("in foo")
}

这里锁被复制,并且传入函数foo中。 修正后的代码。将主函数main中的defer修改,并且传入锁指针。

package main

import (
    "fmt"
    "sync"
)

type Counter struct {
    sync.Mutex
    Count int
}

func main() {
    var c Counter
    c.Lock()
    c.Count++
    c.Unlock()
    foo(&c) // 复制锁
}

// 这里Counter的参数是通过复制的方式传入的
func foo(c *Counter) {
    c.Lock()
    defer c.Unlock()
    fmt.Println("in foo")
}
  • • 重入锁
func foo(l sync.Locker) {
    fmt.Println("in foo")
    l.Lock()
    bar(l)
    l.Unlock()
}


func bar(l sync.Locker) {
    l.Lock()
    fmt.Println("in bar")
    l.Unlock()
}


func main() {
    l := &sync.Mutex{}
    foo(l)
}

标准库 Mutex 不是可重入锁,这里会导致死锁。

  • • 实现可重入锁
  1. 1. 使用goroutineId
package main

import (
    "fmt"
    "runtime"
    "strconv"
    "strings"
    "sync"
    "sync/atomic"
)

func GoID() int {
    var buf [64]byte
    n := runtime.Stack(buf[:], false)
    // 得到id字符串
    idField := strings.Fields(strings.TrimPrefix(string(buf[:n]), "goroutine "))[0]
    id, err := strconv.Atoi(idField)
    if err != nil {
        panic(fmt.Sprintf("cannot get goroutine id: %v", err))
    }
    return id
}

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

func (m *RecursiveMutex) Lock() {
    gid := int64(GoID())
    // 如果当前持有锁的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 := int64(GoID())
    // 非持有锁的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()
}
  1. 1. 使用token
// Token方式的递归锁
type TokenRecursiveMutex struct {
    sync.Mutex
    token     int64
    recursion int32
}

// 请求锁,需要传入token
func (m *TokenRecursiveMutex) Lock(token int64) {
    if atomic.LoadInt64(&m.token) == token { //如果传入的token和持有锁的token一致,说明是递归调用
        m.recursion++
        return
    }
    m.Mutex.Lock() // 传入的token不一致,说明不是递归调用
    // 抢到锁之后记录这个token
    atomic.StoreInt64(&m.token, token)
    m.recursion = 1
}

// 释放锁
func (m *TokenRecursiveMutex) Unlock(token int64) {
    if atomic.LoadInt64(&m.token) != token { // 释放其它token持有的锁
        panic(fmt.Sprintf("wrong the owner(%d): %d!", m.token, token))
    }
    m.recursion-- // 当前持有这个锁的token释放锁
    if m.recursion != 0 { // 还没有回退到最初的递归调用
        return
    }
    atomic.StoreInt64(&m.token, 0// 没有递归调用了,释放锁
    m.Mutex.Unlock()
}
  • • 死锁
package main


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


func main() {
    // 派出所证明
    var psCertificate sync.Mutex
    // 物业证明
    var propertyCertificate sync.Mutex


    var wg sync.WaitGroup
    wg.Add(2// 需要派出所和物业都处理


    // 派出所处理goroutine
    go func() {
        defer wg.Done() // 派出所处理完成


        psCertificate.Lock()
        defer psCertificate.Unlock()


        // 检查材料
        time.Sleep(5 * time.Second)
        // 请求物业的证明
        propertyCertificate.Lock()
        propertyCertificate.Unlock()
    }()


    // 物业处理goroutine
    go func() {
        defer wg.Done() // 物业处理完成


        propertyCertificate.Lock()
        defer propertyCertificate.Unlock()


        // 检查材料
        time.Sleep(5 * time.Second)
        // 请求派出所的证明
        psCertificate.Lock()
        psCertificate.Unlock()
    }()


    wg.Wait()
    fmt.Println("成功完成")
}

这里两个go协程互相等待造成死锁。

3.2.3 共享锁

func main() {
    var counter Counter
    for i := 0i < 10i++ { // 10个reader
        go func() {
            for {
                counter.Count() // 计数器读操作
                time.Sleep(time.Millisecond)
            }
        }()
    }

    for { // 一个writer
        counter.Incr() // 计数器写操作
        time.Sleep(time.Second)
    }
}
// 一个线程安全的计数器
type Counter struct {
    mu    sync.RWMutex
    count uint64
}

// 使用写锁保护
func (c *Counter) Incr() {
    c.mu.Lock()
    c.count++
    c.mu.Unlock()
}

// 使用读锁保护
func (c *Counter) Count() uint64 {
    c.mu.RLock()
    defer c.mu.RUnlock()
    return c.count
}

3.2.4 共享锁误用点

  • • 不可复制 跟互斥锁一样,这里是不可复制的。
  • • 不可重入 跟互斥锁一样,这里是不可重入的。
  • • 隐蔽依赖导致的死锁 writer 依赖活跃的 reader -> 活跃的 reader 依赖新来的 reader -> 新来的 reader 依赖 writer。形成死锁
func main() {
    var mu sync.RWMutex

    // writer,稍微等待,然后制造一个调用Lock的场景
    go func() {
        time.Sleep(200 * time.Millisecond)
        mu.Lock()
        fmt.Println("Lock")
        time.Sleep(100 * time.Millisecond)
        mu.Unlock()
        fmt.Println("Unlock")
    }()

    go func() {
        factorial(&mu, 10// 计算10的阶乘, 10!
    }()
    
    select {}
}

// 递归调用计算阶乘
func factorial(m *sync.RWMutex, n int) int {
    if n < 1 { // 阶乘退出条件 
        return 0
    }
    fmt.Println("RLock")
    m.RLock()
    defer func() {
        fmt.Println("RUnlock")
        m.RUnlock()
    }()
    time.Sleep(100 * time.Millisecond)
    return factorial(m, n-1) * n // 递归调用
}

3.3 最佳实践

  • • 减少锁的范围:尽量缩小锁的保护范围,减少锁的争用。
  • • 避免嵌套锁:尽量避免在持有锁的情况下再次尝试获取锁,特别是嵌套获取不同类型的锁(如先获取读锁再尝试获取写锁),这可能导致死锁。
  • • 使用 defer 语句释放锁:将解锁操作放在defer语句中,确保即使在发生异常时也能正确释放锁。
  • • 考虑使用其他同步机制:在某些场景下,使用通道(Channels)或其他同步原语(如原子操作)可能更加高效或更适合。

四、总结

以上就是 go 中锁的基本使用。