并发入门
并发入门
并发与并行
简单介绍
这一块内容我们只简单提一下,不会展开。相关问题可以直接在网上找资料,非常全面。
在讨论并发之前,必须得先讲清楚并发和并行,以及他们两个之间的区别,还有各自会有哪些常见的问题。这两个名词都是操作系统里的概念:
- 并发是指两个或多个事件在同一时间间隔内发生。
- 并行性是指系统具有同时进行运算或操作的特性。
举个例子: 并发就是多个人一起去银行柜台取钱。并行就是银行柜台可以同时帮你取现,还可打印取款记录。 对应计算机就是这个场景: 并发就是很多人都同时访问某一个服务或者资源,比如秒杀,或者一些热门视频。并行就是你的电脑上可以同时打游戏,看视频,听歌。正常情况下,我们的服务是既可以并发也可以并行的。
引申,关于并发和并行可以延伸很多场景和问题。比如:异步与同步,阻塞与非阻塞。大家可以补充一下这块的知识。
GO并发编程模型
首先,我们这里要先引入第一个问题:在操作系统中,进程和线程是什么?协程是什么?我们这里不做回答。这些问题的答案网上已经很多了,他们依然属于操作系统的知识体系。 其次,我们再来看一句GO经典谚语:
Don’t communicate by sharing memory; share memory by communicating.
不要通过共享内存来通信,而应该通过通信来共享内存。这是作为 Go 语言的主要创造者之一的Rob Pike的至理名言,这也充分体现了Go言最重要的编程理念。而Channel恰恰是后半句话的完美实现,我们可以利用通道在多个GoRoutine之间传递数据。(这也是Channel最重要的使用场景) 最后,GO并发编程的模型我们通常管他叫做GMP模型,我们在这里只简单说下他的含义,不会过多展开。
- G指的是我们的GoRoutine,即一个需要被执行的协程。
- M指的是Machine,即系统线程。由操作系统统一调度和管理,我们的GoRoutine最终需要再M上用来执行。
- P指的是Processor,GO语言自己的处理器。它维护着我们所有的GoRoutine,并负责给他安排对应的M来执行。
GMP是GO语言里少有的,很少用到但不得不背的八股文之一。
Channel
Channel的源码在 runtime.hchan ,根据源码就可以理解课程里一些常见的知识点。
type hchan struct {
qcount uint //当前通道中的元素个数。
dataqsiz uint //当前循环队列的长度。
buf unsafe.Pointer //指向缓冲区的指针
elemsize uint16 //通道元素的大小
closed uint32 //是否关闭
elemtype *_type //通道元素的类型
sendx uint //发送操作处理到的位置
recvx uint //接收操作处理到的位置
recvq waitq //被阻塞的接收操作队列 链表结构
sendq waitq //被阻塞的发送操作队列 链表结构
lock mutex //互斥锁
}
缓冲区本身是一个数组。
定义与使用方法
俗称:通道,是GO语言所有基础类型中唯一的满足并发安全的类型。我们常用的切片,MAP和结构体都不能并发6操作,只有Channel可以。这也是GO语言的并发操作被大家称赞的原因之一:简单,安全,开箱即用。
//注意,必须使用make来创建
ch := make(chan int,0) //非缓冲通道
ch1 := make(chan int,3) //带有三个缓存区的通道
Channel 收发操作均遵循了先入先出(FIFO)的设计:通道中的各个元素值都是严格地按照发送的顺序排列的,先被发送通道的元素值一定会先被接收。
对于Channel的写入和读取都会使用**< -**。左边的尖括号代表方向。
package main
import "fmt"
func main() {
ch1 := make(chan int, 3) //声明一个有三个缓存区的Channel,类型为int
ch1 <- 2 //向通道写入一个值
ch1 <- 1
ch1 <- 3
elem1 := <-ch1 //从通道中读取一个值
fmt.Printf("The first element received from channel ch1: %v\n",
elem1)
}
Channel跟string或slice有些不同,它在栈上只是一个指针,实际的数据都是由指针所指向的堆上面。
注意,我们上面创建的是双向通道,GO还有单向通道。默认情况下,我们都会使用双向通道。
var uselessChan1 = make(chan<- int, 1) //单向发通道
var uselessChan2 = make(<-chan int, 1) //单向收通道
单向通道在实际工作中很少很少会用到,它基本上只有一个用处,约束函数行为。
通道操作的特性
- 对于同一个通道,发送操作之间是互斥的,接收操作之间也是互斥的。
- 发送操作和接收操作中对元素值的处理都是原子性的。
- 发送操作在完全完成之前会被阻塞。接收操作也是如此。
第一个特性:对于同一个通道Channel,你可以同时发送和接收,但是不能同时接收或者发送,也就是说它的操作都是串行的,阻塞的。另外,对于Channel具体的元素也不能同时发送或者接收。
第二个特性:可以简单理解为,对于Channel中某个元素的操作是原子性的。要么成功,要么失败,只有这两种结果,绝对不会出现部分成功的情况。我们以取一个元素为例,当我们操作Channel的时候实际上有这几个步骤:
找到元素->取元素的副本->将副本交给接收方->删除Channel中这个元素
我们最终能收到的就是这一串操作要么成功,要么失败,不会有中间态。这是Channel是并发安全的一个体现。
第三个特性其实是第一个的补充。最终目的就是为了实现元素和通道操作的原子性。 关于通道的阻塞,我们需要分开来说明:
非缓冲通道
对于非缓冲通道而言,事情非常简单。它不需要存很多东西,只需要记录一个值。详细情况: **必须同时有协程对一个非缓冲的Channel同时进行读写,否则一定会阻塞。**有些错误的写法会直接导致死锁:
ch1 := make(chan int, 0)
ch1 <- 2
//在main函数中执行,一定会爆出:fatal error: all goroutines are asleep - deadlock!
//这里有两个原因,之后我们会再次展开。
正确的用法:
func main() {
ch1 := make(chan int, 0)
go goC1(ch1)
ch1 <- 2 //这里也可以用协程来实现
time.Sleep(3)
}
func goC1(c chan int) {
a,ok := <-c //这里的OK 有一个特殊含义,用来表示通道是否关闭。
fmt.Printf("%d", a)
}
非缓冲通道,通常是用来进行同步数据操作,日常开发中用到的地方不多。
缓冲通道
对于缓冲通道而言,相对复杂点。
- 如果缓冲通道已经塞满了,那么后续的写操作会阻塞,直到通道里有空余位置。
- 如果缓冲通道是空的,那么后续的读操作会阻塞,直到通道里有新的数据。
另外,后续阻塞的协程也会以队列的形式组织起来,先等待的先执行。
使用细节
跟channel相关的操作有:初始化/读/写/关闭。Channel未初始化值就是nil,未初始化的Channel是不能使用的。下面是一些操作规则:
- 读或者写一个nil的Channel的操作会永远阻塞。
- 读一个关闭的Channel会立刻返回一个Channel元素类型的零值。
- 写或者关闭一个关闭的Channel会导致panic。
一个经典问题:操作一个已关闭的Channel会发生什么。 引申,为什么不建议让接收方关闭通道。
如何使用Channel
For Range 遍历
这是很少会用的方法,一般公司内或者项目内也会有明文规定,不推荐使用这种方式获取一个通道内的数据。
chs := make([]int,3)
for ch := range chs{ //注意这一行,所有关键节点都在这里。
fmt.Printf("ch:%v\n",ch)
}
这里要记得只有三种情况:
- 如果chs中没有任何数据,那么程序会阻塞在For这一行,直到有数据进来为止。
- 如果chs已经被关闭了,那么程序会把所有元素都遍历完,再跳出循环。
- 如果chs是nil,那么程序会永远阻塞在For这一行,不会往下执行。
Select
最常用的方法,通常会结合 for 一起使用。
这里直接贴郝琳老师的源码:
//
// 准备好几个通道。
ntChannels := [3]chan int{
make(chan int, 1),
make(chan int, 1),
make(chan int, 1),
}
// 随机选择一个通道,并向它发送元素值。
index := rand.Intn(3)
fmt.Printf("The index: %d\n", index)
intChannels[index] <- index
// 哪一个通道中有可取的元素值,哪个对应的分支就会被执行。
select {
case <-intChannels[0]:
fmt.Println("The first candidate case is selected.")
case <-intChannels[1]:
fmt.Println("The second candidate case is selected.")
case elem := <-intChannels[2]:
fmt.Printf("The third candidate case is selected, the element is %d.\n", elem)
default:
fmt.Println("No candidate case is selected!")
}
这里可以直接模拟select的使用细节和场景。另外,这里大家可以把他拆分到多个GoRoutine中,来模拟并发。
看下Select的注意事项:
- 如果没有Default分支,当所有的Case都不满足要求时,会阻塞在Select这一行,直到有一个Case满足情况。
- 如果有Default分支,那么无论Case是否满足要求,Select都不会阻塞,他会直接执行Default。
- 一旦发现通道关闭了,需要及时处理。有利于程序稳定性。
- Select语句只会对所有的Case执行一次。正常情况,我们会结合For一起使用。这时候就需要注意,Select中的break不能直接跳出For。可以使用goto或者return跳出,一般情况下,不推荐使用goto,会降低代码的可读性。
- Select的Case是从上往下执行的,并且先执行Case条件判断,之后再执行某个Case下的代码,如果有多个Case满足执行要求,会伪随机挑选一个来执行。
问题引申:IO多路复用中的Select。
问题:
- 如果在select语句中发现某个通道已关闭,那么应该怎样屏蔽掉它所在的分支?
for {
select {
case _, ok := <-ch1:
if !ok { //发现当前通道已经关闭
ch1 = make(chan int) //设置为无缓冲通道,这个Case会被永远阻塞。
}
case ... :
////
default:
////
}
}
- 在select语句与for语句联用时,怎样直接退出外层的for语句?
参考:concurrency - Break out of select loop? - Stack Overflow 可以用goto直接跳出循环,实际开发中,还是推荐将For-select 进行封装,使用return来跳出。 引申阅读:https://draveness.me/golang/docs/part3-runtime/ch06-concurrency/golang-channel/