Go之锁(一)

作者: adm 分类: go 发布时间: 2023-05-14

锁的几种描述
死锁

活锁

饥饿锁

锁的种类
互斥锁

读写互斥锁

读写锁

读锁

写锁

死锁
死锁的概念
两个或者两个以上的进程(或线程),因争夺资源而互相等待.

特点:

这些进程(或线程)都无法推进下去.

此时系统处于死锁的状态或者系统产生了死锁.

这些进程(或线程)称为死锁进程(或线程)

死锁发生的条件
互斥条件

线程对于资源的访问是排他的—>一个线程占用某个资源,那么其他线程就必须等待直到该资源被释放

请求和保持条件

线程A占用一个资源a同时提出占用资源b的请求.此时线程B已经占用了资源b,于是线程A等待占用资源b同时又不释放掉已占用的资源a

不剥夺条件

线程已获得的资源必须由自己释放,不能被其他线程剥夺

环路等待条件

线程A等到线程B占用的资源,线程B在等待线程A占用的资源,互相不释放自己当前占用的资源于是就有了互相等待

死锁的解决办法
约定各线程的顺序

使用事务—>注意,在同一事务中,尽可能做到一次锁定获取所需要地资源

针对容易产生死锁的业务场景,升级颗粒度,使用表级锁

采用分布式事务锁或者乐观锁

示例代码:

package main
​
import (
    "fmt"
    "runtime"
    "sync"
    "time"
)
​
/*
通过代码设计体验死锁的状态
 */
/* 声明一个结构体,里面包含锁和值 */
type value struct {
    // 声明锁
    memAccess sync.Mutex
    // 声明值
    value int
}
​
/*
main函数中:
1、声明两个变量,这两个变量直接是函数结果
2、开启两个协程
3、进行程序等待
 */
func main() {
    // 声明使用的CPU
    runtime.GOMAXPROCS(3)
    // 声明等待组
    var wg sync.WaitGroup
​
    // 声明一个求和变量--->形参是value结构体的指针
    sum := func(v1, v2 *value) {
        // 结束处理
        defer wg.Done()
        // v1上锁、休眠
        v1.memAccess.Lock()
        time.Sleep(2 * time.Second)
        // 对v2上锁
        v2.memAccess.Lock()
        // 打印结果
        fmt.Printf("求和的值为:%d\n", v1.value + v2.value)
        // 对资源解锁--->遵循先占有的后解锁
        v2.memAccess.Unlock()
        v1.memAccess.Unlock()
    }
​
    // 声明另一个变量,为了造成资源的互相等待所以一开始锁的是另一个资源
    product := func(v1, v2 *value) {
        // 结束处理
        defer wg.Done()
        // 上一个变量先锁定v1,所以该变量先锁定v2
        v2.memAccess.Lock()
        // 休眠2s
        time.Sleep(2 * time.Second)
        // 尝试锁定v1
        v1.memAccess.Lock()
        // 打印结果
        fmt.Printf("累乘的值为:%d\n", v1.value * v2.value)
        // 对资源进行解锁操作,先锁定的后解锁
        v1.memAccess.Unlock()
        v2.memAccess.Unlock()
    }
​
    // 声明变量,通过协程调用这两个函数
    var v1, v2 value
​
    // 结构体赋值
    v1.value = 1
    v2.value = 1
​
    // 添加等待组
    wg.Add(2)
​
    // 启动这两个函数的协程
    go sum(&v1, &v2)
    go product(&v1, &v2)
​
    // 等待组等待
    wg.Wait()
}

运行结果:

fatal error: all goroutines are asleep - deadlock!

代码调用解析图:

活锁
活锁的概念
两个或者两个以上的进程(或线程)争夺资源不会互相阻塞线程,但也不能够继续执行

特点:

线程将不断重复同样的操作,而且总会失败

活锁的场景举例:

处理事务消息:

如果不能成功处理某个消息,那么消息处理机制将回滚事务,并将它重新放到队列的开头。错误的事务被一直回滚重复执行。

活锁发生的条件
当多个相互协作的线程都对彼此进行相应而修改自己的状态,并使得任何一个线程都无法继续执行时,就导致了活锁。

活锁的解决办法
在重试机制中引入随机性

需求是什么?

设计一个向左向右走的场景,不通过锁定的方式协调这两个goroutine之间的步调

向左成功了就执行下一步,否则就返回上一步

示例代码:

package main
​
import (
    "bytes"
    "fmt"
    "runtime"
    "sync"
    "sync/atomic"
    "time"
)
​
func main() {
    // 设置启用的CPU
    runtime.GOMAXPROCS(3)
    // 声明一个Cond类型的变量
    /*
    可以这样声明的原因:
    1、NewCond的形参是一个Locker类型的变量,该类型是一个结构体.返回的是一个cond类型的引用
    2、Mutex是一个结构体,该结构体实现了Locker类型的接口.所以Mutex结构体是一个互斥锁
     */
    cv := sync.NewCond(&sync.Mutex{})
​
    // 不使用锁的形式而是使用协调的形式协调控制tick的步调
    go func() {
        for range time.Tick(1 * time.Second) {
            // 调用cond引用类型下的broadcast函数
            cv.Broadcast()
        }
    }()
​
    // 对资源进行上锁和解锁过程
    takeStep := func() {
        cv.L.Lock()
        cv.Wait()
        cv.L.Unlock()
    }
​
    /* 定义行走成功或者是行走失败的判断函数 */
    tryDir := func(dirName string, dir *int32, out *bytes.Buffer) bool {
        fmt.Fprintf(out, "%+v", dirName)
        // 调用atomic包下的添加函数对左右进行原子性添加(左右位移一格)
        atomic.AddInt32(dir, 1)
        // 资源锁定和释放
        takeStep()
        // 判单是否位移成功
        if atomic.LoadInt32(dir) == 1 {
            // 返回成功,返回真
            fmt.Fprint(out, ". 成功!")
            return true
        }
        // 没走成功则退回,一样要先锁定
        takeStep()
        atomic.AddInt32(dir, -1)
        return false
    }
​
    /* 声明向左向右的变量 */
    var left, right int32
    // 定义向左走
    tryLeft := func(out *bytes.Buffer) bool {
        // 返回向左走的结果
        return tryDir("向左走\t", &left, out)
    }
​
    // 定义向右走
    tryRight := func(out *bytes.Buffer) bool {
        // 返回向右走的结果
        return tryDir("向右走\t", &right, out)
    }
​
    // 行走变量
    walk := func(walking *sync.WaitGroup, name string) {
        // 声明out变量
        var out bytes.Buffer
        // 延后处理
        defer walking.Done()
        // 打印结果
        defer func() {
            fmt.Println(out.String())
        }()
        // 序列化打印
        fmt.Fprintf(&out, "%v正在行走的方向是:", name)
​
        // 循环执行五次向左或者向右操作
        for i := 0; i < 5; i++ {
            if tryLeft(&out) || tryRight(&out) {
                return
            }
        }
        // 下一个输出进行尝试
        fmt.Fprintf(&out, "\n%v是下一个要行动的对象!", name)
    }
​
    // 声明等待组
    var trail sync.WaitGroup
    // 两个协程以后开始一起操作
    trail.Add(2)
    // 执行行走的操作
    go walk(&trail, "Man")
    go walk(&trail, "Woman")
    // 等待
    trail.Wait()
}

死锁和活锁的区别
死锁:

表现为阻塞、等待

活锁:

实体在不断地改变状态

活锁可以自动解开,死锁不行.

饥饿锁
饥饿锁的概念
一个可运行的进程,能继续执行.但调度器无限期地忽视,而不能被调度执行

饥饿锁的特点:

高优先级线程执行完之后释放了资源饥饿锁优先级低的线程最终还是会执行

饥饿锁发生的条件
有一个或多个贪婪的并发进程,不公平地阻止一个或多个并发进程

结果:

尽可能有效地完成工作,或者阻止全部并发进程

示例代码:

一个贪婪的goroutine和一个平和的goroutine

package main
​
import (
    "fmt"
    "runtime"
    "sync"
    "time"
)
​
/*
手写一个饥饿锁的函数
1、包含一个贪婪的goroutine
2、包含一个平和的goroutine
 */
func main() {
    // 声明等待时长
    const waitTime = 1 * time.Nanosecond
    // 声明使用的cpu
    runtime.GOMAXPROCS(3)
    // 声明等待组
    var wg sync.WaitGroup
    // 声明等待常量
    const runtime = 1 * time.Second
    // 声明互斥锁变量
    var sharedLock sync.Mutex
​
    // 声明一个greed变量
    greed := func() {
        // defer处理
        defer wg.Done()
        var count int
        // 通过时间切片循环的加锁解锁对变量count进行操作
        /*
        <=这个是小于等于的意思
         */
        for begin := time.Now(); time.Since(begin) <= runtime; {
            // 加锁
            sharedLock.Lock()
            // 休眠
            time.Sleep(3 * waitTime)
            // 解锁
            sharedLock.Unlock()
            // 操作count变量
            count++
        }
​
        // 打印贪婪goroutine的内容
        fmt.Printf("贪婪线程能够执行 %v 工作数量\n", count)
    }
​
    // 声明一个平和的协程--->通过多次的等待以后才对变量进行处理
    polite := func() {
        // defer处理
        defer wg.Done()
        var count int
        // 循环开始
        for begin := time.Now(); time.Since(begin) <= runtime; {
            // 三次加锁等待
            sharedLock.Lock()
            time.Sleep(waitTime)
            sharedLock.Unlock()
​
            sharedLock.Lock()
            time.Sleep(waitTime)
            sharedLock.Unlock()
​
            sharedLock.Lock()
            time.Sleep(waitTime)
            sharedLock.Unlock()
​
            // 操作变量
            count++
        }
​
        // 打印结果
        fmt.Printf("平和线程能够执行 %v 工作数量\n", count)
    }
​
    // 等待组等待
    wg.Add(2)
    // 分别为贪婪和平和开启一个协程
    go greed()
    go polite()
​
    // 等待组等待
    wg.Wait()
}

分析:

贪婪的线程会贪婪地抢占共享锁,在相同地等待时间下贪婪协程地工作量是平和协程地三倍

不考虑两种协程算法地情况下可以认为:

贪婪的worker不必要地扩大其持有共享锁上的临界区,井阻止(通过饥饿)平和的worker的goroutine高效工作

总结
不适用锁肯定会出问题。如果用了,虽然解了前面的问题,但是又出现了更多的新问题。

只要有共享资源的访问,必定要使其逻辑上顺序化和原子化,确保访问一致

如果觉得我的文章对您有用,请随意赞赏。您的支持将鼓励我继续创作!