同步的两个用途:
- 避免多个线程在同一时刻操作同一个数据块
- 协调多个线程,以避免它们在同一时刻执行同一个代码块
施加保护的重要手段之一,就是使用实现了某种同步机制的工具,也称为同步工具。
在 Go 语言中,可供我们选择的同步工具并不少
本章节中主要介绍锁、信号量同步工具。
sync.Mutex&&sync.RWMutex
其中,最重要且最常用的同步工具当属互斥量(mutual exclusion,简称 mutex)
使用互斥锁的注意事项:
- 不要重复锁定互斥锁;
- 不要忘记解锁互斥锁,必要时使用defer语句;
- 不要对尚未锁定或者已解锁的互斥锁解锁;
- 不要在多个函数之间直接传递互斥锁。
如果一个流程在锁定了某个互斥锁之后分叉了,或者有被中断的可能,那么就应该使用defer语句来对它进行解锁,而且这样的defer语句应该紧跟在锁定操作之后。这是最保险的一种做法。
我们总是应该保证,对于每一个锁定操作,都要有且只有一个对应的解锁操作,否则会导致panic。
注意:
- 不要重复锁定或忘记解锁,因为这会造成 goroutine 不必要的阻塞,甚至导致程序的死锁。
- 不要传递互斥锁,因为这会产生它的副本,从而引起歧义并可能导致互斥操作的失效。
读写锁
它是互斥锁的一种扩展一个读写锁中同时包含了读锁和写锁,由此也可以看出它对于针对共享资源的读操作和写操作是区别对待的。我们可以基于这件事,对共享资源实施更加细致的访问控制。
读锁:可以同时进行多个协程读操作,不允许写操作
写锁:只允许同时有一个协程进行写操作,不允许其他写操作和读操作
方法:
- RLock:获取读锁
- RUnLock:释放读锁
- Lock:获取写锁
- UnLock:释放写锁
sync.Cond
条件变量与互斥锁
条件变量是基于互斥锁的,它必须有互斥锁的支撑才能发挥作用。
条件变量并不是被用来保护临界区和共享资源的(锁干的事情)
它是用于协调想要访问共享资源的那些线程(条件变量)
条件变量的初始化离不开互斥锁,并且它的方法有的也是基于互斥锁的。
条件变量有三个方法:
- 等待通知 wait
- 单发通知 signal
- 广播通知 broadcast
注意:
- 我们在利用条件变量等待通知的时候,需要在它基于的那个互斥锁保护下进行。
- 而在进行单发通知或广播通知的时候,却是恰恰相反的,也就是说,需要在对应的互斥锁解锁之后再做这两种操作。
func main() {
var mailbox uint8//0,1代表是否有信息
var lock sync.RWMutex//信封上的读写锁
sendCond := sync.NewCond(&lock)//基于读写锁使用sync.NewCond初始化
recvCond := sync.NewCond(lock.RLocker())
var wg sync.WaitGroup
wg.Add(2)
go func(wg *sync.WaitGroup) {
lock.Lock()
for mailbox == 1 {
fmt.Println("send waiting")
sendCond.Wait()
}
fmt.Println("send success")
mailbox = 1
lock.Unlock()
recvCond.Signal()
wg.Done()
}(&wg)
go func(wg *sync.WaitGroup) {
lock.RLock()
for mailbox == 0 {
fmt.Println("receive waiting")
recvCond.Wait()
}
fmt.Println("receive success")
mailbox = 0
lock.RUnlock()
sendCond.Signal()
wg.Done()
}(&wg)
wg.Wait()
}
注意:
- sync.Cond 不是开箱即用的。只能利用sync.NewCond函数创建它的指针值。这个函数需要一个sync.Locker类型的参数值,其中sync.Locker是一个接口类型。声明中含两个方法定义即Lock()和UnLock()
- sync.Mutex类型和sync.RWMutex类型都拥有Lock方法和Unlock方法,是指针方法,这两个类型的指针类型才是接口的实现类型。
- 条件变量是基于互斥锁的,它必须有互斥锁的支撑才能够起作用。它会参与到条件变量的方法实现当中。
- 示例中&lock 变量的Lock Unlock 方法对应写动作的锁定和解锁
- lock.RLocker() 所拥有的Lock() Unlock 内部调用了RLock RUnlock方法。
条件变量wait所做的三件事
- 把调用它的 goroutine(也就是当前的 goroutine)加入到当前条件变量的通知队列中。
- 解锁当前的条件变量基于的那个互斥锁。
- 让当前的 goroutine 处于等待状态,等到通知到来时再决定是否唤醒它。此时,这个 goroutine 就会阻塞在调用这个Wait方法的那行代码上。
- 如果通知到来并且决定唤醒这个 goroutine,那么就在唤醒它之后重新锁定当前条件变量基于的互斥锁。自此之后,当前的 goroutine 就会继续执行后面的代码了。
for语句却可以做多次检查,直到这个状态改变为止
如果一个 goroutine 因收到通知而被唤醒,但却发现共享资源的状态,依然不符合它的要求,那么就应该再次调用条件变量的Wait方法,并继续等待下次通知的到来。不用if因为它不能重复地执行“检查状态 - 等待通知 - 被唤醒”的这个流程
Signal方法和Broadcast方法
signal 只会唤醒一个因此而等待的 goroutine ,被唤醒的 goroutine 一般都是最早等待的那一个。
broadcast通知却会唤醒所有为此等待的 goroutine。
这两个方法并不需要受到互斥锁的保护,我们也最好不要在解锁互斥锁之前调用它们。
条件变量的通知具有即时性。当通知被发送的时候,如果没有任何 goroutine 需要被唤醒,那么该通知就会立即失效。
总结
互斥锁是一个很有用的同步工具,它可以保证每一时刻进入临界区的 goroutine 只有一个。读写锁对共享资源的写操作和读操作则区别看待,并消除了读操作之间的互斥。
条件变量主要是用于协调想要访问共享资源的那些线程。当共享资源的状态发生变化时,它可以被用来通知被互斥锁阻塞的线程,它既可以基于互斥锁,也可以基于读写锁。当然了,读写锁也是一种互斥锁,前者是对后者的扩展。
sync.Map
Go 语言的原生字典的键类型不能是函数类型、字典类型和切片类型。由于并发安全字典内部使用的存储介质正是原生字典,又因为它使用的原生字典键类型也是可以包罗万象的interface{};所以,我们绝对不能带着任何实际类型为函数类型、字典类型或切片类型的键值去操作并发安全字典。
接口如下
type mapInterface interface {
Load(interface{}) (interface{}, bool)
Store(key, value interface{})
LoadOrStore(key, value interface{}) (actual interface{}, loaded bool)
Delete(interface{})
Range(func(key, value interface{}) (shouldContinue bool))
}
可以通过调用reflect.TypeOf函数得到一个键值对应的反射类型值
让并发安全字典只能存储某个特定类型的键 这种方案缺少灵活性
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)
}
func main() {
var myMap IntStrMap
myMap.Store(1, "s1")
myMap.Store(2, "s2")
myMap.Delete(1)
myMap.LoadOrStore(2, "s22")
myMap.Store(3, "s3")
myMap.Range(func(key int, value string) bool {
println(key, value)
return true
} )
}
封装的结构体类型的所有方法,都可以与sync.Map类型的方法完全一致(包括方法名称和方法签名)
type ConcurrentMap struct {
m sync.Map
keyType reflect.Type
valueType reflect.Type
}
func (c *ConcurrentMap) Load(key interface{}) (value interface{}, ok bool) {
if reflect.TypeOf(key) != c.keyType {
return
}
return c.m.Load(key)
}
func (c *ConcurrentMap) Store(key, value interface{}) {
if reflect.TypeOf(key) != c.keyType {
panic(fmt.Errorf("wrong key type: %v", reflect.TypeOf(key)))
}
if reflect.TypeOf(value) != c.valueType {
panic(fmt.Errorf("wrong value type: %v", reflect.TypeOf(key)))
}
c.m.Store(key, value)
}
func (c *ConcurrentMap) LoadOrStore(key, value interface{}) (actual interface{}, loaded bool) {
if reflect.TypeOf(key) != c.keyType {
panic(fmt.Errorf("wrong key type: %v", reflect.TypeOf(key)))
}
if reflect.TypeOf(value) != c.valueType {
panic(fmt.Errorf("wrong value type: %v", reflect.TypeOf(key)))
}
return c.m.LoadOrStore(key, value)
}
func (c *ConcurrentMap) Delete(key interface{}) {
if reflect.TypeOf(key) != c.keyType {
return
}
c.m.Delete(key)
}
func (c *ConcurrentMap) Range(f func(key, value interface{}) (shouldContinue bool)) {
f1 := func(key, value interface{}) bool {
if reflect.TypeOf(key) != c.keyType {
return false
}
if reflect.TypeOf(value) != c.valueType {
return false
}
return f(key, value)
}
c.m.Range(f1)
}
func main() {
myMap := ConcurrentMap{
m: sync.Map{},
keyType: reflect.TypeOf(1),
valueType: reflect.TypeOf(""),
}
myMap.Store(1, "s1")
myMap.Store(2, "s2")
myMap.Delete(1)
myMap.LoadOrStore(2, "s22")
myMap.Store(3, "s3")
myMap.Range(func(key interface{}, value interface{}) bool {
println(key.(int), value.(string))
return true
})
}
sync.Map实现原理
- sync.Map类型在内部使用了大量的原子操作来存取键和值
- 使用了两个原生的map作为存储介质。
只读字典:一个原生map被存在了sync.Map的read字段中,该字段是sync/atomic.Value类型的,这个原生字典可以被看作一个快照,它总会在条件满足时,去重新保存所属的sync.Map值中包含的所有键值对。
只读字典虽然不会增减其中的键,但却允许变更其中的键所对应的值。所以,它并不是传统意义上的快照,它的只读特性只是对于其中键的集合而言的。
由read字段的类型可知,sync.Map在替换只读字典的时候根本用不着锁。另外,这个只读字典在存储键值对的时候,还在值之上封装了一层。先把值转换为了unsafe.Pointer类型的值,然后再把后者封装,并储存在其中的原生字典中。如此一来,在变更某个键所对应的值的时候,就也可以使用原子操作了。
脏字典:sync.Map中的另一个原生字典由它的dirty字段代表。 它存储键值对的方式与read字段中的原生字典一致,它的键类型也是interface{},并且同样是把值先做转换和封装后再进行储存的。
注意,脏字典和只读字典如果都存有同一个键值对,那么这里的两个键指的肯定是同一个基本值,对于两个值来说也是如此。这两个字典在存储键和值的时候都只会存入它们的某个指针,而不是基本值。
- sync.Map在查找指定的键所对应的值的时候,总会先去只读字典中寻找,并不需要锁定互斥锁。只有当确定“只读字典中没有,但脏字典中可能会有这个键”的时候,它才会在锁的保护下去访问脏字典。
- 相对应的,sync.Map在存储键值对的时候,只要只读字典中已存有这个键,并且该键值对未被标记为“已删除”,就会把新值存到里面并直接返回,这种情况下也不需要用到锁。
- 否则,它才会在锁的保护下把键值对存储到脏字典中。这个时候,该键值对的“已删除”标记会被抹去。
小结
- read 只读字典(原子值)键值对可以变更,但是不能增减;只需要原子操作,无需动用锁
- dirty 脏字典 键值对既可变更也能够增减,需要使用锁来进行保护
当一个键值对应该被删除但是仍然出现在只读字典中的时候,才会被用已删除标记,这种方式是逻辑删除而非物理删除。这种情况可以在查询和便利键值对的时候,已经被逻辑删除的键值对会被无视。
当删除键值对的时候 sync.Map先检查只读字典是否有对应的键,没有的话脏字典中可能有,那么就可以在锁的保护下去执行删除,最后sync.Map会把键值对中指向值的那个指针执为nil,这是另一种逻辑删除的方式。
除此之外,还有一个细节需要注意,只读字典和脏字典之间是会互相转换的。在脏字典中查找键值对次数足够多的时候,sync.Map会把脏字典直接作为只读字典,保存在它的read字段中,然后把代表脏字典的dirty字段的值置为nil。在这之后,一旦再有新的键值对存入,它就会依据只读字典去重建脏字典。这个时候,它会把只读字典中已被逻辑删除的键值对过滤掉。理所当然,这些转换操作肯定都需要在锁的保护下进行。
总结
- sync.Map的只读字典和脏字典中的键值对集合,并不是实时同步的,它们在某些时间段内可能会有不同。
- 由于只读字典中键的集合不能被改变,所以其中的键值对有时候可能是不全的。相反,脏字典中的键值对集合总是完全的,并且其中不会包含已被逻辑删除的键值对。
- 在读操作有很多但写操作却很少的情况下,并发安全字典的性能往往会更好,在几个写操作当中,新增键值对的操作对并发安全字典的性能影响是最大的,其次是删除操作,最后才是修改操作。
原子操作
- 条件变量主要是用于协调想要访问共享资源的那些线程(作用于线程)
- 互斥锁保证每一时刻进入临界区的 goroutine 只有一个(作用于临界区)
在同一时刻,只可能有少数的 goroutine 真正地处于运行状态,并且这个数量只会与 M 的数量一致,而不会随着 G 的增多而增长。(GMP模型了解一下)
cpu负责协调换上-goroutine 由非运行态转变为运行态代码在某个cpu上执行 换下-使一个goroutine中的代码中断执行,由运行态转变为非运行态。
中断点可以再任何语句执行的间隙,甚至是莫挑语句执行的过程中
在临界区也是一样的,所以互斥锁只能保证代码的串行执行,不能保证原子性
真正能够保证原子性的是原子操作atomic operation,原子操作不能中断的特性是由底层CPU提供芯片级别的支持,所以绝对有效。
优点:原子操作能够试下解除竞态的问题,能够保证并发安全,执行的速度更快(数量级)
缺点:由于原子操作不能中断所以需要简单并且要求快速,操作系统层面只对针对二进制位或整数的原子操作提供了支持。
Go 语言的原子操作当然是基于 CPU 和操作系统的,所以它也只针对少数数据类型的值提供了原子操作函数。这些函数都存在于标准库代码包sync/atomic中。
sync/atomic 原子操:
加法(add)
比较并交换(compare and swap,简称 CAS)
加载(load)
存储(store)
交换(swap)
这些函数针对的数据类型并不多。但是,对这些类型中的每一个,sync/atomic包都会有一套函数给予支持。这些数据类型有:int32、int64、uint32、uint64、uintptr,以及unsafe包中的Pointer。不过,针对unsafe.Pointer类型,该包并未提供进行原子加法操作的函数。
此外,sync/atomic包还提供了一个名为Value的类型,它可以被用来存储任意类型的值。
简易自旋锁spinlock
for语句加 CAS 操作的假设往往是:共享资源状态的改变并不频繁,或者,它的状态总会变成期望的那样。这是一种更加乐观,或者说更加宽松的做法。
func main() {
var num int32 = 9
go func(num *int32) {
time.Sleep(time.Second)
*num -= 1
fmt.Println(*num)
}(&num)
go func(num *int32) {
*num += 2
fmt.Println(*num)
}(&num)
for {
if atomic.CompareAndSwapInt32(&num, 10, 0) {
fmt.Println("The second number has gone to zero.")
break
}
time.Sleep(time.Millisecond * 500)
}
}
sync/atomic.Value
此类型的值相当于一个容器,可以被用来“原子地”存储和加载任意的值。
它只有两个指针方法:Store和Load。
规则:
第一条规则,不能用原子值存储nil。
第二条规则,我们向原子值存储的第一个值,决定了它今后能且只能存储哪一个类型的值。
sync.Value 存储用类型可能带来的问题
func main() {
var store atomic.Value
arr := []int{1,2,3}
store.Store(arr)
arr[0] = 4 //这种操作不是并发安全的
fmt.Println(store.Load())
}
func main() {
var store atomic.Value
arr := []int{1, 2, 3}
f := func(v []int) {
replica := make([]int, len(v))
copy(replica, v)
store.Store(replica)
}
f(arr)
arr[0] = 4 //没有关系因为使用的是复制的数组
}
相对于原子操作函数,原子值类型的优势很明显,但它的使用规则也更多一些。
首先,在首次真正使用后,原子值就不应该再被复制了
原子值的Store方法对其参数值(也就是被存储值)有两个强制的约束。
一个约束是,参数值不能为nil。
另一个约束是,参数值的类型不能与首个被存储值的类型不同。
不要对外暴露原子变量
不要传递原子值及其指针值
尽量不要在原子值中存储引用类型的值
sync.once
源码可以说是很简单了
func (o *Once) Do(f func()) {
if atomic.LoadUint32(&o.done) == 0 {
// Outlined slow-path to allow inlining of the fast-path.
o.doSlow(f)
}
}
func (o *Once) doSlow(f func()) {
o.m.Lock()
defer o.m.Unlock()
if o.done == 0 {
defer atomic.StoreUint32(&o.done, 1)
f()
}
}
执行效果
func main() {
o := &sync.Once{}
go do(o)
go do(o)
time.Sleep(1)
}
func do(o *sync.Once){
fmt.Println("Start do")
o.Do(func() {
fmt.Println("Doing something")
})
fmt.Println("Do end")
}
输出结果
Start do
Doing something
Start do
Do end
Do end
Context上下文
context接口
- Deadline方法是获取设置的截止时间的意思,第一个返回式是截止时间,到了这个时间点,Context会自动发起取消请求;第二 个返回值ok==false时表示没有设置截止时间,如果需要取消的话,需要调用取消函数进行取消。
- Done方法返回一个只读的chan,类型为struct{},我们在goroutine中,如果该方法返回的chan可以读取,则意味着parent context已经发起了取消请求,我们通过Done方法收到这个信号后,就应该做清理操作,然后退出goroutine,释放资源。之后,Err 方法会返回一个错误,告知为什么 Context 被取消。
- Err方法返回取消的错误原因,因为什么Context被取消。
- Value方法获取该Context上绑定的值,是一个键值对,所以要通过一个Key才可以获取对应的值,这个值一般是线程安全的。
type Context interface {
Deadline() (deadline time.Time, ok bool)
Done() <-chan struct{}
Err() error
Value(key interface{}) interface{}
}
wg实现
func coordinateWithWaitGroup() {
total := 100
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 += stride {
wg.Add(stride)
for j := 0; j < stride; j++ {
go addNum(&num, i, j, wg.Done)
}
wg.Wait()
}
fmt.Println("End.")
}
func addNum(num *int32, base, number int, done func()) {
var mutex sync.Mutex
mutex.Lock()
*num += int32(1)
fmt.Printf("The %d number goroutine exec, sum is %d\n", number, *num)
done()
mutex.Unlock()
}
func main() {
coordinateWithWaitGroup()
}
context实现
func addNum(num *int32, base, number int, done func()) {
var mutex sync.Mutex
mutex.Lock()
*num += int32(1)
fmt.Printf("The %d number goroutine exec, sum is %d\n", number, *num)
done()
mutex.Unlock()
}
func coordinateWithContext() {
total := 12
var num int32
fmt.Printf("The number: %d [with context.Context]\n", num)
cxt, cancel := context.WithCancel(context.TODO())
for i := 1; i <= total; i++ {
go addNum(&num, 0, i, func() {
if atomic.LoadInt32(&num) == int32(total) {
cancel()
}
})
}
<-cxt.Done()
fmt.Println("End.")
}
func main() {
coordinateWithContext()
}