Mutex解析

锁的实现一般会依赖于原子操作、信号量,通过atomic包中的一些原子操作来实现锁的锁定,通过信号量来实现线程的阻塞与唤醒。
锁的实现参考文档
Mutex底层结构:

type Mutex struct {  
     state int32  
     sema  uint32
 }

加锁:
通过原子操作cas加锁,如果加锁不成功,根据不同的场景选择自旋重试加锁或者阻塞等待被唤醒后加锁
请添加图片描述

func (m *Mutex) Lock() {
	// Fast path: grab unlocked mutex.
	if atomic.CompareAndSwapInt32(&m.state, 0, mutexLocked) {
		if race.Enabled {
			race.Acquire(unsafe.Pointer(m))
		}
		return
	}
	// Slow path (outlined so that the fast path can be inlined)
	m.lockSlow()
}

解锁:
通过原子操作add解锁,如果仍有goroutine在等待,唤醒等待的goroutine

func (m *Mutex) Unlock() {
	if race.Enabled {
		_ = m.state
		race.Release(unsafe.Pointer(m))
	}

	// Fast path: drop lock bit.
	new := atomic.AddInt32(&m.state, -mutexLocked)
	if new != 0 {
		// Outlined slow path to allow inlining the fast path.
		// To hide unlockSlow during tracing we skip one extra frame when tracing GoUnblock.
		m.unlockSlow(new)
	}
}
  1. 在Lock()之前使用Unlock()会导致panic异常
  2. 使用Lock()加锁后,再次 Lock() 会导致死锁(不支持重入),需Unlock()解锁后才能再加锁
  3. 锁定状态与 goroutine 没有关联,一个 goroutine 可以 Lock,另一个 goroutine 可以 Unlock

自旋:cpu率高,但不用进行上下文切换,适合短时间内的等待。
条件:cpu核数大于1。有空闲的P。规定最大自旋次数4。锁不处于饥饿模式。本地运行队列为空。

信号量:
实现休眠和唤醒协程

信号量有两个操作P和V
P(S):分配一个资源
1. 资源数减1:S=S-1
2. 进行以下判断
    如果S<0,进入阻塞队列等待被释放
    如果S>=0,直接返回
 
V(S):释放一个资源
1. 资源数加1:S=S+1
2. 进行如下判断
    如果S>0,直接返回
    如果S<=0,表示还有进程在请求资源,释放阻塞队列中的第一个等待进程
    
golang中信号量操作:runtime/sema.go
P操作:runtime_Semacquire
V操作:runtime_Semrelease

底层实现

底层基于运行时Semaphore机制实现。
数据结构体:
sema.go中定义了一个全局变量,semtable数组。大小为251,元素为一个匿名结构体。这里为了避免伪共享问题(参数在同一缓存行被同时读取到不同CPU上)做了一下内存填充。

// Prime to not correlate with any user patterns.
素数不与任何用户模式相关联。该值大小是一个简单的哈希表。使元素的数量成为质数允许使用简单的散列函数,具有可接受的意外冲突概率。 
const semTabSize = 251

type semTable [semTabSize]struct {
	root semaRoot
	pad  [cpu.CacheLinePadSize - unsafe.Sizeof(semaRoot{})]byte
}

每个元素持有的semaRoot为这个数据结构的核心。

// semaRoot持有一个具有不同地址(sudog.elem)的sudog平衡树,
// 每个sudog都可以通过s.waitlink依次指向一个相同地址等待的sudog列表, 
// 在具有相同等待地址的sudog内部列表上的操作时间复杂度都是O(1)。
// 顶层semaRoot列表的扫描为O(logn),其中n是阻止goroutines的不同信号量地址的数量。
// 为sync.Mutex准备的异步信号量
type semaRoot struct {
	lock  mutex
	treap *sudog        // 平衡树的根节点
	nwait atomic.Uint32 // Number of waiters. Read w/o the lock.
}

sudog结构体: 源码定义在runtime/runtime2.go里

type sudog struct {
 g *g
 next *sudog
 prev *sudog
 elem unsafe.Pointer //数据元素 (可能指向栈)
 // 下面的字段不会并发访问
 // 对于channels, waitlink 只被g访问
  // 对于semaphores, 所有自动(包括上面的)只有获取semaRoot的锁才能被访问
 acquiretime int64
 releasetime int64
 ticket      uint32
  //isSelect表示g正在参与一个select,因此必须对g.selectDone进行CAS才能赢得唤醒竞争
 isSelect bool
  //success表示channel c上的通信是否成功。如果goroutine因为在通道c上传递了一个值而被唤醒,则为true;
  //如果因为channel c关闭而被唤醒,则为false
 success bool
  
 parent   *sudog // semaRoot binary tree
 waitlink *sudog // g.waiting list or semaRoot
 waittail *sudog // semaRoot
 c        *hchan // channel
}

其中的next、prev、parent字段构成了平衡树,waitlink和waittail构成了相同信号量地址的链表结构。
主要依赖函数:

//go:linkname sync_runtime_Semacquire sync.runtime_Semacquire
func sync_runtime_Semacquire(addr *uint32) {
	semacquire1(addr, false, semaBlockProfile, 0, waitReasonSemacquire)
}

//go:linkname sync_runtime_Semrelease sync.runtime_Semrelease
func sync_runtime_Semrelease(addr *uint32, handoff bool, skipframes int) {
	semrelease1(addr, handoff, skipframes)
}

//go:linkname sync_runtime_SemacquireMutex sync.runtime_SemacquireMutex
func sync_runtime_SemacquireMutex(addr *uint32, lifo bool, skipframes int) {
	semacquire1(addr, lifo, semaBlockProfile|semaMutexProfile, skipframes, waitReasonSyncMutexLock)
}

semacquire1实现:
获取当前的g并判断是否跟m上实际运行的g是否一致
循环判断信号量的值,若等于0,则直接返回false进入harder case;否则原子操作*addr -= 1成功则相当于拿到信号量直接返回

func semacquire1(addr *uint32, lifo bool, profile semaProfileFlags, skipframes int, reason waitReason) {
	gp := getg()
	if gp != gp.m.curg {
		throw("semacquire not on the G stack")
	}

	// Easy case. 检查信号量大于0且CAS成功则直接返回
	if cansemacquire(addr) {
		return
	}

	// Harder case:
	//	increment waiter count
	//	try cansemacquire one more time, return if succeeded
	//	enqueue itself as a waiter
	//	sleep
	//	(waiter descriptor is dequeued by signaler)
	s := acquireSudog()    //获取一个sudog对象
	root := semtable.rootFor(addr)    //根据信号量地址hash到semtable中
	t0 := int64(0)
	s.releasetime = 0
	s.acquiretime = 0
	s.ticket = 0
	if profile&semaBlockProfile != 0 && blockprofilerate > 0 {
		t0 = cputicks()
		s.releasetime = -1
	}
	if profile&semaMutexProfile != 0 && mutexprofilerate > 0 {
		if t0 == 0 {
			t0 = cputicks()
		}
		s.acquiretime = t0
	}
	for {
		lockWithRank(&root.lock, lockRankRoot)
		// Add ourselves to nwait to disable "easy case" in semrelease.
		root.nwait.Add(1)
		// Check cansemacquire to avoid missed wakeup.
		if cansemacquire(addr) {
			root.nwait.Add(-1)
			unlock(&root.lock)
			break
		}
		// Any semrelease after the cansemacquire knows we're waiting
		// (we set nwait above), so go to sleep.
		root.queue(addr, s, lifo)
		goparkunlock(&root.lock, reason, traceEvGoBlockSync, 4+skipframes)
		if s.ticket != 0 || cansemacquire(addr) {
			break
		}
	}
	if s.releasetime > 0 {
		blockevent(s.releasetime-t0, 3+skipframes)
	}
	releaseSudog(s)
}

acquireSudog源码位置runtime/proc.go

//go:nosplit
func acquireSudog() *sudog {
      // 设置禁止抢占
	mp := acquirem()
	pp := mp.p.ptr()
//当前本地sudog缓存没有了,则去全局缓存中拉取一批
	if len(pp.sudogcache) == 0 {
		lock(&sched.sudoglock)
		// First, try to grab a batch from central cache.
// 首先尝试从全局缓存中获取sudog,直到本地容量达到50%
		for len(pp.sudogcache) < cap(pp.sudogcache)/2 && sched.sudogcache != nil {
			s := sched.sudogcache
			sched.sudogcache = s.next
			s.next = nil
			pp.sudogcache = append(pp.sudogcache, s)
		}
		unlock(&sched.sudoglock)
		// If the central cache is empty, allocate a new one.
		if len(pp.sudogcache) == 0 {
			pp.sudogcache = append(pp.sudogcache, new(sudog))
		}
	}
	n := len(pp.sudogcache)
	s := pp.sudogcache[n-1]
	pp.sudogcache[n-1] = nil
	pp.sudogcache = pp.sudogcache[:n-1]
	if s.elem != nil {
		throw("acquireSudog: found s.elem != nil in cache")
	}

  //解除抢占限制
	releasem(mp)
	return s
}

这里sudog获取使用了二级缓存,即P本地sudog缓存和全局的sched全局的sudog缓存。当本地的sudog缓存不足,则从全局缓存中获取;如果全局缓存也没有,则重新分配一个新的sudog。

递增nwait进而避免semrelease中的快速路径

再次检查cansemacquire避免错过wakeup,如果成功则nwait-1并返回

将当前g封装进sudog并放入等待队列

// queue adds s to the blocked goroutines in semaRoot.
func (root *semaRoot) queue(addr *uint32, s *sudog, lifo bool) {
 s.g = getg()
 s.elem = unsafe.Pointer(addr)
 s.next = nil
 s.prev = nil
 
 var last *sudog
 pt := &root.treap
 for t := *pt; t != nil; t = *pt {
    //说明存在相同地址的节点
  if t.elem == unsafe.Pointer(addr) {
   // Already have addr in list.
   if lifo {//先进先出的话 将新节点放到链表的第一位
    // 用s将t替换掉 
    *pt = s
    s.ticket = t.ticket
    s.acquiretime = t.acquiretime
    s.parent = t.parent
    s.prev = t.prev
    s.next = t.next
    if s.prev != nil {
     s.prev.parent = s
    }
    if s.next != nil {
     s.next.parent = s
    }
    // 将t放入到s的等待链表的第一位
    s.waitlink = t
    s.waittail = t.waittail
    if s.waittail == nil {
     s.waittail = t
    }
    t.parent = nil
    t.prev = nil
    t.next = nil
    t.waittail = nil
   } else {
    // 将s放到等待列表的末尾
    if t.waittail == nil {
     t.waitlink = s
    } else {
     t.waittail.waitlink = s
    }
    t.waittail = s
    s.waitlink = nil
   }
   return
  }
  last = t
    // 根据地址大小来进行查找
  if uintptr(unsafe.Pointer(addr)) < uintptr(t.elem) {
   pt = &t.prev
  } else {
   pt = &t.next
  }
 }
 // 将s作为一个新的叶子节点加入到唯一地址树中
 // 平衡树是一个treap树,使用ticket作为随机堆优先级
 // 也就是说,它是根据elem地址排序的二叉树
 // 但是在代表这些地址的可能的二叉树空间中,是通过ticket满足s.ticket均 <=s.prev.ticket 和 s.next.ticket来维护堆
  // 的顺序,从而平均得保持平衡。
 // https://en.wikipedia.org/wiki/Treap
 // https://faculty.washington.edu/aragon/pubs/rst89.pdf
 // s.ticket在几个地方与零比较,因此设置了最低位
 // 这不会明显影响treap的质量
 s.ticket = fastrand() | 1
 s.parent = last
 *pt = s
 
 // 根据ticket翻转树
 for s.parent != nil && s.parent.ticket > s.ticket {
  if s.parent.prev == s {
   root.rotateRight(s.parent)
  } else {
   if s.parent.next != s {
    panic("semaRoot queue")
   }
   root.rotateLeft(s.parent)
  }
 }
}
 

入队的树结构是一个treap,treap=tree+heap,即拥有tree的特性,又有heap的特性。
主要思想是在二叉搜索树的基础上,给每个节点一个随机权重(这里是一个随机值ticket),然后通过旋转在不破坏二叉搜索树性质的前提下将所有节点根据权重重新组织,
使其满足堆的性质。由于权重的随机性,所以可以认为treap能在增删操作下相对平衡,不会退化为链表。

sudog是从一个特殊的池中分配的。使用acquired Sudog和releaseSudog来分配和释放它们。
接下来是释放releaseSudog:
为了保证sudog的复用,当goroutine被唤醒,当前的sudog需要回收到缓存中以备后续使用。
刚刚提到这里涉及到P和sched的二级缓存。所以归还sudog时,如果本地sudog已经满了,会将本地的一半缓存交还回全局缓存。

//go:nosplit
func releaseSudog(s *sudog) {
... ...
 gp := getg()
 if gp.param != nil {
  throw("runtime: releaseSudog with non-nil gp.param")
 }
 mp := acquirem() // 设置P禁止抢占
 pp := mp.p.ptr()
 if len(pp.sudogcache) == cap(pp.sudogcache) {
  // 将本地一半的sudog缓存放回全局缓存
  var first, last *sudog
  for len(pp.sudogcache) > cap(pp.sudogcache)/2 {
   n := len(pp.sudogcache)
   p := pp.sudogcache[n-1]
   pp.sudogcache[n-1] = nil
   pp.sudogcache = pp.sudogcache[:n-1]
   if first == nil {
    first = p
   } else {
    last.next = p
   }
   last = p
  }
  lock(&sched.sudoglock)
  last.next = sched.sudogcache
  sched.sudogcache = first
  unlock(&sched.sudoglock)
 }
 pp.sudogcache = append(pp.sudogcache, s)
 releasem(mp)
}

加锁完成后解锁:主要调用runtime_Semrelease:
根据信号量地址偏移取模&semtable[(uintptr(unsafe.Pointer(addr))>>3)%semTabSize].root拿到semaRoot

信号量原子递增+1,这样semacquire1阻塞的goroutine就可能通过cansemacquire操作

通过原子判断root.nwait的值是否为0,为0表示当前不存在阻塞的goroutine。这里的检查必须发生在semacquire1中的atomic.Xadd(&root.nwait, 1),防止错过唤醒操作。

加锁再次检查root.nwait的值,没有阻塞的goroutine 则直接返回。

否则,从treap中出队当前信号量上的sudog。

//go:linkname sync_runtime_Semrelease sync.runtime_Semrelease
func sync_runtime_Semrelease(addr *uint32, handoff bool, skipframes int) {
 semrelease1(addr, handoff, skipframes)
}
func semrelease1(addr *uint32, handoff bool, skipframes int) {
 root := semroot(addr)
 atomic.Xadd(addr, 1)
 
 // 快速路径:没有等待者?
 // 检查必须发生在xadd之后,避免错过wakeup
 // (详见semacquire中的循环).
 if atomic.Load(&root.nwait) == 0 {
  return
 }
 
 //查找一个等待着并唤醒它
 lockWithRank(&root.lock, lockRankRoot)
 if atomic.Load(&root.nwait) == 0 {
    //计数已经被其他goroutine消费,所以不需要唤醒其他goroutine
  unlock(&root.lock)
  return
 }
 s, t0 := root.dequeue(addr)//查找第一个出现的addr
 if s != nil {
  atomic.Xadd(&root.nwait, -1)
 }
 unlock(&root.lock)
 if s != nil { // 可能比较慢 甚至被挂起所以先unlock
  acquiretime := s.acquiretime
  if acquiretime != 0 {
   mutexevent(t0-acquiretime, 3+skipframes)
  }
  if s.ticket != 0 {
   throw("corrupted semaphore ticket")
  }
  if handoff && cansemacquire(addr) {
   s.ticket = 1
  }
    readyWithTime(s, 5+skipframes) //goready(s.g,5)标记runnable 等待被重新调度
  if s.ticket == 1 && getg().m.locks == 0 {
   // 直接切换G
   // readyWithTime已经将等待的G作为runnext放到当前的P
      // 我们现在调用调度器可以立即执行等待的G
      // 注意waiter继承了我们的时间片:这是希望避免在P上无限得进行激烈的信号量竞争
   // goyield类似于Gosched,但是它是发送“被强占”的跟踪事件,更重要的是,将当前G放在本地runq
      // 而不是全局队列。
      // 我们仅在饥饿状态下执行此操作(handoff=true),因为非饥饿状态下,当我们yielding/scheduling时,
      // 其他waiter可能会获得信号量,这将是浪费的。我们等待进入饥饿状体,然后开始进行ticket和P的手递手交接
   // See issue 33747 for discussion.
   goyield()
  }
 }
}

查找semaRoot中阻塞在指定信号量addr上的第一个goroutine。熟悉了treap结构及queue的逻辑后这里dequeue就比较简单:

查找treap中指定addr的sudog节点

若链表长度大于1,则将头节点弹出,返回弹出的sudog

若链表长度等于1,即需要移除treap树的节点,这时候需要通过循环旋转将节点根据权重保持平衡,将目标节点旋转为叶子节点,然后删除

如果未找到 则返回nil,0

如果找到,判断节点的等待链表

// 如果semacquire1中设置了对sudog进行概要分析,dequeue计算到现在为止唤醒goroutine的时间作为now返回,否则now值为0
func (root *semaRoot) dequeue(addr *uint32) (found *sudog, now int64) {
 ps := &root.treap
 s := *ps
 for ; s != nil; s = *ps {
  if s.elem == unsafe.Pointer(addr) {//查找到指定信号量地址上的sudog
   goto Found
  }
  if uintptr(unsafe.Pointer(addr)) < uintptr(s.elem) {
   ps = &s.prev
  } else {
   ps = &s.next
  }
 }
 return nil, 0
 
Found:
 now = int64(0)
 if s.acquiretime != 0 {
  now = cputicks()
 }
 if t := s.waitlink; t != nil {
  // 用t替换唯一addrs的根树中的s
  *ps = t
  t.ticket = s.ticket
  t.parent = s.parent
  t.prev = s.prev
  if t.prev != nil {
   t.prev.parent = t
  }
  t.next = s.next
  if t.next != nil {
   t.next.parent = t
  }
  if t.waitlink != nil {
   t.waittail = s.waittail
  } else {
   t.waittail = nil
  }
  t.acquiretime = now
  s.waitlink = nil
  s.waittail = nil
 } else {//该信号量地址上 只有一个sudog时
  // 将s旋转为树的叶子节点方便移除,同时注意权重
  for s.next != nil || s.prev != nil {
   if s.next == nil || s.prev != nil && s.prev.ticket < s.next.ticket {
    root.rotateRight(s)
   } else {
    root.rotateLeft(s)
   }
  }
  // s当前为叶子节点,移除s
  if s.parent != nil {
   if s.parent.prev == s {//为根节点的左孩子
    s.parent.prev = nil
   } else {//为根节点的右孩子
    s.parent.next = nil
   }
  } else {//当前treap只有s一个节点
   root.treap = nil
  }
 }
 s.parent = nil
 s.elem = nil
 s.next = nil
 s.prev = nil
 s.ticket = 0
 return s, now
}

如果出队的sudog不为空,将root.nwait原子-1,并释放锁(),让其他goroutine可以继续执行

readyWithTime将sudog中的g唤醒,并放到当前P本地队列的下一个执行位置

func readyWithTime(s *sudog, traceskip int) {
 if s.releasetime != 0 {
  s.releasetime = cputicks()
 }
 goready(s.g, traceskip)
}
func goready(gp *g, traceskip int) {
 systemstack(func() { //切换到系统堆栈
  ready(gp, traceskip, true)
 })
}
// 标记 gp准备run
func ready(gp *g, traceskip int, next bool) {
 if trace.enabled {
  traceGoUnpark(gp, traceskip)
 }
 
 status := readgstatus(gp)
 
 // Mark runnable.
 _g_ := getg()
 mp := acquirem() // 设置禁止P抢占
 if status&^_Gscan != _Gwaiting {
  dumpgstatus(gp)
  throw("bad g->status in ready")
 }
 
 // status is Gwaiting or Gscanwaiting, make Grunnable and put on runq
 casgstatus(gp, _Gwaiting, _Grunnable)
  // 将g放到P的本地队列,注意这里next=true即放到本地队列的下一个执行位置
  // 否则放到对尾
 runqput(_g_.m.p.ptr(), gp, next)
 wakep()
 releasem(mp)//解除抢占
}

饥饿状态下,调用goyield()让出当前时间片,由等待的g继承时间片,避免无限的争夺信号量。因为readyWithTime已经将等待的G放到P本地队列下一个位置,所以调度器会立即执行s.g

func goyield() {
 checkTimeouts()
 mcall(goyield_m)
}
func goyield_m(gp *g) {
 if trace.enabled {
  traceGoPreempt()
 }
 pp := gp.m.p.ptr()
 casgstatus(gp, _Grunning, _Grunnable)//让出时间片
 dropg()
 runqput(pp, gp, false)//将当前g放到P本地队列尾部
 schedule()//触发调度
}

semacquire和semrelease成对出现,实现了简单的sleep和wakeup原语。主要解决并发场景的资源争用问题,显然他们一定是在两个不同的m上执行的场景发生。我们不妨假设m1和m2

  1. 当m1上的g1执行到semacquire1时,如果快速路径cansemacquire成功,则说明g1抢到锁,能够继续执行。但一旦失败且在Harder Case下依然抢不到锁,则会进入goparkunlock,将当前g1放到等待队列中,进而让m1切换并执行其他的g。

  2. 当m2上的g2开始调用semrelease1时,将等待的g1放回P的本地调度队列中,若当前为饥饿模式(handoff=ture)则让当前等待继承时间片立刻执行,如果成功则semacquire1中会归还sudog。

猜你喜欢

转载自blog.csdn.net/weixin_56766616/article/details/129956072
今日推荐