[TOC]
go的强大并发
在go语言中,go routine是并发的基本单位,是操作系统中提到的用户级线程,轻量线程,它的执行切换不会触发操作系统内核态的转换。channel,mutex,是go中进行协程间 协调,通信,以及控制竞争访问 的重要机制,程序间的通信一般就两种:共享内存和消息传递
Share memory by communicating, don’t communicate by sharing memory.
通过通信共享内存,而不是通过共享内存而通信。
线程,线程的阅读
锁和信号量的阅读
神奇的channel
channel可以写入,可以从中读出,并且是并发安全的,即多个routine同时读写是不会出现问题的。相当于用于同步和通信的有锁队列
CSP 是 Communicating Sequential Process 的简称,中文可以叫做通信顺序进程,是一种并发编程模型,由 Tony Hoare 于 1977 年提出。简单来说,CSP 模型由并发执行的实体(线程或者进程)所组成,实体之间通过发送消息进行通信,这里发送消息时使用的就是通道,或者叫 channel。CSP 模型的关键是关注 channel,而不关注发送消息的实体。Go 语言实现了 CSP 部分理论,goroutine 对应 CSP 中并发执行的实体,channel 也就对应着 CSP 中的 channel。
- 无缓存的channel
- 有缓存的channel
无缓存的channel的读写如果没有对应的反操作被执行,会被阻塞。
Notice
不可以理解为是size为1的有缓冲channel,因为1. size 为1 且buf没满时候的发送或接收操作,若没有反操作,不会阻塞 2. 可见性规则。
那是否可以理解为size=0,因为size为0。 它的操作,若没有反操作,会阻塞。
根据 《可见性规范描述》第四条:The kth receive on a channel with capacity C happens before the k+Cth send from that channel completes. 则当buffered的size为0, 第k条接收操作先于第k条发送操作的完成。等同于规则三。
有缓存的channel在缓存没空,或者没满之前,读或者写都不会阻塞。
Notice
不要在一个goroutine中对同一个channel执行发送和接收操作
Make(chan)不带箭头时,通道是没有方向的,但当它被赋予的静态类型是有方向的时候:当一个通道被赋予给一个静态类型为<-chan
的变量时,goroutine只能从中接收,又称为接收通道;被赋予给了 chan <-
变量时,goroutine只能向它发送值,又称为发送channel。
Make(chan)的时候可以带方向。
对于channel中发送和接收出去的都会是复制的值,不是原值。
一个channel的变量运行中可以有三种状态:
- nil
- 开启着的
- 关闭了的
nil channel
对一个nil的channel读写会被阻塞,当用在select
语句中时,会被强制忽略。当for-select
中读到被关闭的channel时,即生产者,关闭了channel,这样在下次的循环中就不会从中读取到值了,因为对于关闭了的channel,总是读到对应类型的零值。
for {
select {
case v, ok<- 被关闭了的channel的变量 variable:
if ok {dov} else { 将被关闭的channel的variable置为 nil}
case v <- 其他channel
}
}
开启着的channel
对开启的channel的读写,是否阻塞取决于是否还有空的缓存位。
关闭着的channel
- 从一个关闭的channel接收,永远 不会阻塞,立刻会读到channel内类型的零值。
- 而对一个关闭了channel发送,会panic。
- 并且重复关闭一个已经关闭了的channel也会panic。
- 关闭一个没有了缓存的channel(或者本身就是无缓存channel), 所有阻塞在 channel接收操作上的阻塞会立刻返回零值。
另外
close 内置函数关闭一个通道,该通道必须是双向的或仅发送的。
close(make(<-chan int, 10 )) 这个close会报错, 因为是个接收channel, goroutine从中接收元素
channel 的关闭应仅由发送方执行,而不应由接收方执行,并且在收到最后发送的值后具有关闭通道的效果。
即goroutine在没有信息需要发送到channel的时候,就关闭它
因为这几个特点,所以在channel必须被优雅关闭,另外可以利用它们做一些特殊的操作,如:
- 通过4,我们可以实现结束再通知
- 因为1,我们在
for-select
中必须做好判断:是否关闭,然后如何避免再次读到,是否可以置为nil
(有时候不能置为nil,这个时候貌似都可以用for-range
, 它的特殊在于会自己判断close然后跳出来)
通道的关闭原则
关于channel的一些关闭的资料, 虽然我并不完全认同,但是也有可取之处,比如:
-
利用生产者(goroutine)和消费者(goroutine)的各类场景来阐述channel的正确用法
-
对关闭channel的执着,与细致入微的分析思考:一定要关闭
channel
以避免死锁 -
因为消费者的意外退出(一个存活的都没了)而导致生产者的发送channel阻塞,而导致了死锁,是死锁吗?这不是goroutine泄露吗?通过
defer
强行关闭来自生产者的发送channel(必须是发送或双向的),然后让生产者因为第二个特点, 而panic,再利用defer
处理这个异常,是的生产者退出。 -
消费者(只有一个)通过多加一个
channel
来通知生产者,主动让它停止生产:向取消channel中发送一个值,或者关闭它,单个的生产者接收到一个值,主动关闭生产chan和取消chan。多个生产者接收到关闭信号,主动关闭生产。小问题: 生产者已经生产的值,在chan中了的,可以消费亦可以不消费,看选择了。 -
上面的情况,如果消费者有多个呢,同时要求停止生产,即 n-m 的消费与生产,有什么好的通用方法?每个都关闭取消chan,同时都要做
defer
的recover
-
还是上面的情况,若是生产者已经生产了的,虽然还没放到chan中的,如果想继续出来怎么办,那就在检测在取消chan的关闭后,继续发送(可以主动置为nil或者在检测关闭后发送)并且消费侧也要工作。
-
如果消费者,在消费结束以后(即生产者关闭了),所有的消费者消费完了生产者的生产,可以用计数器栅栏
WaitGroup
来统计状态(栅栏的+1决不能放在子协程内)
在设计上,不管是消费者与生产者: 1-1, 1-n,n-1,n-m,都要避免channel的重复关闭,如果有一定要defer recover
。且总有一个routine
知道发送已经完成了,或者都知道;总有一个routine知道接收结束了,或者都知道,即接收的channel被发送方关闭了,接下里的操作是每个routine都做,还是统一一起做,用栅栏。一定要注意,如果难以避免重复关闭,无法关闭,那一定是设计上有问题,并且很可能routine泄露
, 残留数据为发送,未处理,未接受等。
疑问:
如果消费者被退出,生产者也停止,并且关闭了它们的channel
,但是这个channel的缓存中还有值,这个算什么(大概其实是上面第四点)?泄露?会被回收吗?
答:
一个channel,无论是否被关闭,只要没有再被引用,就会被go语言的垃圾回收器自动回收,即卢轩认为:channel不需要通过close来确保被回收,只有当需要告诉channel的接收者,不会再有数据发送过去了,才会需要关闭channel。
并发模式
流水线模型
模型来自现实世界的启发,是对现实世界的生产消费,通知发布等模式的抽象,模仿。
流水线由多个阶段组成,阶段间用channel
链接,每个阶段可以由同时运行的多个go routine
组成。这里也可以看出go并发的思想,通过通信来共享数据,内存,信息,控制等。
-
Fan-In: 扇入,一个
goroutine
从多个channel
读取数据,直到它们都关闭,In,代表一种收敛的模式,常用来做结果的收集。 -
Fan-Out:扇出,多个
goroutine
从一个channel
读取数据,知道它关闭,Out,代表一种发散的模式,常用来做任务的分发。
将Fan-In和Fan-out结合起来,可以实现丰富且强大的流水线模型。流水线模型的channel通常是有缓存的,这样能搞适当提高程序性能。
Sync包:mutex
channel的是go语言的主角,但是也有它不能完美解决的场景,mutex
就可以弥补,且channel
的开销其实是大于mutex
的,并且从结构来看,channel
有锁也有cond。
解决问题的对比
channel
DataFlow -> Drawing -> Pipieline -> Exiting
数据是流动的,可以Drawing
将其画出来, pipeline
就是流水线, 然后退出。所以它适合的点在于:数据的流动
- 传递数据的所有权,将数据发给其他协程
- 分发任务,任务就是数据
- 交流异步结果,结果是数据
mutex
适合不动(不移动)的数据, 1. 缓存 2. 状态
这两个该如何理解???更新缓存?改状态?
不同的goroutine之间不再像合作,而像是竞争。对临界区资源的读取或者修改。
sync.Once
= 原子操作配合互斥锁 , 但是开销比 原子操作 + 互斥锁小,atomic
包对基本类型和复杂对象都提供了原子操作的支持。
Load
和Store
方法,分别来加载和保存数据,返回值都是interface{}
,所以可以用于任何复杂自定义类型。所谓原子操作,就是无论任何适合读,都不会读到一半的数据,在jvm的并发中,使用不当的时候,可以读到初始化了一半的对象等,是"破损的数据"。
var {
instance *Singleton // 某单例, 非导出
once sync.Once
}
func Instance() *Singleton { // 导出,单例的获取方法
once.Do(func() {instance = &singleton{}})
return instance
}
cond
mutex
Pool
Map
WaitGroup
RWMutex
channel,mutex,waitGroup
channe和mutex并不是相互对立的关系,而是互补,在某些场景我们选择 channnel or mutex
, 复杂场景是channel and mutex
,
甚至再加上WaitGroup
等待栅栏,流动的数据在被处理完后,在某个点不动,等待其他处理的结束,这是BSP。
可见性
由于现代计算机cpu的架构设计和实现,多核运算的线程或者程序总是会遇到可见性问题,这个问题在go中也存在,虽然go用的是协程,对于可见性,官方文档。另外根据规范,main函数的执行不会等待其他routine的运行结束。当初始化的时候,会按照main包里导入的其他包顺序不断深入导入并且初始化,是个递归的过程。在main.main
函数执行之前所有代码都运行在同一个Goroutine中,也是运行在程序的主系统线程中。如果某个init
函数内部用go关键字启动了新的Goroutine的话,新的Goroutine和main.main
函数是并发执行的。
中文阅读材料:(https://juejin.cn/post/6911126210340716558#heading-7)
常见并发模式
在神奇的channel中,有一些直观的对并发模式的思考。
-
消费者和生产者:生产者的生产发给了消费者,相互了解
-
发布订阅模式:发布者不关心谁在订阅,它只是发布到了通道,由中间人,将通道的消息送给订阅者
并发度的控制
我们利用channel
来传递,通信,也可以利用阻塞 + 缓存 来实现并发度的控制。缓存 + 阻塞 = 带一定数据的可阻塞可恢复的信号量。
启动goroutine
之前或者之中,操作缓存channel,等待channel不能操作的时候,goroutine
的创建或者创建好的运行中会阻塞,再每个运行结束的channel会反操作channel,然后执行流会恢复。
利用这个方法,1. 可以控制并发度 2. 可以检测channel的缓存的空余来判断程序的运行状态,通过已使用和空的位置比例判断并发率。
网友评论