Go语言基础之并发,首先要弄清楚并发和并行的区别:
并发:同一时间段内执行多个任务(有先后顺序)
并行:同一时刻执行多个任务(同时进行)
Go语言的并发通过goroutine实现,goroutine是由Go语言运行时调度完成。goroutine类似于线程,数据用户态的线程。go语言还提供channel在多个goroutine间进行通信,这个后边再聊。
这里我们先进行goroutine学习:
使用goroutine非常简单,只需要在调用函数的时候在前面加上go关键字,就可以为一个函数创建一个goroutine,需要注意的是,一个goroutine必须对应一个函数,可以创建多个goroutine执行相同的函数。
启动单个goroutine的例子如下:
package main
import (
"fmt"
)
func hello() {
fmt.Println("Hello Goroutine!")
}
func main() {
go hello() // 启动另外一个goroutine去执行hello函数
fmt.Println("main goroutine done!")
}
例子中的代码多次执行,输出结果只打印了"main goroutine done!",并没有打印"Hello Goroutine!"。这是为什么呢?
这是因为在程序启动时,Go程序就会为main()函数创建一个默认的goroutine,当main()函数返回的时候该goroutine就结束了,在main()函数中启动的goroutine会一同结束。那么就会有一种情况出现,main()函数所在的goroutine先执行结束了,其他还在执行的goroutine就会被强制中断。
为了解决这种问题,有以下几种解决方式:
(1).使用time.Sleep
package main
import (
"fmt"
"time"
)
func hello() {
fmt.Println("Hello Goroutine!")
}
func main() {
go hello() // 启动另外一个goroutine去执行hello函数
fmt.Println("main goroutine done!")
time.Sleep(time.Second)
}
使用time.Sleep这个方法不建议使用,因为会导致程序执行慢
(2).在代码中生硬的使用time.Sleep是不合适的,那么怎么做既能解决上边使用time.Sleep执行慢的问题,又可以输出正确的结果呢?
就是使用sync.WaitGroup,经过简单对上边的代码进行修改就可以解决
package main
import (
"fmt"
"sync"
)
var wg sync.WaitGroup
func hello() {
defer wg.Done()
fmt.Println("Hello Goroutine!")
}
func main() {
wg.Add(1)
go hello() // 启动另外一个goroutine去执行hello函数
wg.Wait()
fmt.Println("main goroutine done!")
}
/*
输出结果:
Hello Goroutine!
main goroutine done!
*/
sync.WaitGroup
Go语言中可以使用sync.WaitGroup来实现并发任务的同步,sync.WaitGroup有以下几个方法:
方法名 功能
(wg * WaitGroup) Add(delta int) 计数器+delta
(wg WaitGroup) Done() 计数器-1
(wg WaitGroup) Wait() 阻塞直到计数器变为0
sync.WaitGroup内部维护着一个计数器,计数器的值可以额增加和减少。当启动了N个并发任务时,使用Add(N)将计数器的值增加到N,每个任务完成时通过调用Done()方法将计数器减1,通过调用Wait()来等待并发任务执行完毕,当计数器值为0时,表示所有并发任务已经完成。
启动多个goroutine
Go语言中实现并发很简单,就是启动多个goroutine,这里有一个例子:
package main
import (
"fmt"
"sync"
)
var wg sync.WaitGroup
func hello(i int){
defer wg.Done() //goroutine结束就登记-1
fmt.Println("Hello Goroutine!",i)
}
func main(){
for i:=0;i<10;i++{
wg.Add(1) //启动一个goroutine就登记+1
go hello(i)
}
wg.Wait() //等待所有登记的goroutine都结束
}
/* 输出结果如下(每行的输出结果排序每次执行都会不同):
Hello Goroutine! 0
Hello Goroutine! 1
Hello Goroutine! 9
Hello Goroutine! 2
Hello Goroutine! 3
Hello Goroutine! 4
Hello Goroutine! 5
Hello Goroutine! 6
Hello Goroutine! 7
Hello Goroutine! 8
*/
goroutine与线程的关系
goroutine调度:
GPM是Go语言运行时(runtime)层面的实现,是go语言自己实现的一套调度系统。区别于操作系统调度OS线程。
G指的是Goroutine,是Go程序并发的执行体,本质上也是一种轻量级的线程,里边存放着goroutine信息以及所在的P等信息。
P指的是processor,代表了M所需的上下文环境,也是处理用户级代码逻辑的处理器,管理者一组goroutine队列,存储当前goroutine运行的上下文环境,P会对自己管理的goroutine队列做一些调度(比如把占用CPU时间较长的goroutine暂停、运行后续的goroutine等等)当自己的队列消费完了就去全局队列里取,如果全局队列里也消费完了会去其他P的队列里抢任务。
M(machine)是Go运行时(runtime)对操作系统内核线程的虚拟,M与内核线程一般是一一映射的关系,一个groutine最终是要放到M上执行的
三者的关系如下:
以上这个图讲的是两个线程(内核线程)的情况。一个M会对应一个内核线程,一个M也会连接一个上下文P,一个上下文P相当于一个“处理器”,一个上下文连接一个或者多个Goroutine。P(Processor)的数量是在启动时被设置为环境变量GOMAXPROCS的值,或者通过运行时调用函数runtime.GOMAXPROCS()进行设置。Processor数量固定意味着任意时刻只有固定数量的线程在运行go代码。Goroutine中就是我们要执行并发的代码。图中P正在执行的Goroutine为蓝色的;处于待执行状态的Goroutine为灰色的,灰色的Goroutine形成了一个队列runqueues。
那能不能把运行队列直接放在线程上呢?不行,因为如果正在执行的线程被阻塞,我们需要使用上下文把它们交给其他线程。
M0中的G0执行了syscall,然后就创建了一个M1(也有可能本身就存在,没创建),(转向右图)然后M0丢弃了P,等待syscall的返回值,M1接受了P,将·继续执行Goroutine队列中的其他Goroutine。
当系统调用syscall结束后,M0会尝试从其他线程上获取一个上下文,如果获取不到,M0就把它的Gouroutine G0放到一个全局的runqueue中,然后自己放到线程池或者转入休眠状态。全局runqueue是各个P在运行完自己的本地的Goroutine runqueue后用来拉取新goroutine的地方。P也会周期性的检查这个全局runqueue上的goroutine,如果全部goroutine runqueue中的goroutine也没有了呢,就会去其他正在运行中的P的runqueue中去拿,而且直接拿一半。
P与M一般也是一一对应的。他们关系是:P管理着一组G挂载在M上运行。当一个G长久阻塞在一个M上时,runtime会新建一个M,阻塞G所在的P会把其他的G 挂载在新建的M上。当旧的G阻塞完成或者认为其已经死掉时 回收旧的M。
P的个数是通过runtime.GOMAXPROCS设定(最大256),Go1.5版本之后默认为物理线程数。在并发量大的时候会增加一些P和M,但不会太多,切换太频繁的话得不偿失。
单从线程调度讲,Go语言相比起其他语言的优势在于OS线程是由OS内核来调度的,goroutine则是由Go运行时(runtime)自己的调度器调度的,这个调度器使用一个称为m:n调度的技术(复用/调度m个goroutine到n个OS线程)。其一大特点是goroutine的调度是在用户态下完成的,不涉及内核态与用户态之间的频繁切换,包括内存的分配与释放,都是在用户态维护着一块大的内存池, 不直接调用系统的malloc函数(除非内存池需要改变),成本比调度OS线程低很多。另一方面充分利用了多核的硬件资源,近似的把若干goroutine均分在物理线程上,再加上本身goroutine的超轻量,以上种种保证了go调度方面的性能。
Go运行时的调度器使用GOMAXPROCS参数来确定需要使用多少个OS线程来同时执行Go代码。默认值是机器上的CPU核心数。
Go语言中可以通过runtime.GOMAXPROCS()函数设置当前程序并发时占用的CPU逻辑核心数。
package main
import (
"fmt"
"runtime"
"time"
)
func a(){
for i :=1;i<100;i++{
fmt.Println("Hello Goroutine!",i)}
}
func b(){
for i :=1;i<100;i++{
fmt.Println("Hello EveryOne!",i)}
}
func main(){
runtime.GOMAXPROCS(1) // 设置成1,两个任务只能用一个逻辑核心,做完一个任务再做另外一个任务,如果设置成多个,就是两个任务并行执行
go a()
go b()
time.Sleep(time.Second)
}
channel
Go语言的并发模型是CSP(Communicating Sequential Processes),提倡通过通信共享内存而不是通过共享内存而实现通信。如果说goroutine是Go程序并发的执行体,channel就是它们之间的连接,channel是可以让一个goroutine发送特定值到另一个goroutine的通信机制。
Go语言中的通道(channel)是一种特殊的类型。通道像一个传送带或者队列,总是遵循先入先出(First In First Out)的规则,保证收发数据的顺序。每一个通道都是一个具体类型的导管,也就是声明channel的时候需要为其指定元素类型。
channel类型
channel是一种类型,一种引用类型。声明通道类型的格式如下:
var 变量 chan 元素类型
通道是引用类型,通道类型的空值是nil,声明通道后需要使用make函数初始化之后才能使用。
make(chan 元素类型, [缓冲大小])
channel操作
通道有发送(send)、接收(receive)和关闭(close)三种操作。发送和接收都使用
定义通道
ch := make(chan int)
发送
ch
接收
x :=
关闭
close(ch)
关于关闭通道需要注意的事情是,只有在通知接收方goroutine所有的数据都发送完毕的时候才需要关闭通道。通道是可以被垃圾回收机制回收的,它和关闭文件是不一样的,在结束操作之后关闭文件是必须要做的,但关闭通道不是必须的。
关闭后的通道有以下特点:
1.对一个关闭的通道再发送值就会产生panic
2.对一个关闭的通道进行接收会一直获取值直到通道为空
3.对一个关闭的并且没有值的通道执行接收操作会得到对应类型的零值
4.关闭一个已经关闭的通道会导致panic
无缓冲通道:
make的时候,不设定缓冲大小就是无缓冲通道(也被称为同步通道),反之就是有缓冲通道(异步通道)。
ch :=make(chan int)
无缓冲的通道必须有接收才能发送,否则会报deadlock错误;相反如果接收操作先执行,接收方的goroutine会阻塞,直到另一个goroutine在该通道上发送一个值。
使用无缓冲通道进行通信将导致发送和接收的goroutine同步化。因此,无缓冲通道也被称为同步通道
有缓冲通道:
make函数初始化通道的时候为其指定通道的容量,只要通道容量大于0,就是有缓冲通道。
ch :=make(chan int,1)
从通道循环取值:使用for range
package main
import "fmt"
// channel 练习
func main() {
ch1 := make(chan int)
ch2 := make(chan int)
// 开启goroutine将0~100的数发送到ch1中
go func() {
for i := 0; i < 100; i++ {
ch1 i
}
close(ch1)
}()
// 开启goroutine从ch1中接收值,并将该值的平方发送到ch2中
go func() {
for {
i, ok := ch1 // 通道关闭后再取值ok=false
if !ok {
break
}
ch2 i * i
}
close(ch2)
}()
// 在主goroutine中从ch2中接收值打印
for i := range ch2 { // 通道关闭后会退出for range循环
fmt.Println(i)
}
}
单向通道:
限制通道在函数中只能发送或者接收,这样的通道就是单向通道。
package main
import "fmt"
func counter(out chan int) {
for i := 0; i < 10; i++ {
out i
}
close(out)
}
func squarer(out chan int, in chan int) {
for i := range in {
out i * i
}
close(out)
}
func printer(in chan int) {
for i := range in {
fmt.Println(i)
}
}
func main() {
ch1 := make(chan int)
ch2 := make(chan int)
go counter(ch1)
go squarer(ch2, ch1)
printer(ch2)
}
chan在函数传参及任何赋值操作中可以将双向通道转换为单向通道,但反过来是不可以的。
select多路复用
go内置了select关键字,可以同时响应多个通道的操作。
select的使用类似于switch语句,它有一系列的case分支和一个默认的分支。每个case会对应一个通道的通信(接收或者发送)过程。select会一直等待,直到某个case的通信操作完成时,就会执行case分支对应的语句。
select{
case ch1:
...
case data := ch2:
...
case ch3data:
...
default:
默认操作
}
select多路复用的特性:
可以处理一个或多个channel的发送/接收操作
如果多个case同时满足,select会随机选择一个
对于没有case的select{}会一直等待,可用于阻塞main函数
例子如下:
package main
import (
"fmt"
)
func main() {
ch := make(chan int, 1)
for i := 0; i < 10; i++ {
select {
case x := ch:
fmt.Println(x)
case ch i:
}
}
}
并发安全和锁
在go代码中可能会存在多个goroutine同时操作一个资源,这种情况会发生竞态问题。
互斥锁:
互斥锁是一种常用的控制共享资源访问的方法,它能够保证同时只有一个goroutine可以访问共享资源。Go语言中使用sync包的mutex类型来实现互斥锁。
var x int64
var wg sync.WaitGroup
var lock sync.Mutex
func add() {
for i := 0; i < 5000; i++ {
lock.Lock() // 加锁
x = x + 1
lock.Unlock() // 解锁
}
wg.Done()
}
func main() {
wg.Add(2)
go add()
go add()
wg.Wait()
fmt.Println(x)
}
互斥锁能保证同一时间有且只有一个goroutine进入临界区,其他的goroutine则在等待锁,当互斥锁释放后,等待的goroutine才可以获取锁进去临界区,多个goroutine同时等待一个锁时,唤醒的策略是随机的。
读写互斥锁:
并发的读取一个资源不涉及资源修改的时候,读写互斥锁是比互斥锁更好的一种选择。读写锁在Go语言中使用sync包中的RWMutex类型。
读写锁分为两种:读锁和写锁,当一个goroutine获取读锁后,其他的goroutine如果想获取读锁则可以获取到,如果想获取写锁,则需要等待;当一个goroutine获取写锁后,其他的goroutine无论是获取读锁还是写锁都会等待。
package main
import (
"fmt"
"sync"
"time"
)
var (
x int64
wg sync.WaitGroup
lock sync.Mutex
rwlock sync.RWMutex
)
func write() {
// lock.Lock() // 加互斥锁
rwlock.Lock() // 加写锁
x = x + 1
time.Sleep(10 * time.Millisecond) // 假设读操作耗时10毫秒
rwlock.Unlock() // 解写锁
// lock.Unlock() // 解互斥锁
wg.Done()
}
func read() {
// lock.Lock() // 加互斥锁
rwlock.RLock() // 加读锁
time.Sleep(time.Millisecond) // 假设读操作耗时1毫秒
rwlock.RUnlock() // 解读锁
// lock.Unlock() // 解互斥锁
wg.Done()
}
func main() {
start := time.Now()
for i := 0; i < 10; i++ {
wg.Add(1)
go write()
}
for i := 0; i < 10; i++ {
wg.Add(1)
go read()
}
wg.Wait()
end := time.Now()
fmt.Println(end.Sub(start))
}