Golang--锁

锁基础

原子(atomic)操作

原子操作是一种硬件层面加锁的机制,其可以保证一个协程操作一个变量时,其他协程/线程不能访问,但也仅限于对简单变量的简单操作。

Go的atomic包来提供多种原子操作,达到并发安全。

sema锁

信号量锁

每一个uint32的sema数字,都对应了一个semaRoot结构体:

go 复制代码
type semaRoot struct {
	lock  mutex
	treap *sudog        // 一棵平衡二叉树的根节点
	nwait atomic.Uint32 // Number of waiters. Read w/o the lock.
}

type sudog struct {
	g *g

	next *sudog
	prev *sudog
	....
}

sema锁操作

  • 获取
go 复制代码
// 获取sema锁
func semacquire(addr *uint32) {
    semacquire1(addr, false, 0, 0, waitReasonSemacquire)
}

func semacquire1(addr *uint32, lifo bool, profile semaProfileFlags, skipframes int, reason waitReason) {
    ...
    if cansemacquire(addr) {
        return
    }
    ...
}

func cansemacquire(addr *uint32) bool {
	for {
		v := atomic.Load(addr) //原子地加载addr
		if v == 0 {
			return false
		}
		if atomic.Cas(addr, v, v-1) { //若v>0,原子地减一
			return true
		}
	}
}

sema数字>0时,获取sema锁本质cansemacquire中对sema减一

sema==0时,获取锁时cansemacquire返回false,semacquire1继续往后执行:

go 复制代码
func semacquire1(addr *uint32, lifo bool, profile semaProfileFlags, skipframes int, reason waitReason) {
    ...
    if cansemacquire(addr) {
        return
    }
    
    s := acquireSudog()
    root := semtable.rootFor(addr)//拿到平衡二叉树根节点
    ...
    for {
		...
		root.queue(addr, s, lifo)  //进入树中排队
		goparkunlock(&root.lock, reason, traceEvGoBlockSync, 4+skipframes)//休眠协程
		if s.ticket != 0 || cansemacquire(addr) {
			break
		}
	}
}
  • 释放

释放sema锁本质就是sema值+1,判断树中是否有协程在等待,有则取出协程并唤醒

go 复制代码
func semrelease(addr *uint32) {
    semrelease1(addr, false, 0)
}

func semrelease1(addr *uint32, handoff bool, skipframes int) {
    ...
    atomic.Xadd(addr, 1) //原子地+1

    if root.nwait.Load() == 0 {
		return
	}
    ...

    s, t0 := root.dequeue(addr)//取出协程
    ...
}

故,sema uint32这个值本质上表示的是可以并发获取该锁的协程的数量,这个sema锁可以不止一个协程获得。

sema置为0时,则可以用作一个休眠队列。

Mutex

Mutex结构

**src/sync/mutex.go**中定义了互斥锁的数据结构:

go 复制代码
type Mutex struct{
    state int32 //互斥锁的状态
    sema uint32 //信号量,解锁的协程通过释放信号量来唤醒等待信号量的协程
}

32位的state:

  • **Waiter**:阻塞等待该锁的协程数
  • **Starving**:表示是否有等待该Mutex的协程处于饥饿状态
  • **Woken**:表示是否有协程在CPU上运行申请加锁,而非阻塞状态
  • **Locked**:表示该Mutex是否被上锁

简单的上锁解锁过程:

自旋

自旋对应于CPU的 **PAUSE**指令,CPU对于该指令什么都不做,相当于CPU空转,不同于sleep,不需要将协程转为睡眠状态。

加锁时,如果Locked为1,尝试加锁的协程不会马上转入P(sema)阻塞,而是会自旋持续探测Locked是否变为0。自旋时间很短,如果自旋过程中锁被释放,那么这个自旋的协程可以立马获得锁,即使此时有阻塞的协程被唤醒也无法获得锁,只能继续阻塞。

条件

加锁时程序会判断是否可以自旋,无限制的自旋会给CPU过大压力,自旋必须满足以下所有条件:

  1. 自旋次数要小,通常为4。
  2. cpu核数要大于1,不然自旋没有意义,因为此时不可能有其他协程释放锁。
  3. **协程调度机制中的Process数量要大于1,比如使用GOMAXPROCS()将处理器设置为1就不能启用自旋 **
  4. **协程调度机制中的可运行队列必须为空,否则会延迟协程调度 **

总而言之就是不忙的时候才会启用自旋。

好处

自旋的好处在于可以充分利用CPU,尽量避免协程切换。

当前申请加锁的协程是拥有CPU的,如果在短时间的自旋可以立马获得锁,当前协程就可以继续运行,而不用阻塞切换。

劣势

自旋过程中获得锁,会导致之前被阻塞的协程无法获得锁,如果来申请加锁的协程特别多,每次都通过自旋获得了锁,就会导致之前阻塞的协程很难获得锁,从而进入饥饿状态。

为了避免协程长时间无法获得锁的饥饿状态,1.8版本之后,Mutex添加了Starving状态。

Mutex的模式

正常模式

Mutex的默认模式,该模式下,协程申请加锁不成功不会立即转入阻塞排队,而是判断是否满足自旋的条件,如果满足则会启动自旋过程,尝试抢锁。

饥饿模式

释放锁时,如果发现有阻塞的协程,还会释放一个信号量来唤醒一个等待协程,被唤醒的协程申请锁时,发现锁已经被自旋的协程抢占了,只好再次阻塞。但是阻塞前会判断自上次阻塞到本次阻塞经过了多少时间,如果超过1ms就将Mutex的Starving置为1 ,即将Mutex标记为饥饿模式,然后再阻塞。

处于饥饿模式的Mutex,不会启动自旋过程,一旦有协程释放了锁,就一定会唤醒阻塞的协程,被唤醒的协程将成功获取锁,同时Waiter-1。

Woken

用于加锁和解锁过程的通信。如,一个在自旋过程的协程,此时Woken为1,同时另一协程解锁时判断Woken为1则不会释放信号量。

为何重复解锁要panic

由于Unlock时会可能每次都释放一个信号量,如果重复解锁可能唤醒多个协程。多个协程被唤醒后又会继续在Lock中抢锁,势必会增加Lock实现的复杂度,也会引起不必要的协程切换。

RWMutex

数据结构

**src/sync/rwmutex.go**中定义了读写锁的数据结构:

go 复制代码
type RWMutex struct{
    w            Mutex  //控制多个写锁,获得写锁要先获得这把锁,
                       //如果有一个写锁在进行,到来的写锁会阻塞在这
    writerSem    uint32 //写阻塞等待的信号量,最后一个读者释放锁会释放该信号量
    readerSem 	 uint32 //读阻塞等待的信号量,写者释放锁后会释放该信号量
    readerCount  int32  //记录读者个数
    readerWait   int32  //记录目前写阻塞时的读者个数,防止写锁饥饿
}

接口实现

Lock():写锁定

go 复制代码
func (rw *RWMutex) Lock() {
	if race.Enabled {
		_ = rw.w.state
		race.Disable()
	}
	// First, resolve competition with other writers.
	rw.w.Lock() //想要获取写锁,首先要获取互斥锁,下面再等待所有读者释放锁
	// Announce to readers there is a pending writer.
	r := atomic.AddInt32(&rw.readerCount, -rwmutexMaxReaders) + rwmutexMaxReaders //使用原子操作减去rwmutexMaxReaders将readerCount置为负值,目的是阻止读锁。再加上rwmutexMaxReaders又可以获取原来的读者数。非常精妙
	// Wait for active readers.
	if r != 0 && atomic.AddInt32(&rw.readerWait, r) != 0 { //如果读者数是0,那么直接获取写锁,不需要等待信号量。 因为写锁获取成功,所以此处简单的加上读者数量即可。(加上读者数量应该不会出现0的情况)
		runtime_SemacquireMutex(&rw.writerSem, false) // 续:此处将读者数写入readerWait实际上是用于排队,即当前为止的读者释放后轮到写操作,避免写锁被饿死
	}
	if race.Enabled {
		race.Enable()
		race.Acquire(unsafe.Pointer(&rw.readerSem))
		race.Acquire(unsafe.Pointer(&rw.writerSem))
	}
}

RUnLock():释放读锁

go 复制代码
func (rw *RWMutex) RUnlock() {
	if race.Enabled {
		_ = rw.w.state
		race.ReleaseMerge(unsafe.Pointer(&rw.writerSem))
		race.Disable()
	}
	if r := atomic.AddInt32(&rw.readerCount, -1); r < 0 { //每个读者解锁时,首先将readerCount -1,如果readerCount为负值,说明有协程在等待写锁
		if r+1 == 0 || r+1 == -rwmutexMaxReaders {
			race.Enable()
			throw("sync: RUnlock of unlocked RWMutex")
		}
		// A writer is pending.
		if atomic.AddInt32(&rw.readerWait, -1) == 0 { //将readerWait -1, 并且最后一个读者负责释放一个信号量,来唤醒等待写锁的协程
			// The last reader unblocks the writer.
			runtime_Semrelease(&rw.writerSem, false)
		}
	}
	if race.Enabled {
		race.Enable()
	}
}

RLock():读锁定

go 复制代码
func (rw *RWMutex) RLock() {
	if race.Enabled {
		_ = rw.w.state
		race.Disable()
	}
	if atomic.AddInt32(&rw.readerCount, 1) < 0 { //读者数量简单+1,如果readerCount为负值,说明有协程持有了写锁,需要等待协程解除写锁后释放信号量解锁
		// A writer is pending, wait for it.
		runtime_SemacquireMutex(&rw.readerSem, false)
	}
	if race.Enabled {
		race.Enable()
		race.Acquire(unsafe.Pointer(&rw.readerSem))
	}
}

Unlock():释放写锁

go 复制代码
func (rw *RWMutex) Unlock() {
	if race.Enabled {
		_ = rw.w.state
		race.Release(unsafe.Pointer(&rw.readerSem))
		race.Disable()
	}

	// Announce to readers there is no active writer.
	r := atomic.AddInt32(&rw.readerCount, rwmutexMaxReaders) //因为持有写锁期间,读者数量有可能增加,此处将读者数量加上rwmutexMaxReaders,将读者数量转为正值。
	if r >= rwmutexMaxReaders {
		race.Enable()
		throw("sync: Unlock of unlocked RWMutex")
	}
	// Unblock blocked readers, if any.
	for i := 0; i < int(r); i++ { //持有锁期间,读者可能继续到来并阻塞起来,所以这里有多少个读者,释放多少个信号量
		runtime_Semrelease(&rw.readerSem, false)
	}
	// Allow other writers to proceed.
	rw.w.Unlock()
	if race.Enabled {
		race.Enable()
	}
}

解析

写操作如何阻止写操作

写操作必须先申请互斥锁w,写者A申请到了互斥锁,则B就只能阻塞等待互斥锁。

写操作如何阻止读操作

写操作申请到互斥锁之后会 **readerCount-=2^30**,直到写解锁才 **readerCount+=2^30**;读操作来时 **readerCount++**后会判断 **readerCount**是否为正,若为负则知道此时有写操作就只能阻塞等待。

**写操作将readerCount变成负值来阻止读操作的。 **

读操作如何阻止写操作

读操作会readerCount++,写操作发现读者不为0,则阻塞等待。

写操作为什么不会被饿死

写操作阻塞时,一方面会将 **readerCount**变为负数,之后到来的读者会阻塞,二方面会用 **readerWait**记录目前的正在读的读者数量,这一批读者结束就会释放信号量,来轮到该写者操作。而写阻塞之后到来的读者只能等到该写操作结束释放信号量来唤醒。

相关推荐
程序员一点2 小时前
第23章:备份与灾难恢复策略
linux·运维·网络·数据库·openeuler
x_xbx2 小时前
LeetCode:88. 合并两个有序数组
算法·leetcode·职场和发展
白杆杆红伞伞2 小时前
Qt进程间通信
开发语言·qt
ฅ^•ﻌ•^ฅ12 小时前
LeetCode hot 100(复习c++) 1-15
c++·算法·leetcode
艾莉丝努力练剑2 小时前
确保多进程命名管道权限一致的方法
java·linux·运维·服务器·开发语言·网络·c++
alphaTao2 小时前
LeetCode 每日一题 2026/3/9-2026/3/15
算法·leetcode·职场和发展
Kiyra2 小时前
[特殊字符] LeetCode 做题笔记(二):678. 有效的括号字符串
笔记·算法·leetcode
神奇小汤圆2 小时前
Java面试被问:跟我讲下JVM和JMM?
后端
Fcy6482 小时前
与队列有关练习题
算法