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
的只读字典和脏字典中的键值对集合并不是实时同步的,它们在某些时间段内可能会有不同。