19、并发编程
1、并发与并行
并发:同一时间段内执行多个人任务。
并行:同一时刻执行多个任务。
go语言中并发通过goroutine
实现。goroutine
雷系线程,属于用户态的线程,我们可以根据需要创建成千上万的goroutine
并发工作。goroutine
是由Go语言的运行时runtime
调度完,而先线程是由操作系统调度完成。
Go语言还提供channel
在多个goroutine
进行通信。goroutine
和channel
是Go语言秉承的CSP(Communicating Sequential Process)并发模式的实现基础。
2、goroutine
在java/c++中我们要实现并发编程的时候,我们为您通常需要维护一个线程池,并且需要自己包装一个有一个的任务,同时需要自己去调度线程执行任务并维护上线纹切换。
Go语言中的goroutine
就是这种机制,goroutine
的概念类似线程,但goroutine
是由Go语言运行时runtime
调度和管理的。Go程序会智能的将goroutine中的任务合理的分配给每个CPU。Go语言之所以称为现代化的编程语言,就是因为它在语言层面上已经实现了调度和上下文切换。
2.1、使用goroutine
只需要在调度的函数前加上go
关键字,就可以为函数创建一个goroutine
。
一个goroutine
必定对应一个函数,可以创建多个goroutine
去执行相同的函数。
2.2、启动单个goroutine
启动goroutine
的方式非常简单,只需要在调用的函数/匿名函数前面加一个go
关键字。
- 默认串行执行
1 | //该示例没有加`go`串行执行 |
- 使用
go
关键字并行执行
1 | //该示例加`go`并行行执行 |
启动多个goroutine
可以启动多个goroutine
执行一个函数。使用sync.WaitGroup
来实现goroutine同步。
1 | var wg sync.WaitGroup |
每次打印的顺序都不一样,是因为10个goroutine
是并发执行的,而goroutine
的调度是随机的。
goroutine与线程
可增长的栈
OS线程(操作系统线程)一都有固定的栈内存(通常2MB),一个goroutine
的栈在其生命周期开始的时候只有很小的栈(典型情况下2Kb),goroutine
的栈是不固定的,他可以按需增大和缩小,goroutine
的栈大小限制可以达到1Gb,极少情况下会用到那么大。在Go语言中一次创建十万个左右的goroutine
也是可以的。
goroutine调度
GMP
是Go语言运行时runtime
层面实现的,是Go语言自己实现的一套调度系统。区别于系统调度OS线程。
G
:就是goroutine,里面除了存放本goroutine的信息外,还有与所在P的绑定信息。P
:管理者一组goroutine队列,P里面储存着当前goroutine运行的上下文环境(函数指针,堆栈地址以及地址边界),P会对自己管理的goroutine队列做一些调度(比如把CPU占用时间长的goroutine暂停,运行后续的goroutine等),当自己队列中消费完就去全局队列里取,如果全局队列里也消费完就会去其他P的队列里抢任务。M(machine)
:是Go运行时(runtime)对操作系统内核线程的虚拟,M与内核的关系一般是一一映射的关系,一个goroutine一般是要放到M上执行的。
P与M一般也是一一对应的。他们的关系是:P管理着一组G挂在M上运行。当一个G长久阻塞在一个M上时,runtime会新建一个M,阻塞G所在的P会把其他的G挂载新建的M上运行。当旧的G阻塞完成或者认为其已经死掉时回收旧的M。
P的个数是通过runtime.GOMAXPROCS
设定(最大256),Go1.5版本之后默认为物理线程数。在并发量大的时候会增加一些M和P,但不会太多,频繁切换的话会得不偿失。
单从线程调度讲,Go语言相比其它语言的优势在于OS线程是由OS内核来调度的,goroutine
则是由Go运行时runtime
自己的调度器调度的,这个调度器使用一个称为m:n的调度技术(复用/调度m个goroutine到n个OS线程)。其一大特点是goroutine的调度是在用户态下完成的,不涉及内核态与用户态之间的频繁切换,包括内存的分配与释放,都是在用户态维护着的一大块内存池,不直接调用系统的malloc函数(除非内存池需要改变),成本比调度OS线程低很多。另一方面充分利用了多核的硬件资源,近似把若干goroutine均分到物理线程上,再加上本身goroutine的超轻量,以上种种保证了go调度方面的性能。
GOMAXPROCS
Go运行时的调度器使用GOMAXPROCS
参数来确定使用多少个OS线程来同时执行Go代码。(Go1.5版本之后)默认值是机器上CPU的核心数。
Go语言通过runtime.GOMAXPROCS()
函数设置当前程序并发时占用的的CPU逻辑核心数。
1 | var wg sync.WaitGroup |
两个任务只有一个逻辑核心,此时是做完一个任务在做另外一个任务。
1 | var wg sync.WaitGroup |
将逻辑核心数设置为2,此时两个任务并行执行。
Go语言中的操作系统线程和goroutine的关系:
- 1、一个操作系统线程对应用户态多个goroutine。
- 2、go程序可以同时使用多个操作系统线程。
- goroutine和OS线程是多对多的关系,即m:n。
channel
单纯的将函数并发是没有意义的。函数与函数之间需要交换数据才能体现并发执行函数的意义。
虽然可以使用共享内存进行数据交换,但其共享内存在不同的goroutine中容易出现竞态问题。为保证数据交换的正确性,必须使用互斥量对内存进行加锁,这种做法会造成性能问题。
Go语言的并发模型是CSP(Communicating Sequential Procsses)
,提倡通过通信共享内存而不是通过共享内存而实现通信。
如果说goroutine
是Go程序并发的执行体,channel
就是它们之间的连接。channel
是可以让一个goroutine
发送值到另外一个goroutine
的通信机制。
Go语言中的channel
是一种特殊的类型。通道像一个传送带或者队列,总是遵循先入先出(First In First Out)的规则,保证收发数据的顺序一致。每个通道都是一个具体类型的管道,也就是申明channel时需要为其指定元素类型。
channel类型
channel
是一种类型,引用类型。
1 | var 变量 chan 元素类型 |
Example:
1 | var ch1 chan int //声明一个传递整型的通道 |
注意:声明的通道必须使用make
函数初始化之后才能使用
make(chan 元素类型, 缓冲区大小)
1 | var ch1 chan int |
channel操作
通道有发送(send),接受(receive)和关闭(close)三种操作。
发送和接收都使用<-
符号。
定义一个通道:
1 | ch := make(chan int) |
发送:
1 | ch <- 10 //把10发送到通道中 |
接收:
1 | x := <- ch //从ch中接收值并赋值给变量x |
关闭:
1 | close(ch) |
注意:
- 只有在通知接收方goroutine所有数据发送完毕的时候才需要关闭通道。
- 通道是可以被垃圾回收机制收回的,它和文件关闭是不一样的,在操作结束后关闭文件是必须做的,但关闭通道不是必须的。
通道的特点:
- 对一个关闭的通道再发送值就会导致panic。
- 对一个关闭的通道进行接收会一直获取直到通道为空。
- 对一个关闭并且没有值的通道执行接收操作会得到对应的类型的零值。
- 关闭一个已经关闭的通道会导致panic。
无缓冲区通道
无缓冲区的通道又称为无阻塞通道。
1 | //无缓冲区的通道只有在有人接收值的时候才能发送值,不然会造成死锁 |
无缓冲区的通道只有在有人接收值的时候才能发送值。
1 | //启用一个goroutine接收值 |
无缓冲区通道上的发送操作会阻塞,直到另一个goroutine
在该通道上执行接收操作,这时值才会发送成功,两个goroutine将继续执行。如果接收操作先执行,接收方的goroutine将阻塞,直到另一个goroutine在该通道上发送一个值。
使用无缓冲区的通道进行通信将导致发送和接收的goroutine同步化。因此,无缓冲通道也被称为同步通道
。
有缓冲区通道
在make函数初始化通道时为其指定通道的容量。
1 | func main() { |
只要通道的容量大于零,那么该通道就是有缓冲区通道,通道的容量表示通道中能存放元素的数量。
可以使用len()
函数获取通道内元素的数量,使用cap()
函数可以获取到通道的容量。
for range从通道中循环获取值
向通道中发送完数据时,可以使用close()
函数来关闭通道。
当通道关闭时,载往该通道发送值会引发panic
,从该通道取值操作会先取完通道中的值,然后取到的之一直都是对应类型的零值。
1 | func main() { |
for range
遍历通道时,当通道被关闭的时候就会退出for range
。
单向通道
有的时候会将通道作为参数在多个任务函数间传递,很多时候我们在不同的任务函数中使用通道都会对其限制,比如限制通道在函数中只能发送或者接收。
1 | func main() { |
chan <- int
只是一个写单向通道(只能对其写入int类型),只可以对其执行发送操作但是不能对其执行接收操作。<- chan int
只是一个单向读通道(只能从其读取int类型值),可以对其执行接收操作但不能执行发送操作。
在函数传参时及任何赋值操作中可以将双向通道转换为单向通道,但不能从单向通道转换为双向通道。
通道总结
channel | nil | 非空 | 空 | 满了 | 没满 |
---|---|---|---|---|---|
接收 | 阻塞 | 接收值 | 阻塞 | 接收值 | 接收值 |
发送 | 阻塞 | 发送值 | 发送值 | 阻塞 | 发送值 |
关闭 | panic | 关闭成功,读完数据后返回零值 | 关闭成功,返回零值 | 关闭成功,读完数据后返回零值 | 关闭成功,读完数据后返回零值 |
关闭已经关闭的channel
,会引发panic
select多路复用
select
的使用类似于switch语句,它有一系列的case分支和一个默认分支。每个分支对应一个通道的通信(接收或者发送)过程。select
会一直等待,直到某个case
的通信操作完成时,就会执行case
分支对应的语句。
1 | func main() { |
使用select
语句能提高代码的可读性:
- 可以处理一个或者多个channel的发送/接收操作。
- 如果多个
case
同时满足,select
会随机选择一个。 - 对于没有
case
的select{}
会一直等待,可用于阻塞main函数。
作业
//使用goroutine和channel实现一个计算int64随机数各位数和的程序。
//开启一个goroutine循环生成int64类型的随机数,发送到jobChan
//开启24个goroutine从jobChan中取出随机数计算各位数的和,将结果发送到resultChan
//主goroutine从resultChan取出结果并打印到终端输出
1 | func main() { |
并发安全和锁
有时候在Go代码中可能存在多个goroutine
同时操作一个资源(临界值),这种情况会发生竞态问题
(数据竞态)。
1 | var x = 0 |
输出值每次都不一致
互斥锁
互斥锁是一种常用的控制共享资源访问的方法,他能够保证同时只有一个goroutine
可以访问共享资源。
1 | //互斥锁 |
1 | var x = 0 |
使用互斥锁能够保证同一时间有且只有一个goroutine
进入临界区,其他goroutine
则在等待锁;当互斥锁释放后,等待的goroutine
才可以获取锁进入临界区,多个goroutine
同时等待一个锁时,唤醒的策略是随机的。
读写互斥锁
互斥锁是完全互斥的,但是有很多实际的场景下是读多写少的,当我们并发的去读取一个资源不涉及资源修改的时候是没有必要去加锁的,这种场景下使用读写锁是更好的一种选择。
1 | //读写互斥锁 |
读写锁分为两种:
当一个goroutine
获取读锁之后,其他的goroutine
如果是获取读锁会继续获得锁,如果是获取写锁就会等待;当一个goroutine
获取写锁之后,其他的goroutine
无论是获取读锁还是写锁都会等待。
1 | var( |
sync.WaitGroup
Go语言可以使用sync.WaitGroup
来实现并发任务同步的。
方法名 | 功能 |
---|---|
func (wg *WaitGroup) Add(delta int) | 计数器+delta |
func (wg *WaitGroup) Done() | 计数器减一 |
func (wg *WaitGroup) Wait() | 阻塞直到计数器变为0 |
sync.WaitGroup
内部维护者一个计数器,计数器的只可以增加减少。例如当我们启动了N个并发任务时,就将计数器值增加N。每个任务完成时通过调用Done()方法将计数器减1.通过调用Wait()方法来等待并发任务执行完,当计数器值为0时,表示所有任务已完成。
1 | func f1() { |
注意sync.WaitGroup
是一个结构体,传递的时候要传指针
sync.Once
这是一个进阶知识点。
在很多场景下我们需要确保某些操作在高并发的场景下只执行一次,例如只加载一次配置文件,只关闭一次通道等。
1 | var once sync.Once |
sync.Once
只有一个Do
方法
1 | func (o *Once) Do(f func()) |
备注:如果要执行的函数f
需要传递参数就需要搭配闭包来使用
加载配置文件示例
延迟开销很大的初始化操作到真正用到它的时候再执行是一个很好的实践。因为预先初始化一个变量(比如在init函数中完成初始化)会增加程序启动时的消耗,而且有可能实际过程中这个变量没用上,该操作就是不必要的。
1 | var icons map[string]image.Image |
多个goroutine
并发调用Icon函数时不是并发安全的,现代的编译器和CPU可能会在保证每个goroutine
都满足串行的一致的基础上自由的重排访问顺序。loadIcons函数可能会被重拍为以下结果:
1 | func loadIcons(){ |
这种情况下就会出现即使判断了icons
不是nil也不意味着变量初始化完成了。在这种情况下,我们会想到使用互斥锁,保证初始化icons
的时候不会被其他的goroutine
操作,但是这样又回引发性能问题。
使用sync.Once
改造
1 | var icons map[string]image.Image |
并发安全下的单例模式
sync.Once
实现单例
1 | type singleton struct {} |
sync.Once
其实内部包含一个互斥锁和一个布尔值,互斥锁保证布尔值和数据的安全,而布尔值用来记录初始化是否完成。这样能保证初始化操作的时候是并发安全的并且初始化操作也不会被执行多次。
sync.Map
Go语言内置的map不是并发安全的。
1 | //普通map在并发情况下是不安全的 |
并发执行会报错:fatal error: concurrent map writes
在并发情况下就需要使用为map加索保证并发的安全性,Go语言的sync
包中提供了一个并发安全的sync.Map
,并且内置Store
,Load
,Delete
,Range
等操作方法。
1 | //使用并发安全的map |
原子操作
代码中的加锁操作因为涉及内核态的上下文切换会比较耗时,代价比较高。针对基本数据类型我们还可以使用原子操作来保证并发安全,因为原子操作是Go语言提供的方法它在用户态就可以完成,因此性能比加锁好。
atomic
1 | //定义接口 |