Go并发编程 - 互斥锁

互斥锁是并发控制的一个基本手段,是为了避免竞争而建立的一种并发控制机制

waitGroup是做并发控制,可以让主进程等待goroutine执行一些耗时的操作

sync.Mutex

互斥锁Mutex就提供两个方法Lock和Unlock; 进入临界区之前调用Lock方法,退出临界区的时候调用Unlock方法

当一个goroutine通过调用Lock方法获得了这个锁的拥有权后,其它请求锁的goroutine就会阻塞在Lock方法的调用上,直到锁被释放并且获取到这个锁的拥有权

go 复制代码
package main

import (
	"sync"
	"fmt"
)

func main() {
	var wg sync.WaitGroup
	var count = 0
	wg.Add(10)
	for i := 0; i < 10; i++ {
		go func() {
			defer wg.Done()
			for j := 0; j < 100000; j++ {
				count++
			}
		}()
	}
	wg.Wait()
	fmt.Println(count)
}

在上面的程序当中,会发现每次执行的结果都不同,这是由于count++不是一个原子操作,因此可能会存在并发问题

针对这个问题,Go提供了一个检测并发访问共享资源是否有问题的工具:race detector, 它可以帮助我们自动发现程序有没有data race的问题, 例如执行 go run -race main.go

在上面的程序中,临界区就是count++,我们只需要在count++的前面加上Lock, 在其后面加上Unlock即可达到我们想要的效果输出1000000, 代码如下

go 复制代码
package main

import (
	"sync"
	"fmt"
)

func main() {
	var mu sync.Mutex
	var count = 0
	var wg sync.WaitGroup
	wg.Add(10)
	for i := 0; i < 10; i++ {
		go func() {
			defer wg.Done()
			for j := 0; j < 100000; j++ {
				mu.Lock()
				count++
				mu.Unlock()
			}
		}()
	}
	wg.Wait()
	fmt.Println(count)
}

在很多情况下, Mutex会嵌入到其它struct中使用,比如下面的方式

go 复制代码
package main

import (
	"sync"
	"fmt"
)

type Counter struct {
	mu sync.Mutex
	Count uint64
}

func main() {
	var wg sync.WaitGroup
	var counter Counter
	wg.Add(10)
	for i := 0; i < 10; i++ {
		go func() {
			defer wg.Done()
			for j := 0; j < 100000; j++ {
				counter.mu.Lock()
				counter.Count++
				counter.mu.Unlock()
			}
		}()
	}
	wg.Wait()
	fmt.Println(counter.Count)
}

sync.Mutex使用错误的常见4种场景

使用Mutex常见的错误场景有4类,分别是Lock/Unlock不是成对出现、Copy已经使用的锁、重入和死锁

1. Lock/Unlock没有成对出现

Lock/Unlock没有成对出现,就意味着会出现死锁的情况,或者是因为Unlock一个未加锁的Mutex而导致panic

缺少Unlock的场景,常见的有三种情况:

  • 代码中有太多if-else分支,可能在某个分支中漏写了Unlock
  • 在重构的时候把Unlock给删除了
  • Unlock误写成Lock

在上面的这种情况,锁被获取之后,就不会被释放了,这也意味着其它的goroutine永远都没机会获取到锁。

缺少Lock的场景一般就是误删了Lock

2. Copy已使用的Mutex

sync包在使用之后是不能被复制的,Mutex也是不能复制的。

原因在于, Mutex是一个有状态的对象,它的state字段记录这个锁的状态。如果要复制一个已经加锁的Mutex给一个新的变量,那么新的刚初始化的变量就已经被枷锁了,这显然不是我们想要的,因为我们想要的是一个零值的Mutex

go 复制代码
package main

import (
	"sync"
	"fmt"
)

type Counter struct {
	mu sync.Mutex
	Count uint64
}

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

func foo(c Counter) {
	c.mu.Lock()
	defer c.mu.Unlock()
	fmt.Println("in foo")
}

上面的程序会导致panic, 原因就是使用一个加锁的锁进行初始化

3. 重入锁

重入锁: 当一个线程获取锁时,如果没有其它线程拥有这个锁,那么,这个线程就能成功获取到这个锁。之后,如果其它线程再请求这把锁的话,就会出现阻塞等待的状态。当时,如果拥有这把锁的线程再请求这把锁的话,不会阻塞,而是成功返回,所以叫可重入锁。

需要注意的是: Mutex是不可重入锁, 例如

go 复制代码
package main

import (
	"sync"
	"fmt"
)

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

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()
}

上面的程序会报错: fatal error: all goroutines are asleep - deadlock

如何实现一个可重入锁,有两种方案:

  1. 通过hacker的方式获取到goroutine id, 记录下获取锁的goroutine id, 它可实现Locker接口
  2. 调用Lock/Unlock方法时,由goroutine提供一个token,用来标识它自己,而不是我们通过hacker的方式获取到goroutine id。但是,这样一来,就不满足Locker接口了

可重入锁(递归锁)解决了代码重入或者递归调用带来的死锁问题

goroutine id

这个方案的关键一步是获取goroutine id, 方式有两种,分别是简单方式和hacker方式

简单方式:获取goroutine的方式可以通过runtime.Stack方法获取,例如

go 复制代码
func GoID() int {
	var buf [64]byte
	n := runtime.Stack(buf[:], false)

	idField := strings.Fields(strings.TrimPrefix(string(buf[:n]), "goroutine"))
	id, err := strconv.Atoi(idField[0])
	if err != nil {
		panic(fmt.Sprintf("cannot get goroutine id: %v", err))
	}
	return id

}

通过hacker的方式获取:

获取运行时的g指针, 反解出对应的g结构。每个运行的goroutine结构的g指针保存在当前的goroutine的一个叫TLS对象中。

第一步: 获取到TLS对象;

第二步:再从TLS中获取goroutine结构的g指针 第三步:再从g指针取出goroutine id

可以使用pertermattis/goid第三方库获取goroutine id, 下面实现一个可重入锁

go 复制代码
package main

import (
	"fmt"
	"github.com/petermattis/goid"
	"sync"
	"sync/atomic"
)

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, 调用次数加一
	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))
	}
	m.recursion--
  // 如果这个goroutine还没有完全释放,则直接返回
	if m.recursion != 0 {
		return
	}
  // 此goroutine最后一次调用,需要释放锁
	atomic.StoreInt64(&m.owner, -1)
	m.Mutex.Unlock()
}

func main() {
	l := &RecursiveMutex{}
	foo(l)
}

func foo(l *RecursiveMutex) {
	fmt.Println("in foo")
	l.Lock()
	bar(l)
	l.Unlock()
}

func bar(l *RecursiveMutex) {
	l.Lock()
	fmt.Println("in bar")
	l.Unlock()
}

token

调用者自己提供一个token, 获取锁的时候把这个token传入, 释放锁的时候也需要把这个token传入。通过用户传入的token替换方案一中的goroutine

go 复制代码
type TokenRecursiveMutex struct {
	sync.Mutex
	token     int64
	recursion int32
}

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

func (m *TokenRecursiveMutex) Unlock(token int64) {
	if atomic.LoadInt64(&m.token) != token {
		panic(fmt.Sprintf("wrong the owner(%d): %d!", m.token, token))
	}
	m.recursion--
	if m.recursion != 0 {
		return
	}
	// 没有递归调用,释放锁
	atomic.StoreInt64(&m.token, 0)
	m.Mutex.Unlock()
}

4. 死锁

两个或两个以上的进程(或线程,goroutine)在执行的过程中,因争夺共享资源而处于一种相互等待的状态,如果没有外部干涉,它们都将无法推进下去,此时,我们称系统处于死锁状态或系统产生了死锁。死锁产生的条件有:

  • 互斥:至少有一个资源是被排他性独享的,其它线程必须处于等待状态,直到资源被释放
  • 持有和等待: goroutine持有一个资源,并且还在请求其它goroutine持有的资源
  • 不可剥夺: 资源只能由持有它的goroutine来释放
  • 环路等待:一般来说,存在一组等待进程,P={P1,P2,P3...},P1等待P2持有的资源,P2等待P3持有的资源,依此类推,最后PN等待P1持有的资源,这就形成了一个环路等待的死结
go 复制代码
package main

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

func main() {
	var mu1 sync.Mutex
	var mu2 sync.Mutex

	var wg sync.WaitGroup
	wg.Add(2)

	go func() {
		defer wg.Done()

		mu1.Lock()
		defer mu1.Unlock()

		time.Sleep(5 * time.Second)

		mu2.Lock()
		mu2.Unlock()
	}()

	go func() {
		defer wg.Done()

		mu2.Lock()
		defer mu2.Unlock()

		time.Sleep(5 * time.Second)
		mu1.Lock()
		mu1.Unlock()
	}()

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

上面的程序由于mu1和mu2发生环路等待,因此导致死锁,所以该程序无法运行成功

Mutex进阶

锁是性能下降的"罪魁祸首"之一,所以有效地降低所得竞争,就能够很好地提高性能。因此,监控关键互斥锁上等待的goroutine的数量,是分析锁竞争的激烈程度的一个指标

TryLock

当一个goroutine调用这个TryLock方法请求锁的时候,如果这把锁没有被其它goroutine所持有,那么这个goroutine就持有了这把锁,并返回true; 如果这把锁已经被其它goroutine所持有,或者是正在准备交给某个被唤醒的goroutine,那么,这个请求锁的goroutine就直接返回false, 不会阻塞在方法调用上。

go 复制代码
func main() {
	var mu Mutex
	go func() {
		mu.Lock()
		time.Sleep(time.Duration(rand.Intn(2)) * time.Second)
		mu.Unlock()
	}()
	
	time.Sleep(time.Second)

	ok := mu.TryLock()

	if ok {
		fmt.Println("got the lock")
		mu.Unlock()
		return
	}

	fmt.Println("can not get the lock")
}

const (
	mutexLocked      = 1 << iota // 加锁标识位置1 =》2的0次方
	mutexWoken                   // 唤醒标识位置2 =》2的1次方
	mutexStarving                // 锁饥饿标识位置4 =》2的2次方
	mutexWaiterShift = iota      // 标识waiter的起始bit位置3
)

type Mutex struct {
	sync.Mutex
}

func (m *Mutex) TryLock() bool {
	if atomic.CompareAndSwapInt32((*int32)(unsafe.Pointer(&m.Mutex)), 0, mutexWaiterShift) {
		return true
	}
	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)
}

使用Mutex实现一个线程安全的队列

go 复制代码
package main

import "sync"

type SliceQueue struct {
	data []interface{}
	mu sync.Mutex
}

func NewSliceQueue(n int) (q *SliceQueue) {
	return &SliceQueue{data: make([]interface{}, 0, n)}
}

func (q *SliceQueue) Enqueue(v interface{}) {
	q.mu.Lock()
	q.data = append(q.data, v)
	q.mu.Unlock()
}

func (q *SliceQueue) Dequeue() interface{} {
	q.mu.Lock()
	if len(q.data) == 0 {
		q.mu.Unlock()
		return nil
	}
	v := q.data[0]
	q.data = q.data[1:]
	q.mu.Unlock()
	return v
}
相关推荐
kirito学长-Java31 分钟前
springboot/ssm网上宠物店系统Java代码编写web宠物用品商城项目
java·spring boot·后端
海绵波波10738 分钟前
flask后端开发(9):ORM模型外键+迁移ORM模型
后端·python·flask
余生H42 分钟前
前端Python应用指南(二)深入Flask:理解Flask的应用结构与模块化设计
前端·后端·python·flask·全栈
AI人H哥会Java1 小时前
【Spring】基于XML的Spring容器配置——<bean>标签与属性解析
java·开发语言·spring boot·后端·架构
计算机学长felix2 小时前
基于SpringBoot的“大学生社团活动平台”的设计与实现(源码+数据库+文档+PPT)
数据库·spring boot·后端
sin22012 小时前
springboot数据校验报错
spring boot·后端·python
去哪儿技术沙龙2 小时前
去哪儿机票智能预警系统-雷达系统落地实践
后端
程序员大阳2 小时前
闲谭Scala(1)--简介
开发语言·后端·scala·特点·简介
直裾2 小时前
scala图书借阅系统完整代码
开发语言·后端·scala