在go中sync负责提供同步原语如互斥锁等。任何属于该包类型的对象都不应该被复制(只能passed by pointer)
sync.Mutex
sync.Mutex是最常用的同步原语。其作用在于对共享资源的互斥访问。
常用的使用范式
mutex := &sync.Mutex{}
mutex.Lock()
// Update shared variable (e.g. slice, pointer on a structure, etc.)
mutex.Unlock()
使用Lock有什么注意事项?
当调用Lock的时候,如果mutex已经被上锁,那么调用Lock的goroutine会阻塞到mutex被解锁为止。
而且mutex是不可重入锁,意味着即便是同一个goroutine,如果多次对同一把锁Lock也会触发死锁
func decThenPlus(mutex *sync.Mutex,i int){
mutex.Lock()
defer mutex.Unlock()
fmt.Println(i)
plus(mutex,i-1)//死锁!
}
func plus(mutex *sync.Mutex,i int){
mutex.Lock()
defer mutex.Unlock()
i++
fmt.Println(i)
}
func main() {
mutex:=new(sync.Mutex)
decThenPlus(mutex,100)
}
/**
输出结果
100
fatal error: all goroutines are asleep - deadlock!
**/
使用Unlock有什么注意事项?
一般情况下,应该确保Lock和Unlock成双出现,必要使用可以使用defer mutex.Unlock()
调用Unlock的时候,如果mutex没有上锁,那么会触发运行时错误(panic)。
Lock和Unlock是可以在两个线程中分别调用的。
除此之外,实践上,应该确保“一个锁对应一个资源”,如果一个锁对应多个资源,那么会平白提高锁争用的概率。
sync.RWMutex
有些场景属于读远多于写的场景,这个时候使用mutex会大大降低性能。对此我们可以使用读写锁sync.RWMutex
。读写锁与互斥锁sync.Mutex
不同的地方在于,对于读操作可以共享,只有对写操作才需要互斥访问。
sync.RWMutex
同样实现Lock接口。可以用Lock
和Unlock
来实现写锁,用RLock()
和RUnlock
来实现读锁。
读锁和写锁的交互有一些值得注意的细节
当有写锁请求在等待读锁的释放,新的读锁请求会被允许吗?
不会,新的读锁请求会等到读锁释放后才能(竞争)获取。这样做是为了避免一直不断出现的读锁请求导致写锁一直阻塞(饥饿)
当已有读锁/写锁占有,读锁/写锁请求会如何?
只有在读锁占有的时候读锁请求不会被阻塞。其他的组合都会被阻塞。
读锁支持可重入读锁吗?
不支持,事实上golang在整体上认为可重入锁意味着结构有问题,所以不支持
多个goroutine在阻塞等待锁的时候,锁释放后唤醒的goroutine规则是什么?
按照等待时间来算,所以是公平的。
sync.Waitgroup
WaitGroup和其他一样都是零值即可用的工具,其作用在于同步多个线程的工作流。比方说N个goroutine在执行,这时候为了避免主goroutine完成main函数导致程序关闭,就可以用wg.Wait()
来要求主goroutine等到其他goroutine的完成
代码分析:为什么这里结果和想象中不一样?
var mutex sync.Mutex
var sum=0
var wg sync.WaitGroup
for i:=0;i<100000;i++{
go func(){
wg.Add(1)
defer wg.Done()
mutex.Lock()
sum++
mutex.Unlock()
}()
}
wg.Wait()
fmt.Println(sum)
/**
输出结果
0
**/
这里看起来好像我们也实现了锁,也用了waitgroup来等待,但是结果却是0。其原因在于,wg.Wait()
在内部计数器为0的时候就解除阻塞,而我们知道golang中的go是异步执行的。这里主goroutine快速启动了100000个goroutine后还没等goroutine运作就执行wg.Wait()
,此时计数器wg.Add(1)
还没有执行,导致输出0。
因此一个教训是要提前执行Add
,比方说预期开启N个goroutine,那么可以在循环之前执行wg.Add(N)
。或者说在启动goroutine之前先调用wg.Add(1)
,由于该指令是在主goroutine内,因此可以确保得到执行。
wg.Add(delta)中delta可以是负数吗?有什么作用?
可以是负数,但是如果导致内部计数器变为负数,那么Add操作触发panic。
sync.Map
sync.Pool
sync.Once
sync.Once可以用于确保某个函数被调用且被调用一次。只有第一次调用Do时传入的函数会被执行,其他调用Do的goroutine会阻塞等到Do执行完成函数。如果有多个函数需要确保执行一次,那么就需要用多个sync.Once
由于在函数执行完成之前没有Do函数会返回,所以如果在函数内调用Do会造成死锁。
即便函数执行过程中触发panic,Do也认为已经执行完成,之后对Do的调用都会立刻返回(而不执行函数)
sync.Once常常用于要求必须执行且只能执行一次的初始化,由于传入函数没有传入参数,所以一般用闭包来传参。
值得注意的是,在Do函数中使用了经典的双重检查来提高性能
sync.Cond
sync.Cond是用来协调goroutine之间工作的,它需要与Mutex相配合。因为Wait内部会调用Unlock,所以如果在调用Wait之前上锁,那么会panic
它应用于函数要求资源符合某些条件的时候,一个最常见的例子,是生产者消费者模型中,生产者函数要求队列未满,消费者函数要求队列不空。由于涉及到多goroutine,所以需要用mutex保护资源。
假设我们不使用Cond和channel的话我们会怎么解决问题呢?我们会先拿着锁Lock
,检查资源状态,如果不符合条件,则放弃锁,然后继续轮询检查直到符合条件为止。
而sync.Cond允许我们持有锁,检查资源发现不符合条件之后,调用Wait函数放弃锁并阻塞本goroutine,直到有goroutine调用Signal
或者Notify
通知可能符合条件。
那生产者消费者模型举例,生产者持有锁检查发现队列已满,然后可以调用Wait陷入沉睡。如果有消费者消费了队列,则消费者可以调用Signal
通知,该函数会唤醒一个因为Wait
而阻塞的goroutine,当然消费者也可以使用Broadcast
唤醒所有在等待的线程。
值得注意的是,在调用Signal
和Broadcast
的时候goroutine可以持有锁(未解锁),也可以不持有锁。但是从观察实现上,一般来说都是在持有锁的时候调用Signal和Broadcast。
一般来说,对于同一个资源的不同条件应该分别处理,比方说上面生产者消费者模型中应该同时有isFull和isEmpty条件,这样可以更精确的通知。如果不同的goroutine基于不同的条件却使用同一个sync.Cond(比方说生产者消费者模型中只使用用一个sync.Cond),那么要求使用Broadcast
避免该通知的goroutine没有通知到。
在使用Wait的时候需要用for包裹条件,因为当wait恢复的时候c.L没有立刻上锁,所以不能保证还能符合条件
c.L.Lock()
for !condition() {
c.Wait()
}
... make use of condition ...
c.L.Unlock()