1 并发概述
并发编程是指在一台处理器上“同时”处理多个任务,Go语言在语言层面天生支持并发,这也是Go语言流行的一个很重要的原因。
宏观的并发是指在一段时间内,有多个程序在同时运行。
并发在微观上,是指在同一时刻只能有一条指令执行,但多个程序指令被快速的轮换执行,使得在宏观上具有多个进程同时执行的效果,但在微观上并不是同时执行的,只是把时间分成若干段,使多个程序快速交替的执行。
2 什么是并发?为什么需要并发?
在过去单CPU时代,单任务在一个时间点只能执行单一程序。之后发展到多任务阶段,计算机能在同一时间点并行执行多任务或多进程。虽然并不是真正意义上的“同一时间点”,而是多个任务或进程共享一个CPU,并交由操作系统来完成多任务间对CPU的运行切换,以使得每个任务都有机会获得一定的时间片运行。
随着多任务对软件开发者带来的新挑战,程序不在能假设独占所有的CPU时间、所有的内存和其他计算机资源。一个好的程序榜样是在其不再使用这些资源时对其进行释放,以使得其他程序能有机会使用这些资源。
再后来发展到多线程技术,使得在一个程序内部能拥有多个线程并行执行。一个线程的执行可以被认为是一个CPU在执行该程序。当一个程序运行在多线程下,就好像有多个CPU在同时执行该程序。
多线程比多任务更加有挑战。多线程是在同一个程序内部并行执行,因此会对相同的内存空间进行并发读写操作。这可能是在单线程程序中从来不会遇到的问题。其中的一些错误也未必会在单CPU机器上出现,因为两个线程从来不会得到真正的并行执行。然而,更现代的计算机伴随着多核CPU的出现,也就意味着不同的线程能被不同的CPU核得到真正意义的并行执行。
如果一个线程在读一个内存时,另一个线程正向该内存进行写操作,那进行读操作的那个线程将获得什么结果呢?是写操作之前旧的值?还是写操作成功之后的新值?或是一半新一半旧的值?或者,如果是两个线程同时写同一个内存,在操作完成后将会是什么结果呢?是第一个线程写入的值?还是第二个线程写入的值?还是两个线程写入的一个混合值?因此如没有合适的预防措施,任何结果都是可能的。而且这种行为的发生甚至不能预测,所以结果也是不确定性的。
至于为什么需要并发,两点:
- 业务需求
- 性能
2 Go 并发
2.1 goroutine
oroutines 可以看作是轻量级线程。
创建go很简单,只需要把 go 关键字放在函数调用语句前。
func newTask() {
i := 0
for {
i++
fmt.Printf("new goroutine: i = %d\n", i)
time.Sleep(1 * time.Second) //延时1s
}
}
func main() {
//创建一个 goroutine,启动另外一个任务
go newTask()
i := 0
//main goroutine 循环打印
for {
i++
fmt.Printf("main goroutine: i = %d\n", i)
time.Sleep(1 * time.Second) //延时1s
}
在java/c++中我们要实现并发编程的时候,我们通常需要自己维护一个线程池,并且需要自己去包装一个又一个的任务,同时需要自己去调度线程执行任务并维护上下文切换,这一切通常会耗费程序员大量的心智。那么能不能有一种机制,程序员只需要定义很多个任务,让系统去帮助我们把这些任务分配到CPU上实现并发执行呢?
Go语言中的goroutine
就是这样一种机制,goroutine
的概念类似于线程,但 goroutine
是由Go的运行时(runtime)调度和管理的。Go程序会智能地将goroutine
中的任务合理地分配给每个CPU。Go语言之所以被称为现代化的编程语言,就是因为它在语言层面已经内置了调度和上下文切换的机制。
在Go语言编程中你不需要去自己写进程、线程、协程,你的技能包里只有一个技能–goroutine
,当你需要让某个任务并发执行的时候,你只需要把这个任务包装成一个函数,开启一个goroutine
去执行这个函数就可以了,就是这么简单粗暴。
使用sync.WaitGroup
来实现goroutine
的同步。
var sw sync.WaitGroup
func hello(i int) {
defer sw.Done() // goroutine结束就登记-1
fmt.Println("Goroutine! Num:", i)
}
func main() {
for i := 0; i < 5; i++ {
sw.Add(1) // 启动一个goroutine就登记+1
go hello(i)
}
sw.Wait() // 等待所有登记的goroutine都结束
}
多次执行上面的代码,会发现每次打印的数字的顺序都不一致。这是因为5个goroutine
是并发执行的,而goroutine
的调度是随机的。
2.2 goroutine特性
主goroutine退出后,其它的工作goroutine也会自动退出。
func SonTask() {
i := 0
for {
i++
fmt.Printf("new goroutine: i = %d\n", i)
time.Sleep(1 * time.Second) //延时1s
}
}
func main() {
//创建一个 goroutine,启动另外一个任务
go SonTask()
fmt.Println("main goroutine exit")
}
2.3 goroutine调度
GPM
是Go语言运行时(runtime)层面的实现,是go语言自己实现的一套调度系统。区别于操作系统调度OS线程。
了解更多GPM
- G是个goroutine的开头字母,里面除了存放本goroutine信息外 还有与所在P的绑定等信息。
- P管理着一组goroutine队列,P里面会存储当前goroutine运行的上下文环境(函数指针,堆栈地址及地址边界),P会对自己管理的goroutine队列做一些调度(比如把占用CPU时间较长的goroutine暂停、运行后续的goroutine等等)当自己的队列消费完了就去全局队列里取,如果全局队列里也消费完了会去其他P的队列里抢任务。
- M(machine)是Go运行时(runtime)对操作系统内核线程的虚拟, M与内核线程一般是一一映射的关系, 一个groutine最终是要放到M上执行。
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调度方面的性能。
3 runtime包
3.1 runtime.Gosched()
使CPU让出时间片,让出当前goroutine的执行权限,调度器安排其他等待的任务运行,并在下次再获得cpu时间轮片的时候,从该出让cpu的位置恢复执行。
func main() {
go func(s string) {
for i := 0; i < 2; i++ {
fmt.Println(s)
}
}("one")
for i := 0; i < 2; i++ {
runtime.Gosched()
fmt.Println("two")
}
}
执行过程:
主协程进入main()函数,进行代码的执行。当执行到go func()匿名函数时,创建一个新的协程,开始执行匿名函数中的代码,主协程继续向下执行,执行到runtime.Gosched( )时会暂停向下执行,直到其它协程执行完后,再回到该位置,主协程继续向下执行。
3.2 runtime.Goexit()
调用 runtime.Goexit() 将立即终止当前 goroutine 执⾏,调度器确保所有已注册 defer延迟调用被执行。
调用此函数会立即使当前的goroutine的运行终止(终止协程),而其它的goroutine并不会受此影响。runtime.Goexit在终止当前goroutine前会先执行此goroutine的还未执行的defer语句。在主函数调用runtime.Goexit,会引发panic。
func test() {
defer fmt.Println("ccccccccccccc")
//return //终止此函数
runtime.Goexit() //终止所在的协程
fmt.Println("dddddddddddddddddddddd")
}
func main() {
//创建新建的协程
go func() {
fmt.Println("aaaaaaaaaaaaaaaaaa")
//调用test()
test()
fmt.Println("bbbbbbbbbbbbbbbbbbb")
}()
//特地写一个死循环,目的不让主协程结束
for {
}
}
3.3 runtime.GOMAXPROCS()
设置可以并行计算的CPU核数最大值,并返回之前的值。
将任务分配到不同的CPU逻辑核心上实现并行的效果:
func a() {
for i := 1; i < 10; i++ {
fmt.Println("A:", i)
}
}
func b() {
for i := 1; i < 10; i++ {
fmt.Println("B:", i)
}
}
func main() {
//逻辑核心数设为2,此时两个任务并行执行
runtime.GOMAXPROCS(2)
go a()
go b()
time.Sleep(time.Second)
}
4 channel
channel
允许 goroutine
之间相互通信。你可以把channel
看作管道,goroutine
可以往里面发消息,也可以从中接收其它 goroutine
的消息。
Go语言的并发模型是CSP(Communicating Sequential Processes),提倡通过通信共享内存而不是通过共享内存而实现通信。
Go 语言中的通道(channel
)是一种特殊的类型。通道像一个传送带或者队列,总是遵循先入先出(First In First Out
)的规则,保证收发数据的顺序。每一个通道都是一个具体类型的导管,也就是声明channel
的时候需要为其指定元素类型。
channel
是一种引用类型。
声明通道类型的格式如下:
var 变量 chan 元素类型
创建channel的格式如下:
make(chan 元素类型, [缓冲大小])
//channel的缓冲大小是可选的
4.1 channel操作
4.1.1 发送
发送和接收都使用<-
符号。
//定义一个通道:
ch := make(chan int)
ch <- 10 // 把10发送到ch中
4.1.2 接收
从一个通道中接收值。
x := <- ch // 从ch中接收值并赋值给变量x
<-ch // 从ch中接收值,忽略结果
4.1.3 关闭
close(ch)
关闭后的通道:
- 对一个关闭的通道再发送值就会导致panic。
- 对一个关闭的通道进行接收会一直获取值直到通道为空。
- 对一个关闭的并且没有值的通道执行接收操作会得到对应类型的零值。
- 闭一个已经关闭的通道会导致panic。
4.2 无缓冲通道(阻塞的通道)
使用ch := make(chan int)
创建的是无缓冲的通道,无缓冲的通道只有在有人接收值的时候才能发送值。
func main() {
ch := make(chan string)
ch <- "helloworld"
fmt.Println("发送成功")
}
执行的时候会出现以下错误:
fatal error: all goroutines are asleep - deadlock!
goroutine 1 [chan send]:
main.main()
/Users/frankyxia/go/src/DailyGolang/go并发/goroutine.go:7 +0x54
上面的代码会阻塞在ch <- "helloworld"这一行代码形成死锁。
解决办法:
启用一个goroutine去接收值。
func recv(c chan string) {
ret := <-c
fmt.Println("接收成功", ret)
}
func main() {
ch := make(chan string)
go recv(ch) // 启用goroutine从通道接收值
ch <- "helloworld"
fmt.Println("发送成功")
}
无缓冲通道上的发送操作会阻塞,直到另一个goroutine
在该通道上执行接收操作,这时值才能发送成功,两个goroutine
将继续执行。相反,如果接收操作先执行,接收方的goroutine
将阻塞,直到另一个goroutine
在该通道上发送一个值。
使用无缓冲通道进行通信将导致发送和接收的goroutine
同步化。因此,无缓冲通道也被称为同步通道
。
4.3 有缓冲通道
解决无缓冲报错问题的方法还有一种就是使用有缓冲区的通道。
使用make函数初始化通道的时候为其指定通道的容量。
func main() {
ch := make(chan , 1) // 创建一个容量为1的有缓冲区通道
ch <- 8
fmt.Println("发送成功")
}
只要通道的容量大于零,那么该通道就是有缓冲的通道,通道的容量表示通道中能存放元素的数量。就像你小区的快递柜只有那么个多格子,格子满了就装不下了,就阻塞了,等到别人取走一个快递员就能往里面放一个。
我们可以使用内置的len
函数获取通道内元素的数量,使用cap
函数获取通道的容量,虽然我们很少会这么做。