并发进阶
并发进阶
本文为极客时间《Go语言核心36讲》的学习笔记,梳理了相关的知识点。
通常情况下,我们是肯定不会直接起一个协程开始写逻辑代码的。Go语言已经帮我们写好了各种常用的并发工具,基本都在**sync(同步)**包中,可以开箱即用。
//正常情况下,是不会这样子直接起一个协程做并发业务的。
go func(){
....
}
sync包里很多功能都是基于atomic来实现的。需要引申下Go的原子操作,以及底层实现原理,这里不做展开,内容不多,但需要使用到硬件相关的知识。
Mutex和RWMutex
互斥锁和读写互斥锁是并发操作中最经常使用的,其底层的实现是基于atomic来实现的。并发场景保证数据一致性最经典的思路就是加锁,但加锁必定会导致程序性能的下降,使并行变成串行。因此,还有一种思维叫做无锁并发。
引申,乐观锁与悲观锁是什么,会用在哪些场景中。
Mutex
互斥锁就像一个令牌,只有持有这个令牌的协程才可以操作具体的资源,其他协程只能等待,直到拿到这个令牌为止。举个例子:
mu.Lock() //加锁
_, err := writer.Write([]byte(data)) //操作某一块资源
if err != nil {
log.Printf("error: %s [%d]", err, id)
}
mu.Unlock() //解锁
使用互斥锁的注意事项:
- 不要重复锁定互斥锁;
Mutex是不可重入锁,如果尝试对一个已经加锁的Mutex再次加锁,可能会导致协程陷入死锁。
fatal error: all goroutines are asleep - deadlock!
在go语言中出现死锁会有这个报错。注意,这个报错的等级非常高,是系统层级的错误,无法被Recover回收,只要报出来,程序必然会崩溃。
引申,Map的并发操作触发的Panic能否被回收?
- **不要忘记解锁互斥锁,**必要时使用defer语句;
正常情况下,**mu.Lock()和mu.Unlock()**一定是成对出现的,并且一定会使用defer语句。在具体写代码时有两个要求:
- 严禁将一个锁用在多个资源上,一个资源加一把锁。这样不会出现冲突或者重复操作,但是会引入死锁的情况。
- 谁持有谁负责。加锁和解锁操作最好放在一个函数中,先加锁后解锁,避免因业务逻辑导致的死锁。
mu.Lock() //立马加锁,如果没有必要不要写业务逻辑。
defer mu.Unlock() //使用defer语句,延迟掉解锁操作
//将业务逻辑都写在上述操作后面
//这样子,任何逻辑下都不会有问题,依赖的是defer机制的保证。
//这也是各大公司里明文要求的写法。
- 不要对尚未锁定或者已解锁的互斥锁解锁;
注意!解锁未锁定的互斥锁会立即引发panic,并且是一个严重的错误,无法被恢复,会导致程序直接崩溃。
- 不要在多个函数之间直接传递互斥锁。
和我们之前讲过的情况一样。Mutex本质是一个结构体,和其他任何结构体一样,都存在传值还是指针的问题。因此,为了避免混淆,在开发中不要再多个函数中间传递Mutex,从源头上消灭这类问题。
RWMutex
Mutex有一个问题,锁的粒度非常大,持有锁的人可以随便操作,没有锁的人就只能干等着。但实际业务中容易出现数据不一致的只有写操作,RWMutex再此基础上做了一点优化。 读写互斥锁本质上和互斥锁没有区别,只是贯彻了再加一层的思想,将互斥操作拆分为读操作和写操作。
引申,在大多数互联网场景中都是读操作要远远大于写操作。
我们直接抛出使用时的注意事项:
- 多个写操作不能同时进行,写操作和读操作也不能同时进行,但多个读操作却可以同时进行。
- 对写锁进行解锁,会唤醒所有因试图锁定读锁,而被阻塞的goroutine。
- 对读锁进行解锁,只会在没有其他读锁锁定的前提下,唤醒“因试图锁定写锁,而被阻塞的 goroutine。
日常中真正用的多的是RWMutex。
Cond
这个组件很少很少用,正常情况下可以用Channel来平替,甚至有人讨论过,要不要去掉这个组件。它常见的场景是实现观察者模式
这个组件的名字叫条件变量,其实干的就是信号通知的事情。 Cond的实现一定是依赖于互斥锁的。他只有三个方法:
- wait让某个调用者进入Cond的等待队列里并阻塞,也就是进入了等待状态。
- signal让调用者唤醒一个等待Cond的协程。
- broadcast唤醒全部协程。
我们这里不再展开Cond的具体用法,用的非常非常少,只有个别开源项目中有使用到。理论上,我们需要信号通知的场景完全可以使用Channel进行平替,Cond在此基础上再一次贯彻了加一层的思想,使程序不在依赖于具体的Channel。我们看下他们的区别:
- Cond底层是依赖于Locker的,可以基于这一点做一些调整。Channel不具备这一点。
- Cond可以同时支持signal和broadcast,但是Channel只能同时支持一种,除非起多个。
- Cond可以重复使用。Channel一但关闭了,就不能再使用了。
WaitGroup
这是日常开发中真正常用并发安全的组件。开箱即用,完全信赖。
WaitGroup主要用来对多个协程进行编排,让主协程可以等待所有子协程全部执行完毕后,再继续执行。它只有三个方法:Add,Done,Wait。我们直接看代码:
wg := sync.WaitGroup{} //起一个WaitGroup
wg.Add(1) //计数器+1,注意这个方法必须在协程外执行,不能放到协程内。
go func() {
fmt.Printf("这是协程1!\n")
wg.Done() //协程执行完成,计数器-1,这个方法必须在协程内部使用。
}()
wg.Add(1) //同上
go func() {
fmt.Printf("这是协程2!\n")
wg.Done() //同上
}()
wg.Wait() //主协程阻塞,直到计数器归零,说明子协程已经全部执行完成。
time.Sleep(2 * time.Second)
fmt.Printf("这是主协程!\n")
注意,如果WaitGroup的计数器出现负值,会直接报出Panic,在使用中需要格外小心。就像上面的代码,通常我们调用Add,和Wait需要在同一个函数或者说,在同一个协程中。
可以看下sync源码包里waitgroup_test.go文件,其中的名称以TestWaitGroupMisuse为前缀的测试函数,很好地展示了这些异常情况的发生条件。
Once
功能单一,用的比较少,只有特殊场景才会使用。但是,越简单的东西,越实用。Once最常见的使用场景实现一个单例模式。
Once也是基于互斥锁实现的,在使用过程中也要注意传递问题。它只有一个方法Do,可以确保传入的方法仅仅只执行一次。注意事项:
- 不要让Once执行一些耗时非常长或者有可能出现阻塞的方法,避免其他调用这个Once的协程被阻塞。
- Once不保证函数执行成功。无论函数是正常退出还是出现了Error或者Panic,都只会执行一次。
Pool
Pool在日常开发中极少使用,它提供的是一个临时对象池,存在安全隐患。我们常用的池化技术和它在思想上相同,在实现和使用上完全不一样,这点需要特别注意。
Pool只有三个方法:
- New 用来创建一个新的元素。一般情况下,需要我们自己来实现一个方法创建一个元素。
- Get 用来获取一个元素。当取走这个元素时,Pool中会移除这个它。如果Pool里面没有元素了,就会调用New创建一个。这时候如果没有设置New,就会返回一个Nil。
- Put 向Pool中塞入一个元素,Pool会把他保存下来。如果塞入了一个nil,会直接忽略。
它的具体实现非常复杂,和GMP调度模型有一些关联,我们这里就不展开了。 Pool存在内存泄露和内存浪费的问题,另外它有被清理的可能。日常开发中的池化技术通常会使用第三方包来实现。
问题引申,我们常用的TCP连接池是怎样实现的。
Map
sync.Map要区别于Map。Go语言本身的Map是不具备并发安全的,这个我们之前写过,在广大开发者千呼万唤中,GO开发团队在1.9版本中发布了能够实现并发安全的sync.Map。 sync.Map的底层还是原生的Map,所以在键值的选择上依然不用函数类型、字典类型和切片类型。另外,如果让我们自己实现一个并发安全的Map,一定下意识的使用上面说的读写互斥锁。这个思路没有错,但只要加锁,必然降低效率。 sync.Map也有使用互斥锁,但核心操作全部都是原子操作。好处是性能较高,缺点是使用场景较少。按照官方的描述:
- 只会增长的缓存系统中,一个 key 只写入一次而被读很多次。
- 多个 goroutine 为不相交的键集读、写和重写键值对。
总而言之,Go自带的Map是不支持并发安全的,sync.Map有使用场景的限制,加锁又会显著的降低性能。在具体开发过程中,最好不要再并发中使用Map,如果一定要用,需要仔细分析当前的业务场景,选择合适的方案。
总结
我们梳理了一些常见的并发同步工具,有些常用,有些特定情况下有用。Go语言的并发确实是一绝,但是日常开发中能不用就不用,能少用就少用。任何简单的场景,只要加入并发与异步都会变得相对复杂。
实事求是,量力而行。
引申阅读: https://draveness.me/golang/docs/part3-runtime/ch06-concurrency/golang-sync-primitives/