chaoz的杂货铺

生命有息、学无止境、折腾不止

0%

2022-Golang-并发编程-sync

并发编程

sync

go的mutex实现原理,了解过Go的Mutex和RWmutex吗?说说RWMutex与Mutex的区别

https://mp.weixin.qq.com/s/bRCOk8W-OZKIyhJlGbhXPw

共享数据的一致性代表着某种约定,即:多个线程对共享数据的操作总是可以达到它们各自预期的效果。

共享资源的代表:数据块和代码块的背后都隐含着一种或多种资源(比如存储资源、计算资源、I/O 资源、网络资源等等)。

多个并发运行的线程对这个共享资源的访问是完全串行的。

同步的用途有两个,一个是避免多个线程在同一时刻操作同一个数据块,另一个是协调多个线程,以避免它们在同一时刻执行同一个代码块。控制多个线程对共享资源的访问。

它们可以是一个内含了共享数据的结构体及其方法,也可以是操作同一块共享数据的多个函数。临界区总是需要受到保护的,否则就会产生竞态条件。施加保护的重要手段之一,就是使用实现了某种同步机制的工具,也称为同步工具。

ps:
最早加锁,最晚释放。
代码轻量。
加多把锁的情况,注意解锁逻辑。

互斥锁

在 Go 语言中,可供我们选择的同步工具并不少。其中,最重要且最常用的同步工具当属互斥量(mutual exclusion,简称 mutex)。sync包中的Mutex就是与其对应的类型,该类型的值可以被称为互斥量或者互斥锁。

互斥锁可以看作是针对某一个临界区或某一组相关临界区的唯一访问令牌。

对于每一个锁定操作,都要有且只有一个对应的解锁操作。

Go 语言中的互斥锁是开箱即用的。换句话说,一旦我们声明了一个sync.Mutex类型的变量,就可以直接使用它了。

每一个互斥锁都只保护一个临界区,或一组相关临界区

我们今天的问题是:我们使用互斥锁时有哪些注意事项?
使用互斥锁的注意事项如下:
不要重复锁定互斥锁;
不要忘记解锁互斥锁,必要时使用defer语句;
不要对尚未锁定或者已解锁的互斥锁解锁;
不要在多个函数之间直接传递互斥锁。

死锁:指的就是当前程序中的主 goroutine,以及我们启用的那些 goroutine 都已经被阻塞。这些 goroutine 可以被统称为用户级的 goroutine。这就相当于整个程序都已经停滞不前了。
注意:这种由 Go 语言运行时系统自行抛出的 panic 都属于致命错误,都是无法被恢复的,调用recover函数对它们起不到任何作用。也就是说,一旦产生死锁,程序必然崩溃。
注意:互斥锁类型是一个结构体类型,属于值类型中的一种。把它传给一个函数、将它从函数中返回、把它赋给其他变量、让它进入某个通道都会导致它的副本的产生。并且,原值和它的副本,以及多个副本之间都是完全独立的,它们都是不同的互斥锁。如果你把一个互斥锁作为参数值传给了一个函数,那么在这个函数中对传入的锁的所有操作,都不会对存在于该函数之外的那个原锁产生任何的影响。

本包的类型的值不应被拷贝。

互斥锁的底层是怎么实现的?

不会。
https://blog.csdn.net/qq_42956653/article/details/121323179

读写锁

在 Go 语言中,读写锁由sync.RWMutex类型的值代表。与sync.Mutex类型一样,这个类型也是开箱即用的。

一个读写锁中实际上包含了两个锁,即:读锁和写锁。sync.RWMutex类型中的Lock方法和Unlock方法分别用于对写锁进行锁定和解锁,而它的RLock方法和RUnlock方法则分别用于对读锁进行锁定和解锁。

规则:
在写锁已被锁定的情况下再试图锁定写锁,会阻塞当前的 goroutine。
在写锁已被锁定的情况下试图锁定读锁,也会阻塞当前的 goroutine。
在读锁已被锁定的情况下试图锁定写锁,同样会阻塞当前的 goroutine。
在读锁已被锁定的情况下再试图锁定读锁,并不会阻塞当前的 goroutine。

注意:对写锁进行解锁,会唤醒“所有因试图锁定读锁,而被阻塞的 goroutine”,并且,这通常会使它们都成功完成对读锁的锁定。
不要重复锁定或忘记解锁。
不要传递互斥锁。

条件变量

互斥锁:是对一个共享区域进行加锁 所有线程都是一种竞争的状态去访问
条件变量:主要是通过条件状态来判断,实际上他还是会阻塞 只不过不会像互斥锁一样去参与竞争,而是在哪里等待条件变量的状态发生改变过后的通知,再被唤醒。

sync.NewCond函数来初始化一个sync.Cond类型的条件变量。
sync.NewCond函数需要一个sync.Locker类型的参数值。

条件变量怎样与互斥锁配合使用?
条件变量的初始化离不开互斥锁,并且它的方法有的也是基于互斥锁的。

条件变量提供的方法有三个:等待通知(wait)、单发通知(signal)和广播通知(broadcast)。

条件变量的Wait方法主要做了四件事:
1.把调用它的 goroutine(也就是当前的 goroutine)加入到当前条件变量的通知队列中。
2.解锁当前的条件变量基于的那个互斥锁。
3.让当前的 goroutine 处于等待状态,等到通知到来时再决定是否唤醒它。此时,这个 goroutine 就会阻塞在调用这个Wait方法的那行代码上。
4.如果通知到来并且决定唤醒这个 goroutine,那么就在唤醒它之后重新锁定当前条件变量基于的互斥锁。自此之后,当前的 goroutine 就会继续执行后面的代码了。

直接上伪代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
var mailbox uint8
var lock sync.RWMutex
sendCond := sync.NewCond(&lock)
recvCond := sync.NewCond(lock.RLocker())

lock.Lock()
for mailbox == 1 {
sendCond.Wait()
}
mailbox = 1
lock.Unlock()
recvCond.Signal()


lock.RLock()
for mailbox == 0 {
recvCond.Wait()
}
mailbox = 0
lock.RUnlock()
sendCond.Signal()

想想一下生产者消费者模型。
变量mailbox代表信箱,是uint8类型的。 标记用,表示生产者消费者中的对列(仓库),这里用信箱表示吧。
lock是一个类型为sync.RWMutex的变量,是一个读写锁,也可以被视为信箱上的那把锁。
基于这把锁,还创建了两个代表条件变量的变量,名字分别叫sendCond和recvCond。 它们都是*sync.Cond类型的,同时也都是由sync.NewCond函数来初始化的。
sendCond变量做初始化的时候,把基于lock变量的指针值传给了sync.NewCond函数:lock变量的Lock方法和Unlock方法分别用于对其中写锁的锁定和解锁,它们与sendCond变量的含义是对应的。sendCond是专门为放置情报而准备的条件变量,向信箱里放置情报,可以被视为对共享资源的写操作。recvCond变量代表的是专门为获取情报而准备的条件变量。可以把获取情报看做是对共享资源的读操作。

recvCond.Signal()表示接收方可以接受了,发送这个信号的是发送方。
sendCond.Signal()表示发送方可以再次发送了,发送这个信号的是接收方。

流程: 外部函数加锁 -> 判断条件变量->wait内部解锁->阻塞等待信号->wait内部加锁-> 修改条件变量-> 外部解锁-> 触发信号。 第一次加解锁是为了保证读条件变量时它不会被修改, wait解锁是为了条件变量能够被其他线程改变。wait内部再次加锁,是对条件变量的保护,因为外部要修改。

注意:
1.sync.Mutex类型和sync.RWMutex类型不同,sync.Cond类型并不是开箱即用的。我们只能利用sync.NewCond函数创建它的指针值。这个函数需要一个sync.Locker类型的参数值。

2.sync.Locker其实是一个接口,在它的声明中只包含了两个方法定义,即:Lock()和Unlock()。sync.Mutex类型和sync.RWMutex类型都拥有Lock方法和Unlock方法,只不过它们都是指针方法。因此,这两个类型的指针类型才是sync.Locker接口的实现类型。
*sync.Cond类型的值可以被传递吗?那sync.Cond类型的值呢?

指针可以传递,值不可以,传递值会拷贝一份,导致出现两份条件变量,彼此之间没有联系

条件变量的Signal方法和Broadcast方法有哪些异同?

条件变量的Signal方法和Broadcast方法都是被用来发送通知的,不同的是,前者的通知只会唤醒一个因此而等待的 goroutine,而后者的通知却会唤醒所有为此等待的 goroutine。
条件变量的Wait方法总会把当前的 goroutine 添加到通知队列的队尾,而它的Signal方法总会从通知队列的队首开始,查找可被唤醒的 goroutine。所以,因Signal方法的通知,而被唤醒的 goroutine 一般都是最早等待的那一个。
注意:
1.与Wait方法不同,条件变量的Signal方法和Broadcast方法并不需要在互斥锁的保护下执行。
2.这两个方法并不需要受到互斥锁的保护,我们也最好不要在解锁互斥锁之前调用它们。还有,条件变量的通知具有即时性。当通知被发送的时候,如果没有任何 goroutine 需要被唤醒,那么该通知就会立即失效。

为什么先要锁定条件变量基于的互斥锁,才能调用它的Wait方法?

因为条件变量的Wait方法在阻塞当前的 goroutine 之前,会解锁它基于的互斥锁,所以在调用该Wait方法之前,我们必须先锁定那个互斥锁,否则在调用这个Wait方法时,就会引发一个不可恢复的 panic。

为什么要用for语句来包裹调用其Wait方法的表达式,用if语句不行吗?

这主要是为了保险起见。如果一个 goroutine 因收到通知而被唤醒,但却发现共享资源的状态,依然不符合它的要求,那么就应该再次调用条件变量的Wait方法,并继续等待下次通知的到来。

sync.Cond类型中的公开字段L是做什么用的?我们可以在使用条件变量的过程中改变这个字段的值吗?

原子操作

“原子操作(atomic operation)是不需要synchronized”,这是多线程编程的老生常谈了。所谓原子操作是指不会被线程调度机制打断的操作;这种操作一旦开始,就一直运行到结束,中间不会有任何 context switch 。

在底层,这会由 CPU 提供芯片级别的支持,所以绝对有效。即使在拥有多 CPU 核心,或者多 CPU 的计算机系统中,原子操作的保证也是不可撼动的。

操作系统层面只对针对二进制位或整数的原子操作提供了支持。Go 语言的原子操作当然是基于 CPU 和操作系统的,所以它也只针对少数数据类型的值提供了原子操作函数。这些函数都存在于标准库代码包sync/atomic中。

互斥锁虽然可以保证临界区中代码的串行执行,但却不能保证这些代码执行的原子性(atomicity)。

原子操作即是进行过程中不能被中断的操作,针对某个值的原子操作在被进行 的过程中,CPU 绝不会再去进行其他的针对该值的操作。为了实现这样的严谨 性,原子操作仅会由一个独立的 CPU 指令代表和完成。
原子操作是无锁的,常常直接通过 CPU 指令直接实现。 事实上,其它同步技术的实现常常依赖于原子操作。

底层又是复杂的东西:
并不是说,一遇到原子操作,所有的CPU核心就都不工作了。你要理解“中断”这个词的真正含义。这里的细节还是很多的,涉及到cache line、多核CPU协调机制(几种方案)、LOCK原语(CPU不同方案不同)等等。

atomic包提供了底层的原子级内存操作,对于同步算法的实现很有用。这些函数必须谨慎地保证正确使用。除了某些特殊的底层应用,使用通道或者sync包的函数/类型实现同步更好。

atomic包**

方法 解释
func LoadInt32(addr *int32) (val int32)
func LoadInt64(addr *int64) (val int64)
func LoadUint32(addr*uint32) (val uint32)
func LoadUint64(addr*uint64) (val uint64)
func LoadUintptr(addr*uintptr) (val uintptr)
func LoadPointer(addr*unsafe.Pointer) (val unsafe.Pointer)
读取操作
func StoreInt32(addr *int32, val int32)
func StoreInt64(addr *int64, val int64)
func StoreUint32(addr *uint32, val uint32)
func StoreUint64(addr *uint64, val uint64)
func StoreUintptr(addr *uintptr, val uintptr)
func StorePointer(addr *unsafe.Pointer, val unsafe.Pointer)
写入操作
func AddInt32(addr *int32, delta int32) (new int32)
func AddInt64(addr *int64, delta int64) (new int64)
func AddUint32(addr *uint32, delta uint32) (new uint32)
func AddUint64(addr *uint64, delta uint64) (new uint64)
func AddUintptr(addr *uintptr, delta uintptr) (new uintptr)
修改操作
func SwapInt32(addr *int32, new int32) (old int32)
func SwapInt64(addr *int64, new int64) (old int64)
func SwapUint32(addr *uint32, new uint32) (old uint32)
func SwapUint64(addr *uint64, new uint64) (old uint64)
func SwapUintptr(addr *uintptr, new uintptr) (old uintptr)
func SwapPointer(addr *unsafe.Pointer, new unsafe.Pointer) (old unsafe.Pointer)
交换操作
func CompareAndSwapInt32(addr *int32, old, new int32) (swapped bool)
func CompareAndSwapInt64(addr *int64, old, new int64) (swapped bool)
func CompareAndSwapUint32(addr *uint32, old, new uint32) (swapped bool)
func CompareAndSwapUint64(addr *uint64, old, new uint64) (swapped bool)
func CompareAndSwapUintptr(addr *uintptr, old, new uintptr) (swapped bool)
func CompareAndSwapPointer(addr *unsafe.Pointer, old, new unsafe.Pointer) (swapped bool)
比较并交换操作

比较下互斥锁和原子操作的性能:

sync/atomic包中提供了几种原子操作?可操作的数据类型又有哪些?

sync/atomic包中的函数可以做的原子操作有:加法(add)、比较并交换(compare and swap,简称 CAS)、加载(load)、存储(store)和交换(swap)。

传入这些原子操作函数的第一个参数值对应的都应该是那个被操作的值。比如,atomic.AddInt32函数的第一个参数,对应的一定是那个要被增大的整数。可是,这个参数的类型为什么不是int32而是*int32呢?

因为原子操作函数需要的是被操作值的指针,而不是这个值本身;被传入函数的参数值都会被复制,像这种基本类型的值一旦被传入函数,就已经与函数外的那个值毫无关系了。unsafe.Pointer类型虽然是指针类型,但是那些原子操作函数要操作的是这个指针值,而不是它指向的那个值,所以需要的仍然是指向这个指针值的指针。只要原子操作函数拿到了被操作值的指针,就可以定位到存储该值的内存地址。只有这样,它们才能够通过底层的指令,准确地操作这个内存地址上的数据。

用于原子加法操作的函数可以做原子减法吗?比如,atomic.AddInt32函数可以用于减小那个被操作的整数值吗?

当然是可以的。atomic.AddInt32函数的第二个参数代表差量,它的类型是int32,是有符号的。如果我们想做原子减法,那么把这个差量设置为负整数就可以了。

CAS

CAS 的全称为 Compare And Swap,直译就是比较交换。是一条 CPU 的原子指 令,其作用是让 CPU 先进行比较两个值是否相等,然后原子地更新某个位置的 值,其实现方式是给予硬件平台的汇编指令,在 intel 的 CPU 中,使用的 cmpxchg 指令,就是说 CAS 是靠硬件实现的,从而在硬件层面提升效率。

简述过程是这样:

假设包含 3 个参数内存位置(V)、预期原值(A)和新值(B)。V 表示要更新变量的 值,E 表示预期值,N 表示新值。仅当 V 值等于 E 值时,才会将 V 的值设为 N, 如果 V 值和 E 值不同,则说明已经有其他线程在做更新,则当前线程什么都不 做,最后 CAS 返回当前 V 的真实值。CAS 操作时抱着乐观的态度进行的,它总 是认为自己可以成功完成操作。基于这样的原理,CAS 操作即使没有锁,也可 以发现其他线程对于当前线程的干扰。

比较并交换操作与交换操作相比有什么不同?优势在哪里?

比较并交换操作即 CAS 操作,是有条件的交换操作,只有在条件满足的情况下才会进行值的交换。

设我已经保证了对一个变量的写操作都是原子操作,比如:加或减、存储、交换等等,那我对它进行读操作的时候,还有必要使用原子操作吗?

很有必要,如果写操作还没有进行完,读操作就来读了,那么就只能读到仅修改了一部分的值。这显然破坏了值的完整性,读出来的值也是完全错误的。原子操作函数的执行速度要比互斥锁快得多。而且,它们使用起来更加简单,不会涉及临界区的选择,以及死锁等问题。

怎样用好sync/atomic.Value?

atomic.Value类型是开箱即用的,我们声明一个该类型的变量(以下简称原子变量)之后就可以直接使用了。这个类型使用起来很简单,它只有两个指针方法:Store和Load。
注意:
1.第一条规则,不能用原子值存储nil。
2.我们向原子值存储的第一个值,决定了它今后能且只能存储哪一个类型的值。
使用建议:
1.不要把内部使用的原子值暴露给外界。比如,声明一个全局的原子变量并不是一个正确的做法。这个变量的访问权限最起码也应该是包级私有的。
2.如果不得不让包外,或模块外的代码使用你的原子值,那么可以声明一个包级私有的原子变量,然后再通过一个或多个公开的函数,让外界间接地使用到它。注意,这种情况下不要把原子值传递到外界,不论是传递原子值本身还是它的指针值。
3.如果通过某个函数可以向内部的原子值存储值的话,那么就应该在这个函数中先判断被存储值类型的合法性。若不合法,则应该直接返回对应的错误值,从而避免 panic 的发生。
4.如果可能的话,我们可以把原子值封装到一个数据类型中,比如一个结构体类型。这样,我们既可以通过该类型的方法更加安全地存储值,又可以在该类型中包含可存储值的合法类型信息。
5.尽量不要向原子值中存储引用类型的值。因为这很容易造成安全漏洞。

如果要对原子值和互斥锁进行二选一,你认为最重要的三个决策条件应该是什么?

  1. 是否一定要操作引用类型的值;
  2. 是否一定要操作nil;
  3. 是否需要处理一个接口的不同类型。
    比如保护全局配置、同时保护一坨全局计数、保护 bit array,等等。

go中的互斥锁、自旋锁、读写锁、悲观锁、乐观锁。有这些东西么?

并发控制的方法?chan、sync

https://xie.infoq.cn/article/11a6d3c9f9e19ed37280783b2

errGroup

协程出错-通知其他协程

https://juejin.cn/post/6979241352580038692
https://juejin.cn/post/6996300205989560333
errGroup通常伴随一个errCtx上下文出现,用于通知同一个组内的协程,处理逻辑可以由代码控制。

wg.wait 实际上是第一个报错就会返回

一个报错,就退出。

全部取消、降级
这里就是wg.wait之后加一个判断error是否为空
20220219162315

不管是否存在报错,都要继续执行

协程

原码里没有做panic处理。

起的协程出现panic会引发整个程序出现panic

context 作用域要注意

context 闭包处理 如 cancel 处理之后会返回。不能继续传给其他函数了。

可能存在一次性起了太多协程的情况

考虑数量控制。
如果超过限制,用channel进行排队。
没看懂。
20220219162145

waitgroup

WaitGroup类型中只用到了原子操作。

sync.WaitGroup 类型值中计数器的值可以小于0吗?

不可以。之所以说WaitGroup值中计数器的值不能小于0,是因为这样会引发一个 panic。 不适当地调用这类值的Done方法和Add方法都会如此。

导致WaitGroup值的方法抛出 panic 的原因

不要把增加其计数器值的操作和调用其Wait方法的代码,放在不同的 goroutine 中执行。换句话说,要杜绝对同一个WaitGroup值的两种操作的并发执行。

注意

在使用WaitGroup值的时候,我们一定要注意,千万不要让其中的计数器的值小于0,否则就会引发 panic。另外,我们最好用“先统一Add,再并发Done,最后Wait”这种标准方式,来使用WaitGroup值。 尤其不要在调用Wait方法的同时,并发地通过调用Add方法去增加其计数器的值,因为这也有可能引发 panic。

在使用WaitGroup值实现一对多的 goroutine 协作流程时,怎样才能让分发子任务的 goroutine 获得各个子任务的具体执行结果?

sync.Once

Once类型使用互斥锁和原子操作实现了功能。
Once值的使用方式比WaitGroup值更加简单,它只有一个Do方法。同一个Once值的Do方法,永远只会执行第一次被调用时传入的参数函数,不论这个函数的执行会以怎样的方式结束。

Once 可以用来执行且仅仅执行一次动作,常常用于单例对象的初始化场 景。
Once 常常用来初始化单例资源,或者并发访问只需初始化一次的共享资 源,或者在测试的时候初始化一次测试资源。
sync.Once 只暴露了一个方法 Do,你可以多次调用 Do 方法,但是只有第 一次调用 Do 方法时 f 参数才会执行,这里的 f 是一个无参数无返回值 的函数。

Do方法在功能方面的两个特点。

第一个特点,由于Do方法只会在参数函数执行结束之后把done字段的值变为1,因此,如果参数函数的执行需要很长时间或者根本就不会结束(比如执行一些守护任务),那么就有可能会导致相关 goroutine 的同时阻塞。

第二个特点,Do方法在参数函数执行结束后,对done字段的赋值用的是原子操作,并且,这一操作是被挂在defer语句中的。因此,不论参数函数的执行会以怎样的方式结束,done字段的值都会变为1。

使用场景

20220222172711
20220222172744
20220222172755

类型值的Do方法是怎么保证只执行参数函数一次的?

https://zhuanlan.zhihu.com/p/348494392
https://www.jianshu.com/p/c8190ca4b3bd

sync.Pool

对象池,提供对象可复用能力,本身是可伸缩且并发安全的。

对于很多需要重复分配、回收内存的地方,sync.Pool 是一个很好的选择。频 繁地分配、回收内存会给 GC 带来一定的负担,严重的时候会引起 CPU 的毛 刺。
而 sync.Pool 可以将暂时将不用的对象缓存起来,待下次需要的时候直 接使用,不用再次经过内存分配,复用对象的内存,减轻 GC 的压力,提升系 统的性能。

主要结构体Pool对外导出两个方法: Get 和 Put,Get是用来从Pool中获取可用对象,如果可用对象为空,则会通过New预定义的func创建新对象。Put是将对象放入Pool中,提供下次获取。

临时对象池可以帮助程序实现可伸缩性。这就是它的最大价值。

sync.Pool类型也属于结构体类型,它的值在被真正使用之后,就不应该再被复制了。
sync.Pool类型可以被称为临时对象池,它的值可以被用来存储临时的对象。
这里的“临时对象”的意思是:不需要持久使用的某一类值。
sync.Pool类型只有两个方法——Put和Get:
Put 用于在当前的池中存放临时对象,它接受一个interface{}类型的参数
Get 则被用于从当前的池中获取临时对象,它会返回一个interface{}类型的值。

注意

只能放置可被任意时刻回收的对象,如内存、结构体。
比如不能放置连接池,会导致泄漏。

临时对象会在什么时候被创建

调用get方法时未获取到,会自动调用new方法进行创建。

临时对象会在什么时候被销毁

在sync包下的pool.go文件中存在init方法,在init中调用了runtime_registerPoolCleanup 缓存池清理方法。4
sync包在被初始化的时候,会向 Go 语言运行时系统注册一个函数,这个函数的功能就是清除所有已创建的临时对象池中的值。我们可以把它称为池清理函数。

池汇总列表

在sync包中还有一个包级私有的全局变量。这个变量代表了当前的程序中使用的所有临时对象池的汇总,它是元素类型为*sync.Pool的切片。
在一个临时对象池的Put方法或Get方法第一次被调用的时候,这个池就会被添加到池汇总列表中。

为什么说临时对象池中的值会被及时地清理掉?

因为,Go 语言运行时系统中的垃圾回收器,所以在每次开始执行之前,都会对所有已创建的临时对象池中的值进行全面地清除。

池清理函数会遍历池汇总列表执行流程

池清理函数会遍历池汇总列表。对于其中的每一个临时对象池,它都会先将池中所有的私有临时对象和共享临时对象列表都置为nil,然后再把这个池中的所有本地池列表都销毁掉。
最后,池清理函数会把池汇总列表重置为空的切片。

临时对象池存储值所用的数据结构是怎样的?

在临时对象池中,有一个多层的数据结构。这个数据结构的顶层,我们可以称之为本地池列表,不过更确切地说,它是一个数组。这个列表的长度,总是与 Go 语言调度器中的 P 的数量相同。

sync.Pool 中的本地池与各个 G 的对应关系

一个临时对象池的Put方法或Get方法会获取到哪一个本地池,完全取决于调用它的代码所在的 goroutine 关联的那个 P。

临时对象池是怎样利用内部数据结构来存取值的?

临时对象池的Put方法总会先试图把新的临时对象,存储到对应的本地池的private字段中,当这个private字段已经存有某个值时,该方法才会去访问本地池的shared字段。由于shared字段是共享的,所以此时必须受到互斥锁的保护。

临时对象池的Get方法,总会先试图从对应的本地池的private字段处获取一个临时对象。只有当这个private字段的值为nil时,它才会去访问本地池的shared字段。

怎样保证一个临时对象池中总有比较充足的临时对象?

实现一个数据库连接池

https://www.topgoer.cn/blog-173.html

思考题

获取不到锁会一直等待吗?

那如何实现一个timeout的锁?

go中的互斥锁:正常、饥饿状态,读写锁中写操作如何阻止读操作?

饥饿模式下如何保证队列里的协程一定拿得到锁,或者说协程是根据什么判断进入这个队列的?

golang自动检测死锁deadlock的实现

获取不到锁会一直等待吗?

如何实现一个timeout的锁?

锁的底层实现是什么

锁有哪些?怎么抢锁?锁怎么释放?加了超时之后有没有可能在没有释放的情况下, 被人抢走锁?

分布式锁的提供方,

用过ZK和Redis的分布式锁

线程之间如何进行通信的

死锁发生的条件

Mutex 几种状态

mutexLocked — 表示互斥锁的锁定状态;
mutexWoken — 表示从正常模式被从唤醒;
mutexStarving — 当前的互斥锁进入饥饿状态;
waitersCount — 当前互斥锁上等待的 Goroutine 个数;

Mutex 正常模式和饥饿模式

正常模式(非公平锁)

正常模式下,所有等待锁的 goroutine 按照 FIFO(先进先出)顺序等待。唤醒 的 goroutine 不会直接拥有锁,而是会和新请求 goroutine 竞争锁。新请求的 goroutine 更容易抢占:因为它正在 CPU 上执行,所以刚刚唤醒的 goroutine有很大可能在锁竞争中失败。在这种情况下,这个被唤醒的 goroutine 会加入 到等待队列的前面。

饥饿模式(公平锁)

为了解决了等待 goroutine 队列的长尾问题

饥饿模式下,直接由 unlock 把锁交给等待队列中排在第一位的 goroutine (队 头),同时,饥饿模式下,新进来的 goroutine 不会参与抢锁也不会进入自旋状 态,会直接进入等待队列的尾部。这样很好的解决了老的 goroutine 一直抢不 到锁的场景。 饥饿模式的触发条件:当一个 goroutine 等待锁时间超过 1 毫秒时,或者当前 队列只剩下一个 goroutine 的时候,Mutex 切换到饥饿模式。

总结

对于两种模式,正常模式下的性能是最好的,goroutine 可以连续多次获取 锁,饥饿模式解决了取锁公平的问题,但是性能会下降,这其实是性能和公平 的一个平衡模式。

Mutex 允许自旋的条件

锁已被占用,并且锁不处于饥饿模式。
积累的自旋次数小于最大自旋次数(active_spin=4)。
CPU 核数大于 1。
有空闲的 P。
当前 Goroutine 所挂载的 P 下,本地待运行队列为空。

RWMutex 实现

通过记录 readerCount 读锁的数量来进行控制,当有一个写锁的时候,会将读 锁数量设置为负数 1<<30。目的是让新进入的读锁等待之前的写锁释放通知读 锁。同样的当有写锁进行抢占时,也会等待之前的读锁都释放完毕,才会开始 进行后续的操作。 而等写锁释放完之后,会将值重新加上 1<<30, 并通知刚才 新进入的读锁(rw.readerSem),两者互相限制。

RWMutex 注意事项

RWMutex 是单写多读锁,该锁可以加多个读锁或者一个写锁
读锁占用的情况下会阻止写,不会阻止读,多个 Goroutine 可以同时获取 读锁
写锁会阻止其他 Goroutine(无论读和写)进来,整个锁由该 Goroutine 独占
适用于读多写少的场景
RWMutex 类型变量的零值是一个未锁定状态的互斥锁
RWMutex 在首次被使用之后就不能再被拷贝
RWMutex 的读锁或写锁在未锁定状态,解锁操作都会引发 panic
RWMutex 的一个写锁去锁定临界区的共享资源,如果临界区的共享资源已 被(读锁或写锁)锁定,这个写锁操作的 goroutine 将被阻塞直到解锁
RWMutex 的读锁不要用于递归调用,比较容易产生死锁
RWMutex 的锁定状态与特定的 goroutine 没有关联。一个 goroutine 可 以 RLock(Lock),另一个 goroutine 可以 RUnlock(Unlock)
写锁被解锁后,所有因操作锁定读锁而被阻塞的 goroutine 会被唤醒,并 都可以成功锁定读锁
读锁被解锁后,在没有被其他读锁锁定的前提下,所有因操作锁定写锁而 被阻塞的 Goroutine,其中等待时间最长的一个 Goroutine 会被唤醒

Cond 是什么

Cond 实现了一种条件变量,可以使用在多个 Reader 等待共享资源 ready 的场 景(如果只有一读一写,一个锁或者 channel 就搞定了)

每个 Cond 都会关联一个 Lock(sync.Mutex or sync.RWMutex),当修改条 件或者调用 Wait 方法时,必须加锁,保护 condition。

Broadcast 和 Signal 区别

func (c *Cond) Broadcast() Broadcast
会唤醒所有等待 c 的 goroutine。

调用 Broadcast 的时候,可以加锁,也可以不加锁。

func (c *Cond) Signal()
Signal 只唤醒 1 个等待 c 的 goroutine。

调用 Signal 的时候,可以加锁,也可以不加锁。

Cond 中 Wait 使用

func (c *Cond) Wait()
Wait()会自动释放 c.L 锁,并挂起调用者的 goroutine。之后恢复执行, Wait()会在返回时对 c.L 加锁。

除非被 Signal 或者 Broadcast 唤醒,否则 Wait()不会返回。

由于 Wait()第一次恢复时,C.L 并没有加锁,所以当 Wait 返回时,调用者通常 并不能假设条件为真。如下代码:。

取而代之的是, 调用者应该在循环中调用 Wait。(简单来说,只要想使用 condition,就必须加锁。)

1
2
3
4
5
6
c.L.Lock() 
for !condition() {
c.Wait()
}
... make use of condition ...
c.L.Unlock()

WaitGroup 实现原理

WaitGroup 主要维护了 2 个计数器,一个是请求计数器 v,一个是等待计数 器 w,二者组成一个 64bit 的值,请求计数器占高 32bit,等待计数器占低 32bit。
每次 Add 执行,请求计数器 v 加 1,Done 方法执行,等待计数器减 1,v 为 0 时通过信号量唤醒 Wait()。

用Go实现可重入锁

https://zhuanlan.zhihu.com/p/376670305

喜欢这篇文章?打赏一下作者吧!

欢迎关注我的其它发布渠道