sync同步
“sync”的中文意思是“同步”。相比于 Go 语言宣扬的“用通讯的方式共享数据”,通过共享数据的方式来传递信息和协调线程运行的做法其实更加主流,目前大多数的现代编程语言都是用后一种方式作为并发编程的解决方案的。
一旦数据被多个线程共享,那么就很可能会产生争用和冲突的情况。这种情况也被称为竞态条件(race condition),这往往会破坏共享数据的一致性。
同步的用途有两个:
- 避免多个线程在同一时刻操作同一个数据块
- 协调多个线程,避免它们在同一时刻执行同一个代码块
由于这样的数据块和代码块的背后都隐含着一种或多种资源(比如存储资源、计算资源、I/O 资源、网络资源等等),所以我们可以把它们看做是共享资源,或者说共享资源的代表。同步其实就是在控制多个线程对共享资源的访问。通过同步工具来控制共享资源的访问。
在 Go 语言中,最重要且最常用的同步工具是互斥量(mutual exclusion,简称 mutex)。sync包中的Mutex就是与其对应的类型,该类型的值可以被称为互斥量或者互斥锁。
通过它来保证,在同一时刻只有一个 goroutine 处理共享资源。锁定共享资源操作通过调用互斥锁的Lock方法实现,而解锁操作调用互斥锁的Unlock方法。
// 定义互斥锁
var mu sync.Mutex
mu.Lock() // 锁定
// 操作共享资源
_, err := writer.Write([]byte(data))
if err != nil {
log.Printf("error: %s [%d]", err, id)
}
mu.Unlock() // 解锁
互斥锁注意事项
- 不要重复锁定互斥锁;
- 对被锁定的互斥锁进行锁定,会立即阻塞当前的 goroutine 。
- 不要忘记解锁互斥锁,必要时使用
defer语句;- 避免造成重复锁定。
- 不要对尚未锁定或者已解锁的互斥锁解锁;
- 不要在多个函数之间直接传递互斥锁。
- 斥锁同时用在了多个地方,会有更多的 goroutine 争用这把锁。不但会让程序变慢,还会大大增加死锁(deadlock)的可能性。
- 把锁传给一个函数、将它从函数中返回、把它赋给其他变量、让它进入某个通道都会导致它的副本的产生。它们都是不同的互斥锁。
死锁,指的就是当前程序中的主 goroutine,以及我们启用的那些 goroutine 都已经被阻塞。这些 goroutine 可以被统称为用户级的 goroutine。Go 会自行抛出一个带有如下信息的 panic:
fatal error: all goroutines are asleep - deadlock!
注意,这种由 Go 语言运行时系统自行抛出的 panic 都属于致命错误,都是无法被恢复的,调用recover函数对它们起不到任何作用。也就是说,一旦产生死锁,程序必然崩溃。
要尽量避免这种情况的发生,而最简单、有效的方式就是让每一个互斥锁都只保护一处共享资源。并且应该保证,对于每一个锁定操作,都要有且只有一个对应的解锁操作。
// 准备数据。
data = fmt.Sprintf("%s\t",
time.Now().Format(time.StampNano))
// 写入数据。
mu.Lock()
defer mu.Unlock()
n, err = writer.Write([]byte(data))
buffer, ok := reader.(*bytes.Buffer)
if !ok {
err = errors.New("unsupported reader")
return
}
// 读取数据。
mu.Lock()
defer mu.Unlock()
data, err = buffer.ReadString('\t')
n = len(data)
读写锁
读写锁是读 / 写互斥锁的简称。在 Go 语言中,读写锁由sync.RWMutex类型的值代表。与sync.Mutex类型一样,这个类型也是开箱即用的。
一个读写锁中实际上包含了两个锁,即:读锁和写锁。sync.RWMutex类型中的Lock方法和Unlock方法分别用于对写锁进行锁定和解锁,而它的RLock方法和RUnlock方法则分别用于对读锁进行锁定和解锁。
对于同一个读写锁来说有如下规则。
- 在写锁已被锁定的情况下再试图锁定写锁,会阻塞当前的 goroutine。
- 在写锁已被锁定的情况下试图锁定读锁,也会阻塞当前的 goroutine。
- 在读锁已被锁定的情况下试图锁定写锁,同样会阻塞当前的 goroutine。
- 在读锁已被锁定的情况下再试图锁定读锁,并不会阻塞当前的 goroutine。
\对于某个受到读写锁保护的共享资源,多个写操作不能同时进行,写操作和读操作也不能同时进行,但多个读操作却可以同时进行。Go 语言的读写锁是互斥锁的一种扩展。
条件变量sync.Cond
条件变量是基于互斥锁的,它必须有互斥锁的支撑才能发挥作用。用于协调想要访问共享资源的那些线程的。当共享资源的状态发生变化时,它可以被用来通知被互斥锁阻塞的线程。
在共享资源的状态产生变化的时候,条件变量起到了通知的作用。条件变量在这里的最大优势就是在效率方面的提升,不用再像以前那样,通过线程反复检查来判断共享资源状态。
条件变量提供的方法有三个:
- 等待通知(wait)
- 单发通知(signal)
- 广播通知(broadcast)
在利用条件变量等待通知的时候,需要在它基于的那个互斥锁
保护下进行。而在进行单发通知或广播通知的时候,需要在对应的互斥锁解锁之后再做这两种操作。
Go语言传递对象时,使用的是浅拷贝的值传递,当传递一个Cond对象时复制了这个Cond对象,但是低层保存的L(Locker类型),noCopy(noCopy类型),notify(notifyList类型),checker(copyChecker)对象的指针没变,因此,*sync.Cond和sync.Cond都可以传递。
实践案例:
// 变量mailbox代表信箱,是uint8类型的。
// 若它的值为0则表示信箱中没有情报,而当它的值为1时则说明信箱中有情报。
var mailbox uint8
// 定义读写锁
var lock sync.RWMutex
// 创建两个条件变量,都是 *sync.Cond 类型的
sendCond := sync.NewCond(&lock)
recvCond := sync.NewCond(lock.RLocker())
条件变量需要利用sync.NewCond函数创建它的指针值。这个函数需要一个sync.Locker类型的参数值。因为条件变量是基于互斥锁的。
sync.Locker其实是一个接口,在它的声明中只包含了两个方法定义,即:Lock()和Unlock()。
// sign 用于传递演示完成的信号。
sign := make(chan struct{}, 3)
max := 5
// 创建发信的 goroutine
// 适时地向信箱里放置情报并通知你
go func(max int) {
defer func() {
sign <- struct{}{}
}()
for i := 1; i <= max; i++ {
time.Sleep(time.Millisecond * 500)
// 为了操作资源,先持有锁
lock.Lock()
// 判断当前信箱是否有情报,有的话不发送消息了
// for语句却可以做多次检查,直到这个状态改变为止。
for mailbox == 1 {
sendCond.Wait()
}
// 如果信箱里没有情报,把新情报放进去,关上信箱、锁上锁,然后离开
log.Printf("sender [%d]: the mailbox is empty.", i)
mailbox = 1
log.Printf("sender [%d]: the letter has been sent.", i)
lock.Unlock()
// 通知有情报了
recvCond.Signal()
}
}(max)
再定义一个 goroutine,用于从信箱中获取信息,然后通知。
// 用于收信。
go func(max int) {
defer func() {
sign <- struct{}{}
}()
for j := 1; j <= max; j++ {
time.Sleep(time.Millisecond * 500)
// 为了操作资源,先持有锁
lock.RLock()
// 如果没有消息,进行等待
// for语句却可以做多次检查,直到这个状态改变为止。
for mailbox == 0 {
recvCond.Wait()
}
// 如果信箱里有情报,那么你就应该取走情报,关上信箱、锁上锁,然后离开。
log.Printf("receiver [%d]: the mailbox is full.", j)
mailbox = 0
log.Printf("receiver [%d]: the letter has been received.", j)
lock.RUnlock()
// 通知有情报了
sendCond.Signal()
}
}(max)
只要条件不满足,就会通过调用条件变量的Wait方法,等待你的通知,只有在收到通知之后我才会再次检查信箱。
利用条件变量可以实现单向的通知,而双向的通知则需要两个条件变量。这也是条件变量的基本使用规则。
条件变量的Wait方法
条件变量的Wait方法主要做了四件事。
- 把调用它的 goroutine 加入到当前条件变量的通知队列中。
- 解锁当前的条件变量的互斥锁。
- 让当前的 goroutine 处于等待状态,等到通知到来时再决定是否唤醒它。此时,这个 goroutine 会处于阻塞状态。
- 当通知到来并且决定唤醒这个 goroutine,那么就在唤醒它之后重新锁定当前条件变量基于的互斥锁。自此之后,当前的 goroutine 就会继续执行后面的代码了。
因为条件变量的Wait方法在阻塞当前的 goroutine 之前会解锁它的互斥锁,所以在调用该Wait方法之前我们必须先锁定那个互斥锁,否则在调用这个Wait方法时,就会引发一个不可恢复的 panic。
条件变量的Signal方法和Broadcast方法
条件变量的Signal方法和Broadcast方法都是被用来发送通知的,不同的是,前者的通知只会唤醒一个因此而等待的 goroutine,而后者的通知却会唤醒所有为此等待的 goroutine。
条件变量的Wait方法把当前的 goroutine 添加到通知队列的队尾,而它的Signal方法会从通知队列的队首开始查找可被唤醒的 goroutine。所以,因Signal方法的通知而被唤醒的 goroutine 一般都是最早等待的那一个。
如果确定只有一个 goroutine 在等待通知,或者只需唤醒任意一个 goroutine 就可以满足要求,那么使用条件变量的Signal方法就好了。否则,使用Broadcast方法总没错,只要你设置好各个 goroutine 所期望的共享资源状态就可以。
条件变量的Signal方法和Broadcast方法并不需要在互斥锁的保护下执行。恰恰相反,我们最好在解锁条件变量基于的那个互斥锁之后,再去调用它的这两个方法。这更有利于程序的运行效率。
原子操作
Go 语言运行时系统中的调度器,会恰当地安排其中所有的 goroutine 的运行。不过,在同一时刻,只可能有少数的 goroutine 真正地处于运行状态,并且这个数量是固定的。调度器总是会频繁地换上或换下这些 goroutine。
换下是使使一个 goroutine 中的代码中断执行,并让它由运行状态转为非运行状态。互斥锁虽然可以保证代码的串行执行,但却不能保证这些代码执行的原子性(atomicity)。
在众多的同步工具中,真正能够保证原子性执行的只有原子操作(atomic operation)。原子操作在进行的过程中是不允许中断的。在底层,这会由 CPU 提供芯片级别的支持,所以绝对有效。即使在拥有多 CPU 核心,或者多 CPU 的计算机系统中,原子操作的保证也是不可撼动的。
原子操作可以完全地消除竞态条件,并能够绝对地保证并发安全性。并且,执行速度要比其他的同步工具快得多,通常会高出好几个数量级。不过,它的缺点也很明显。
更具体地说,正是因为原子操作不能被中断,所以它需要足够简单,并且要求快速。 操作系统层面只对针对二进制位或整数的原子操作提供了支持。
Go 语言的原子操作当然是基于 CPU 和操作系统的,所以它也只针对少数数据类型的值提供了原子操作函数。这些函数都存在于标准库代码包sync/atomic中。
sync/atomi包中的函数可以做原子操作有:加法(add)、比较并交换(compare and swap, CAS),加载(load)、存储(store)和交换(swap)。这些数据类型有:int32、int64、uint32、uint64、uintptr,以及unsafe包中的Pointer。不过,针对unsafe.Pointer类型,该包并未提供进行原子加法操作的函数。
此外,sync/atomic包还提供了一个名为Value的类型,它可以被用来存储任意类型的值。
-
传入这些原子操作函数的第一个参数值对应的都应该是那个被操作的值。比如,
atomic.AddInt32函数的第一个参数,对应的一定是那个要被增大的整数。- 原子操作函数需要的是被操作值的指针,而不是这个值本身;被传入函数的参数值都会被复制,像这种基本类型的值一旦被传入函数,就已经与函数外的那个值毫无关系了。
unsafe.Pointer类型虽然是指针类型,但是那些原子操作函数要操作的是这个指针值,而不是它指向的那个值,所以需要的仍然是指向这个指针值的指针。
-
原子加法操作的函数可以做原子减法。当操作两个数类型不同时,要进行转换。
- 用
atomic.AddUint32和atomic.AddUint64函数做原子减法,就不能这么直接了,先把两个数差量转换为有符号的int32类型的值,假设差量为-3,然后再把该值的类型转换为uint32,用表达式来描述就是uint32(int32(-3))。
num := uint32(18) fmt.Printf("The number: %d\n", num) // 先把int32(-3)的结果值赋给变量delta, // 常量-3不在uint32类型可表示的范围内 delta := int32(-3) // 再把delta的值转换为uint32类型的值,就可以绕过编译器的检查并得到正确的结果了。 atomic.AddUint32(&num, uint32(delta)) fmt.Printf("The number: %d\n", num) // 更加直接的方式: ^uint32(-N-1)) atomic.AddUint32(&num, ^uint32(-(-3)-1)) fmt.Printf("The number: %d\n", num) fmt.Printf("The two's complement of %d: %b\n", delta, uint32(delta)) // -3的补码。 fmt.Printf("The equivalent: %b\n", ^uint32(-(-3)-1)) // 与-3的补码相同。 - 用
-
比较并交换操作与交换操作差别
-
比较并交换操作即 CAS 操作,是有条件的交换操作,只有在条件满足的情况下才会进行值的交换。
for { if atomic.CompareAndSwapInt32(&num2, 10, 0) { fmt.Println("The second number has gone to zero.") break } time.Sleep(time.Millisecond * 500) } -
在
for语句中的 CAS 操作可以不停地检查某个需要满足的条件,一旦条件满足就退出for循环。这就相当于,只要条件未被满足,当前的流程就会被一直“阻塞”在这里。
-
-
变量的读、写操作都要是原子操作。因为可能出现写操作还未完成,便来读取,这时取得数据并不完整,所以读操作也要原子操作。
sync/atomic.Value
为了扩大原子操作的适用范围,Go 语言在 1.4 版本发布的时候向sync/atomic包中添加了一个新的类型Value。此类型的值相当于一个容器,可以被用来“原子地”存储和加载任意的值。
当atomic.Value类型的值(以下简称原子值)被真正使用,它就不应该再被复制了。atomic.Value类型属于结构体类型,而结构体类型属于值类型。
所以,复制该类型的值会产生一个完全分离的新值。这个新值相当于被复制的那个值的一个快照。之后,不论后者存储的值怎样改变,都不会影响到前者,反之亦然。
用原子值来存储值有两条强制性的使用规则。
- 不能用原子值存储
nil。也就是说,我们不能把nil作为参数值传入原子值的Store方法,否则就会引发一个 panic。 - 向原子值存储的第一个值,决定了它今后能且只能存储哪一个类型的值。
var box atomic.Value
fmt.Println("Copy box to box2.")
box2 := box // 原子值在真正使用前可以被复制。
v1 := [...]int{1, 2, 3}
fmt.Printf("Store %v to box.\n", v1)
box.Store(v1)
fmt.Printf("The value load from box is %v.\n", box.Load())
fmt.Printf("The value load from box2 is %v.\n", box2.Load())
使用建议
- 不要把内部使用的原子值暴露给外界。
- 如果想让模块外的代码使用你的原子值,声明一个包级私有的原子变量,通过一个或多个公开的函数。
- 某个函数可以向内部的原子值存储值的话,要判断合法性
- 建议把原子值封装到一个数据类型中,比如一个结构体类型。尽量不要向原子值中存储引用类型的值。
var box6 atomic.Value
v6 := []int{1, 2, 3}
box6.Store(v6)
v6[1] = 4 // 注意,此处的操作不是并发安全的!
// 修补方式
store := func(v []int) {
replica := make([]int, len(v))
copy(replica, v)
box6.Store(replica)
}
store(v6)
v6[2] = 5 // 此处的操作是安全的。
sync.WaitGroup和sync.Once
sync.WaitGroup
互斥锁、条件变量和原子操作都是最基本重要的同步工具。在 Go 语言中,除了通道之外,它们也算是最为常用的并发安全工具了。
为了方便日常开发,sync包还提供了WaitGroup类型,主要用于一对多的 goroutine 协作流程。sync.WaitGroup类型(以下简称WaitGroup类型)是开箱即用的,也是并发安全的。
WaitGroup类型拥有三个指针方法:Add、Done和Wait。你可以想象该类型中有一个计数器,它的默认值是0。我们可以通过调用该类型值的Add方法来增加,或者减少这个计数器的值。
func coordinateWithWaitGroup() {
// 声明了一个WaitGroup类型的变量wg。
var wg sync.WaitGroup
// 调用了它的Add方法并传入了2
wg.Add(2)
num := int32(0)
fmt.Printf("The number: %d [with sync.WaitGroup]\n", num)
max := int32(10)
// wg变量的Done方法本身就是一个既无参数声明,也无结果声明的函数,可以作为最后一个参数
go addNum(&num, 3, max, wg.Done)
go addNum(&num, 4, max, wg.Done)
wg.Wait()
}
// addNum 用于原子地增加numP所指的变量的值。
func addNum(numP *int32, id, max int32, deferFunc func()) {
defer func() {
deferFunc()
}()
for i := 0; ; i++ {
currNum := atomic.LoadInt32(numP)
if currNum >= max {
break
}
newNum := currNum + 2
time.Sleep(time.Millisecond * 200)
if atomic.CompareAndSwapInt32(numP, currNum, newNum) {
fmt.Printf("The number: %d [%d-%d]\n", newNum, id, i)
} else {
fmt.Printf("The CAS operation failed. [%d-%d]\n", id, i)
}
}
}
注意:sync.WaitGroup类型值中计数器的值不可以小于0。小于0会引发一个 panic。 不适当地调用这类值的Done方法和Add方法都会如此。
WaitGroup值是可以被复用的,但需保证其计数周期的完整性。计数周期指的是这样一个过程:该值中的计数器值由0变为了某个正整数,而后又经过一系列的变化,最终由某个正整数又变回了0。
Wait方法在它的某个计数周期中被调用,就会立即阻塞当前的 goroutine,直至这个计数周期完成。在这种情况下,该值的下一个计数周期,必须要等到这个Wait方法执行结束之后,才能够开始。如果在一个此类值的Wait方法被执行期间,跨越了两个计数周期,那么就会引发一个 panic。
注意:不要把增加其计数器值的操作和调用其Wait方法的代码,放在不同的 goroutine 中执行。换句话说,要杜绝对同一个WaitGroup值的两种操作的并发执行。
sync.Once
sync.Once类型(以下简称Once类型)也属于结构体类型,同样也是开箱即用和并发安全的。由于这个类型中包含了一个sync.Mutex类型的字段,所以,复制该类型的值也会导致功能的失效。
Once类型的Do方法只接受一个参数,参数类型必须是func(),即:无参数声明和结果声明的函数。该方法的功能只执行“首次被调用时传入的”那个函数,并且之后不会再执行任何参数函数。有多个只需要执行一次的函数,那么就应该为它们中的每一个都分配一个sync.Once类型的值(以下简称Once值)。- 类型中还有一个名叫
done的uint32类型的字段。它的作用是记录其所属值的Do方法被调用的次数。不过,该字段的值只可能是0或者1。一旦Do方法的首次调用完成,它的值就会从0变为1。操作必须是“原子”的。Do方法在一开始就会通过调用atomic.LoadUint32函数来获取该字段的值,并且一旦发现该值为1就会直接返回。
Do方法在功能方面的两个特点
- 由于
Do方法只会在参数函数执行结束之后把done字段的值变为1,因此,如果参数函数的执行需要很长时间或者根本就不会结束(比如执行一些守护任务),那么就有可能会导致相关 goroutine 的同时阻塞。 Do方法在参数函数执行结束后,对done字段的赋值用的是原子操作,并且,这一操作是被挂在defer语句中的。因此,不论参数函数的执行会以怎样的方式结束,done字段的值都会变为1。
案例:
once = sync.Once{}
wg.Add(2)
go func() {
defer wg.Done()
defer func() {
if p := recover(); p != nil {
fmt.Printf("fatal error: %v\n", p)
}
}()
once.Do(func() {
fmt.Println("Do task. [4]")
panic(errors.New("something wrong"))
fmt.Println("Done. [4]")
})
}()
go func() {
defer wg.Done()
time.Sleep(time.Millisecond * 500)
once.Do(func() {
fmt.Println("Do task. [5]")
})
fmt.Println("Done. [5]")
}()
wg.Wait()
// 执行结果
// Do task. [4]
// fatal error: something wrong
// Done. [5]
context.Context类型
在使用WaitGroup值的时候,我们最好用“先统一Add,再并发Done,最后Wait”的标准模式来构建协作流程。如果在调用该值的Wait方法的同时,为了增大其计数器的值,而并发地调用该值的Add方法,那么就很可能会引发 panic。
如果不能在一开始就确定执行子任务的 goroutine 的数量,那么使用WaitGroup值来协调它们和分发子任务的 goroutine,就是有一定风险的。一个解决方案是:分批地启用执行子任务的 goroutine。
WaitGroup值是可以被复用的,但需要保证其计数周期的完整性。尤其是涉及对其Wait方法调用的时候,它的下一个计数周期必须要等到,与当前计数周期对应的那个Wait方法调用完成之后,才能够开始。
最简单的方式就是使用for循环作为辅助来分批地启用执行子任务的 goroutine。
func coordinateWithWaitGroup() {
total := 12
stride := 3
var num int32
fmt.Printf("The number: %d [with sync.WaitGroup]\n", num)
var wg sync.WaitGroup
for i := 1; i <= total; i = i + stride {
// 统一Add
wg.Add(stride)
for j := 0; j < stride; j++ {
// 并发Done
go addNum(&num, i+j, wg.Done)
}
// 最后Wait
wg.Wait()
}
fmt.Println("End.")
}
使用context包中的程序实体,实现一对多的 goroutine 协作流程
func coordinateWithContext() {
total := 12
var num int32
fmt.Printf("The number: %d [with context.Context]\n", num)
// 调用了context.Background函数和context.WithCancel函数
// 并得到了一个可撤销的context.Context类型的值和一个context.CancelFunc类型的撤销函数(
cxt, cancelFunc := context.WithCancel(context.Background())
for i := 1; i <= total; i++ {
go addNum(&num, i, func() {
// 如果所有的addNum函数都执行完毕,那么就立即通知分发子任务的 goroutine。
if atomic.LoadInt32(&num) == int32(total) {
// cancelFunc函数被调用,针对该通道的接收操作就会马上结束,实现“等待所有的addNum函数都执行完毕”的功能。
cancelFunc()
}
})
}
<-cxt.Done()
fmt.Println("End.")
}
context.Context类型(以下简称Context类型)是在 Go 1.7 发布时才被加入到标准库的。而后,标准库中的很多其他代码包都为了支持它而进行了扩展。
Context类型是一种非常通用的同步工具。它的值不但可以被任意地扩散,而且还可以被用来传递额外的信息和信号,可以传播给多个 goroutine。
Context是接口类型,context包中实现该接口的所有私有类型,都是基于某个数据类型的指针类型,所以,如此传播并不会影响该类型值的功能和安全。所有Context共同构成代表上下文的树形结构。
context包中还包含了四个用于繁衍Context值的函数,即:
WithCancel用于产生一个可撤销的parent的子值。被调用后有两个返回值。第一个就是可撤销的Context值,第二个是用于触发撤销信号的函数。WithDeadline产生一个会定时撤销的parent的子值。WithTimeout产生一个会定时撤销的parent的子值。WithValue产生一个会携带额外数据的parent的子值。
这些函数的第一个参数的类型都是context.Context,而名称都为parent。如果当前的Context值被撤销,接收通道就会被立即关闭。
注意:通过调用
context.WithValue函数得到的Context值是不可撤销的。撤销信号在被传播时,若遇到它们则会直接跨过,并试图将信号直接传给它们的子值。
Context值携带数据的方式:
WithValue函数在产生新的Context值(以下简称含数据的Context值)的时候需要三个参数,即:父值、键和值。与“字典对于键的约束”类似,这里键的类型必须是可判等的。当我们从中获取数据的时候,它需要根据给定的键来查找对应的值。
Context类型的Value方法就是被用来获取数据的。在我们调用含数据的Context值的Value方法时,它会先判断给定的键,是否与当前值中存储的键相等,如果相等就把该值中存储的值直接返回,否则就到其父值中继续查找。
type myKey int
func main() {
keys := []myKey{
myKey(20),
myKey(30),
myKey(60),
}
values := []string{
"value in node2",
"value in node3",
"value in node6",
}
rootNode := context.Background()
// 调用后有两个返回值。第一个就是可撤销的Context值,第二个是用于触发撤销信号的函数。
node1, cancelFunc1 := context.WithCancel(rootNode)
defer cancelFunc1()
node2 := context.WithValue(node1, keys[0], values[0])
node3 := context.WithValue(node2, keys[1], values[1])
fmt.Printf("The value of the key %v found in the node3: %v\n",
keys[0], node3.Value(keys[0]))
fmt.Printf("The value of the key %v found in the node3: %v\n",
keys[1], node3.Value(keys[1]))
fmt.Printf("The value of the key %v found in the node3: %v\n",
keys[2], node3.Value(keys[2]))
fmt.Println()
// 执行结果
// The value of the key 20 found in the node3: value in node2
// The value of the key 30 found in the node3: value in node3
// The value of the key 60 found in the node3: <nil>
node4, _ := context.WithCancel(node3)
node5, _ := context.WithTimeout(node4, time.Hour)
fmt.Printf("The value of the key %v found in the node5: %v\n",
keys[0], node5.Value(keys[0]))
fmt.Printf("The value of the key %v found in the node5: %v\n",
keys[1], node5.Value(keys[1]))
// The value of the key 20 found in the node5: value in node2
// The value of the key 30 found in the node5: value in node3
}
Context类型的实际值大体上分为三种,即:根Context值、可撤销的Context值和含数据的Context值。所有的Context值共同构成了一颗上下文树。这棵树的作用域是全局的,而根Context值就是这棵树的根。它是全局唯一的,并且不提供任何额外的功能。
临时对象池sync.Pool
Go 语言标准库中同步工具除了有互斥锁、读写锁、条件变量和原子操作、sync/atomic.Value、sync.Once、sync.WaitGroup、context.Context外,还有一个:sync.Pool。
sync.Pool类型可以被称为临时对象池,它的值可以被用来存储临时的对象。与 Go 语言的很多同步工具一样,sync.Pool类型也属于结构体类型,它的值在被真正使用之后,就不应该再被复制了。
临时对象指不需要持久使用,不影响程序功能的值。可以把临时对象池当作针对某种数据的缓存来用。sync.Pool类型只有两个方法——Put和Get。
Put在当前的池中存放临时对象,它接受一个interface{}类型的参数;Get从当前的池中获取临时对象,它会返回一个interface{}类型的值。可能会从当前的池中删除掉任何一个值,然后把这个值作为结果返回。如果此时当前的池中没有任何值,那么这个方法就会使用当前池的New字段创建一个新值,并直接将其返回。
New字段的实际值需要我们在初始化临时对象池的时候就给定。否则,在我们调用它的Get方法的时候就有可能会得到nil。
标准库代码包fmt就使用到了sync.Pool类型。这个包会创建一个用于缓存某类临时对象的sync.Pool类型值,这类临时对象可以识别、格式化和暂存需要打印的内容。
// 返回一个全新的pp类型值的指针(即临时对象)。
// 保证了ppFree的Get方法总能返回一个可以包含需要打印内容的值。
var ppFree = sync.Pool{
New: func() interface{} { return new(pp) },
}
临时对象池可以帮助程序实现可伸缩性,在需要的时候提供缓存对象,在不需要的时候,及时清理缓存(Go 语言运行时系统中的垃圾回收器,所以在每次开始执行之前,都会对所有已创建的临时对象池中的值进行全面地清除。)
sync包在被初始化的时候,会向 Go 语言运行时系统注册一个函数,功能是清除所有已创建的临时对象池中的值。我们可以把它称为池清理函数。- 在
sync包中还有一个包级私有的全局变量。这个变量代表了当前的程序中使用的所有临时对象池的汇总,它是元素类型为*sync.Pool的切片。我们可以称之为池汇总列表。 - 池清理函数会遍历池汇总列表。对于其中的每一个临时对象池,它都会先将池中所有的私有临时对象和共享临时对象列表都置为
nil,然后再把这个池中的所有本地池列表都销毁掉。
临时对象池存储值数据结构
在临时对象池中,有一个多层的数据结构。
这个数据结构的顶层,称之为本地池列表,是一个数组。数组长度,总是与 Go 语言调度器中的 P 的数量相同。Go 语言调度器中的 P 是 processor 的缩写,它指的是一种可以承载若干个 G、且能够使这些 G 适时地与 M 进行对接,并得到真正运行的中介。
G 正是 goroutine 的缩写,而 M 则是 machine 的缩写,后者指代的是系统级的线程。正因为有了 P 的存在,G 和 M 才能够进行灵活、高效的配对,从而实现强大的并发编程模型。
P 存在的一个很重要的原因是为了分散并发程序的执行压力,而让临时对象池中的本地池列表的长度与 P 的数量相同的主要原因也是分散压力。
在本地池列表中的每个本地池都包含了三个字段(或者说组件),它们是:存储私有临时对象的字段private、代表了共享临时对象列表的字段shared,以及一个sync.Mutex类型的嵌入字段。
每个本地池都对应着一个 P,一个正在运行的 goroutine 必然会关联着某个 P。在程序调用临时对象池的Put方法或Get方法的时候,总会先试图从该临时对象池的本地池列表中,获取与之对应的本地池,依据的就是与当前的 goroutine 关联的那个 P 的 ID。
临时对象池利用内部数据结构存取值
临时对象池的Put方法总会先试图把新的临时对象存储到对应的本地池的private字段中,以便在后面快速地拿到一个可用的值。只有当这个private字段已经存有时,才会去访问本地池的shared字段。
相应的,临时对象池的Get方法,总会先试图从对应的本地池的private字段处获取一个临时对象。只有当这个private字段的值为nil时,它才会去访问本地池的shared字段。
一个本地池的shared字段原则上可以被任何 goroutine 中的代码访问到,不论这个 goroutine 关联的是哪一个 P。这也是我把它叫做共享临时对象列表的原因。相比之下,本地池的private字段,只可能被与之对应的那个 P 所关联的 goroutine 中的代码访问到,所以可以说,它是 P 级私有的。
此外,本地池本身就拥有互斥锁的功能。Put方法会在互斥锁的保护下,把新的临时对象追加到共享临时对象列表的末尾。
相应的,临时对象池的Get方法在发现对应本地池的private字段未存有值时,也会去访问后者的shared字段。它会在互斥锁的保护下,试图把该共享临时对象列表中的最后一个元素值取出并作为结果。
这里的共享临时对象列表也可能是空的,可能是由于临时对象被取走了,也可能是临时对象池刚清理过。这时Get方法就会调用可创建临时对象的函数。这个函数是由临时对象池的New字段代表的,并且需要我们在初始化临时对象池的时候给定。如果这个字段的值是nil,那么Get方法此时也只能返回nil了。
案例:
// bufPool 代表存放数据块缓冲区的临时对象池。
var bufPool sync.Pool
// Buffer 代表了一个简易的数据块缓冲区的接口。
type Buffer interface {
// Delimiter 用于获取数据块之间的定界符。
Delimiter() byte
// Write 用于写一个数据块。
Write(contents string) (err error)
// Read 用于读一个数据块。
Read() (contents string, err error)
// Free 用于释放当前的缓冲区。
Free()
}
// myBuffer 代表了数据块缓冲区一种实现。
type myBuffer struct {
buf bytes.Buffer
delimiter byte
}
func (b *myBuffer) Delimiter() byte {
return b.delimiter
}
func (b *myBuffer) Write(contents string) (err error) {
if _, err = b.buf.WriteString(contents); err != nil {
return
}
return b.buf.WriteByte(b.delimiter)
}
func (b *myBuffer) Read() (contents string, err error) {
return b.buf.ReadString(b.delimiter)
}
func (b *myBuffer) Free() {
bufPool.Put(b)
}
// delimiter 代表预定义的定界符。
var delimiter = byte('\n')
func init() {
bufPool = sync.Pool{
New: func() interface{} {
return &myBuffer{delimiter: delimiter}
},
}
}
// GetBuffer 用于获取一个数据块缓冲区。
func GetBuffer() Buffer {
return bufPool.Get().(Buffer)
}
func main() {
buf := GetBuffer()
defer buf.Free()
buf.Write("A Pool is a set of temporary objects that" +
"may be individually saved and retrieved.")
buf.Write("A Pool is safe for use by multiple goroutines simultaneously.")
buf.Write("A Pool must not be copied after first use.")
fmt.Println("The data blocks in buffer:")
for {
block, err := buf.Read()
if err != nil {
if err == io.EOF {
break
}
panic(fmt.Errorf("unexpected error: %s", err))
}
fmt.Print(block)
}
}
并发安全字典sync.Map
Go 语言自带的字典类型map并不是并发安全的。在同一时间段内,让不同 goroutine 中的代码,对同一个字典进行读写操作是不安全的。字典值本身可能会因这些操作而产生混乱,相关的程序也可能会因此发生不可预知的问题。在sync.Map出现之前,需要自行编写安全的Map结构,使用 sync.Mutex或sync.RWMutex,再加上原生的map就可以轻松地做到。
Go 官方提供的 sync.Map 字典类型提供了一些常用的键值存取操作方法,并保证了这些操作的并发安全。。同时,它的存、取、删等操作都可以基本保证在常数时间内执行完毕。它们的算法复杂度与map类型一样都是O(1)的。
sync.Map本身虽然也用到了锁,但是,它其实在尽可能地避免使用锁<使用锁就意味着要把一些并发的操作强制串行化。这往往会降低程序的性能,尤其是在计算机拥有多个 CPU 核心的情况下。因此,能用原子操作就不要用锁,不过这很有局限性,毕竟原子只能对一些基本的数据类型提供支持。
无论在何种场景下使用
sync.Map,都需要注意,与原生map明显不同,它只是 Go 语言标准库中的一员,而不是语言层面的东西。也正因为这一点,Go 语言的编译器并不会对它的键和值进行特殊的类型检查。它所有的方法涉及的键和值的类型都是interface{},我们必须在程序中自行保证它的键类型和值类型的正确性。
并发安全字典对键的类型要求
键的实际类型不能是函数类型、字典类型和切片类型。这些键值的实际类型只有在程序运行期间才能够确定,所以 Go 语言编译器是无法在编译期对它们进行检查的,不正确的键值实际类型肯定会引发 panic。
可以调用reflect.TypeOf函数得到一个键值对应的反射类型值(即:reflect.Type类型的值),然后再调用这个值的Comparable方法,得到确切的判断结果来保证键的类型是可比较的(或者说可判等的)。
保证并发安全字典中的键和值的类型正确性
方法一:
在编码时就完全确定键和值的类型,然后利用 Go 语言的编译器帮我们做检查。缺点是不够灵活,改变类型只能重新编写一套代码。
type IntStrMap struct {
m sync.Map
}
func (iMap *IntStrMap) Delete(key int) {
iMap.m.Delete(key)
}
func (iMap *IntStrMap) Load(key int) (value string, ok bool) {
v, ok := iMap.m.Load(key)
if v != nil {
value = v.(string)
}
return
}
func (iMap *IntStrMap) LoadOrStore(key int, value string) (actual string, loaded bool) {
a, loaded := iMap.m.LoadOrStore(key, value)
actual = a.(string)
return
}
func (iMap *IntStrMap) Range(f func(key int, value string) bool) {
f1 := func(key, value interface{}) bool {
return f(key.(int), value.(string))
}
iMap.m.Range(f1)
}
func (iMap *IntStrMap) Store(key int, value string) {
iMap.m.Store(key, value)
}
方法二:
封装的结构体类型的所有方法,都可以与sync.Map类型的方法完全一致。
// ConcurrentMap 代表可自定义键类型和值类型的并发安全字典。
type ConcurrentMap struct {
m sync.Map
// 字段keyType和valueType分别用于保存键类型和值类型, 都是反射类型
keyType reflect.Type
valueType reflect.Type
}
func NewConcurrentMap(keyType, valueType reflect.Type) (*ConcurrentMap, error) {
if keyType == nil {
return nil, errors.New("nil key type")
}
if !keyType.Comparable() {
return nil, fmt.Errorf("incomparable key type: %s", keyType)
}
if valueType == nil {
return nil, errors.New("nil value type")
}
cMap := &ConcurrentMap{
keyType: keyType,
valueType: valueType,
}
return cMap, nil
}
func (cMap *ConcurrentMap) Delete(key interface{}) {
if reflect.TypeOf(key) != cMap.keyType {
return
}
cMap.m.Delete(key)
}
func (cMap *ConcurrentMap) Load(key interface{}) (value interface{}, ok bool) {
// 反射类型值之间可以直接使用操作符==或!=进行判等,所以这里的类型检查代码非常简单。
if reflect.TypeOf(key) != cMap.keyType {
return
}
return cMap.m.Load(key)
}
func (cMap *ConcurrentMap) LoadOrStore(key, value interface{}) (actual interface{}, loaded bool) {
if reflect.TypeOf(key) != cMap.keyType {
panic(fmt.Errorf("wrong key type: %v", reflect.TypeOf(key)))
}
if reflect.TypeOf(value) != cMap.valueType {
panic(fmt.Errorf("wrong value type: %v", reflect.TypeOf(value)))
}
actual, loaded = cMap.m.LoadOrStore(key, value)
return
}
func (cMap *ConcurrentMap) Range(f func(key, value interface{}) bool) {
cMap.m.Range(f)
}
func (cMap *ConcurrentMap) Store(key, value interface{}) {
if reflect.TypeOf(key) != cMap.keyType {
panic(fmt.Errorf("wrong key type: %v", reflect.TypeOf(key)))
}
if reflect.TypeOf(value) != cMap.valueType {
panic(fmt.Errorf("wrong value type: %v", reflect.TypeOf(value)))
}
cMap.m.Store(key, value)
}
在第二种方案中,我们无需在程序运行之前就明确键和值的类型,只要在初始化并发安全字典的时候,动态地给定它们就可以了。这里主要需要用到reflect包中的函数和数据类型,外加一些简单的判等操作。但是反射操作或多或少都会降低程序的性能。
并发安全字典尽量避免使用锁
sync.Map类型在内部使用了大量的原子操作来存取键和值,并使用了两个原生的map作为存储介质。其中一个原生map被存在了sync.Map的read字段中,该字段是sync/atomic.Value类型的,该原生 map 负责读取,可简称为可读字典。
sync.Map在替换只读字典的时候根本用不着锁。另外,这个只读字典在存储键值对的时候,先把值转换为了unsafe.Pointer类型的值,然后再把后者封装,并储存在其中的原生字典中。如此一来,在变更某个键所对应的值的时候,就也可以使用原子操作了。
sync.Map中的另一个原生字典由它的dirty字段代表。它存储键值对的方式与read字段中的原生字典一致,它的键类型也是interface{},并且同样是把值先做转换和封装后再进行储存的。暂且把它称为脏字典。
注意,脏字典和只读字典如果都存有同一个键值对,那么这里的两个键指的肯定是同一个基本值,对于两个值来说也是如此。sync.Map在查找指定的键所对应的值的时候,总会先去只读字典中寻找,并不需要锁定互斥锁,在没找到的情况下,才会在锁的保护下去查找脏字典。
相对应的,sync.Map在存储键值对的时候,只要只读字典中已存有这个键,并且该键值对未被标记为“已删除”,就会把新值存到里面并直接返回,这种情况下也不需要用到锁,否则,它才会在锁的保护下把键值对存储到脏字典中。这个时候,该键值对的“已删除”标记会被抹去。
只读字典和脏字典之间是会互相转换的。在脏字典中查找键值对次数足够多的时候,sync.Map会把脏字典直接作为只读字典,保存在它的read字段中,然后把代表脏字典的dirty字段的值置为nil。
在这之后,一旦再有新的键值对存入,它就会依据只读字典去重建脏字典。这个时候,它会在锁的保护下把只读字典中已被逻辑删除的键值对过滤掉。
综上所述,sync.Map的只读字典和脏字典中的键值对集合并不是实时同步的,它们在某些时间段内可能会有不同。
本文深入探讨Go语言中的并发编程概念,包括sync包的使用、原子操作、条件变量、临时对象池、并发安全字典等高级同步工具,以及context.Context类型在多goroutine协作流程中的应用。
668

被折叠的 条评论
为什么被折叠?



