go的强大并发

[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的变量运行中可以有三种状态:

  1. nil
  2. 开启着的
  3. 关闭了的

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

  1. 从一个关闭的channel接收,永远 不会阻塞,立刻会读到channel内类型的零值。
  2. 而对一个关闭了channel发送,会panic
  3. 并且重复关闭一个已经关闭了的channel也会panic
  4. 关闭一个没有了缓存的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,同时都要做deferrecover

  • 还是上面的情况,若是生产者已经生产了的,虽然还没放到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就是流水线, 然后退出。所以它适合的点在于:数据的流动

  1. 传递数据的所有权,将数据发给其他协程
  2. 分发任务,任务就是数据
  3. 交流异步结果,结果是数据

mutex

适合不动(不移动)的数据, 1. 缓存 2. 状态

这两个该如何理解???更新缓存?改状态?

不同的goroutine之间不再像合作,而像是竞争。对临界区资源的读取或者修改。

sync.Once

= 原子操作配合互斥锁 , 但是开销比 原子操作 + 互斥锁小,atomic包对基本类型和复杂对象都提供了原子操作的支持。

LoadStore方法,分别来加载和保存数据,返回值都是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的缓存的空余来判断程序的运行状态,通过已使用和空的位置比例判断并发率。

你可能感兴趣的:(go的强大并发)