Golang有趣的并发
Posted 大后端开发
tags:
篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了Golang有趣的并发相关的知识,希望对你有一定的参考价值。
并发
什么是并发
说到并发,离不开的另一个词是并行。“并发”是指用一些彼此独立的执行模块构建程序;而“并行”则是指通过将计算任务在多个处理器上同时执行以提高效率。《七周七并发模型》是这样描述他们的:
Concurrency is about dealing with lots of things at once.
Parallelism is about doing lots of things at once.
大概是说,”并发”是同时处理(dealing)很多事情,而”并行”是同时做(doing)很多事情。读者可能还不是很理解,一图胜千言。
并发是两个队伍交替使用咖啡机,并行是两个队伍同时使用两台咖啡机。换成专业术语来解释就是,并发的实质是一个物理CPU(也可以多个物理CPU) 在若干道程序(或线程)之间多路复用(本质上同一时刻只执行一个程序或线程),而并行,是两个或两个以上事件(或线程)在同一时刻发生。
并发的好处
前面一小节我们大概知道了什么事并发,但是我们还不理解并发有哪些好处。举个例子来讲,比如说一个程序员在家里,要做以下事情:1.撸代码(大概要1个小时),2.烧开水(大概10分钟))。如果是顺序做这些事情的话,就是先撸一个小时代码,撸完之后再去烧开水,总共需要花费70分钟。而如果是并发的话,则是这样子的,先撸代码,撸了一段时间之后(小于1小时),去烧开水,然后再回来撸代码,等代码撸完了的时候,开水也烧好了,所以花费的总时间是小于70分钟的。总共消耗的时候变少了,这就是并发的好处。
goroutine
在golang中实现并发主要靠的是两个:goroutine和channel。本小节主要讲述goroutine。在Golang中,每一个并发的执行单元叫作一个goroutine。我们并不深究goroutine到底是什么,读者暂且简单的将goroutine理解为线程就可以了,至于这两者的区别在以后的博文中再详细讲。创建一个goroutine也很简单,
在调用函数的时候在其前面加上go关键字就表示创建了一个goroutine。创建了一个goroutine之后,不用等待这个函数执行完毕就可以执行接下来的代码,等效于跳过了这个函数。当这个函数执行完毕时,这个goroutine也随之退出。
f() //主函数要等待f函数执行结束才能继续往下执行,哪怕f要花费很长时间
go f() //主函数无需等待f函数执行完毕就可以往下执行
还是举前面撸代码和烧开水的例子,这里把顺序执行和并发执行的代码都放出来,读者可以做个对比。
顺序执行:
package main
import (
"fmt"
"time"
)
func code() {
fmt.Println("I am coding")
time.Sleep(60 * time.Minute)
fmt.Println("coding done.")
}
func boil() {
fmt.Println("I am boiling")
time.Sleep(10 * time.Minute)
fmt.Println("boiling done.")
}
func main() {
now := time.Now().Unix()
code()
boil()
fmt.Println(time.Now().Unix() - now)
}
因为是顺序执行的,所以烧开水要等到撸代码完成之后才能开始做,因此总时长要70分钟。
并发执行:
package main
import (
"fmt"
"time"
)
func code() {
fmt.Println("I am coding")
time.Sleep(60 * time.Minute)
fmt.Println("coding done.")
}
func boil() {
fmt.Println("I am boiling")
time.Sleep(10 * time.Minute)
fmt.Println("boiling done.")
}
func main() {
now := time.Now().Unix()
go code()
go boil()
time.Sleep(60 * time.Minute) //等待子goroutine执行完毕
fmt.Println(time.Now().Unix() - now)
}
我们在调用code函数和boil函数的时候分别在其前面加上了go关键字,表示新创建了两个goroutine来做撸代码和烧开水的工作,因此总共有三个goroutine(main函数也是一个goroutine,读者可以将其理解为主goroutine或main goroutine),注意在main函数中调用了一个time.Sleep函数,这是为了保证main goroutine不过早退出,如果main goroutine退出,那么所有的子goroutine也会被打断,不管子goroutine有没有执行完。这里总共只花了60分钟,相比顺序执行确实是变快了。
channel
在go语言世界中有一句名言:
勿以共享实现通信,应以通信实现共享
我们已经知道,用goroutine可以实现并发。那么在并发体之间,如何实现通信呢?这就需要channel了。在一个goroutine中可以发送一个channel到另一个goroutine。channel是引用类型,意味着将channel作为函数参数传递时(只是拷贝了一个引用),调用者和被调用者将引用同一个channel对象,在任何一方将channel改变也会影响到另一方。channel是类型相关的,一个channel只能传递一种类型的值,具体传递哪种类型是在初始化时指定的。我们一般通过make进行channel的初始化。
ci := make(chan int)
ci这个channel只能传递int类型的值。那么哪些情况下goroutine之间需要通信呢?最常见的情况是两个goroutine之间需要同步,也就是说一个goroutine执行完之后才能开始执行另外一个goroutine。或者一个goroutine需要依赖另外一个goroutine的结果。知道了这个之后,我们就可以使用channel来修改刚才烧开水和撸代码的那一段代码,我们终于可以不用在main函数中执行time.Sleep来等待子进程执行结束了。
package main
import (
"fmt"
"time"
)
func code(ci chan int) {
fmt.Println("I am coding")
time.Sleep(60 * time.Minute)
fmt.Println("coding done.")
ci <- 1
}
func boil() {
fmt.Println("I am boiling")
time.Sleep(10 * time.Minute)
fmt.Println("boiling done.")
}
func main() {
now := time.Now().Unix()
ci := make(chan int)
go code(ci)
go boil()
<- ci
fmt.Println(time.Now().Unix() - now)
}
前面说过,一个goroutine可以发送一个channel到另一个goroutine,所以对于channel来说会对应发送操作和接收操作。发送和接收都使用<-运算符。
ch <- x //发送一个值到channel
x = <-ch //从channel里面接收值,并赋值给x变量
<-ch //从channel里面接收值,但抛弃掉接收的值
在本例中,main函数中的<- ci表示从channel里面接收值,如果接收不到的话会阻塞。那么什么时候可以接收到呢?当code这个goroutine执行完毕之后,会将1赋值给ci这个channel,这个时候main函数就可以从ci这个channel里面接收到值了,才可以继续往下运行。
无缓冲channel
前文说到创建一个channel对象的方法
ch = make(chan int)
make其实还有第二个参数,该参数是一个可选的整形参数,默认值是0.如果不提供第二个参数或者提供0,那么创建的就是一个无缓存的channel;如果第二个参数提供一个大于0的整数,那么创建的就是一个有缓存的channel。
ch = make(chan int) //无缓存channel
ch = make(chan int, 0) //无缓存channel
ch = make(chan int, 3) //有缓存channel
这里引用go语言圣经里面的一句话:
一个基于无缓存Channels的发送操作将导致发送者goroutine阻塞,直到另一个goroutine在相同的Channels上执行接收操作,当发送的值通过Channels成功传输之后,两个goroutine可以继续执行后面的语句。反之,如果接收操作先发生,那么接收者goroutine也将阻塞,直到有另一个goroutine在相同的Channels上执行发送操作。
我们可以利用无缓存channel的阻塞特性来保证两个goroutine之间的同步,即另一个goroutine要等到前一个goroutine执行完毕才继续执行。还是看前面那段撸代码和烧开水的代码,在main函数中有一行<-ci,表示从ci这个channel里面取值,如果取不到值的话是会阻塞在这里的,那么什么时候能够取到值呢?当code函数执行完的时候(也即code这个goroutine执行完毕的时候),我们使用ci<-1将1写入到了ci这个channel。换句话说就是main goroutine一直在等待code goroutine执行完毕,这就是同步。
有缓存channel
带缓存的Channel内部持有一个元素队列,队列的容量就是初始化时指定的第二个参数。队列我们大学都学过,从队尾插入元素,从队头取出元素。用内置函数cap可以取出带缓存channel的容量,用len可以去除带缓存channel的元素个数。
ch = make(chan int, 3) //初始化一个带缓存的channel对象
cap(ch) //打印3
len(ch) //打印0,因为我们未发送任何数据到ch
这里要注意的是,如果队列为满的情况下(即len(ch)等于cap(ch)),这时候向队列尾部插入元素将导致发送操作的阻塞,直到另一个goroutine执行了接收操作(释放了队列空间);如果队列为空的情况下(即len(ch)等于0),则这时候另一个goroutine执行接收操作将阻塞,直到另一个goroutine执行一个发送操作;如果队列不空也不满(即len(ch)大于0但是小于cap(ch)),这时候执行发送操作或接收操作都不会引起阻塞。
package main
import (
"fmt"
"time"
)
func main() {
chs := make(chan int, 3)
go func(chs chan int) {
fmt.Println("I am cooking...")
time.Sleep(5 * time.Second)
fmt.Println("cook done...")
chs <- 1
}(chs)
go func(chs chan int) {
fmt.Println("I am singing...")
time.Sleep(10 * time.Second)
fmt.Println("sing done...")
chs <- 1
}(chs)
go func(chs chan int) {
fmt.Println("I am coding...")
time.Sleep(15 * time.Second)
fmt.Println("code done...")
chs <- 1
}(chs)
fmt.Println("main goroutine is waiting for the first thing...")
<- chs
fmt.Println("first thing is done.")
fmt.Println("main goroutine is waiting for the second thing...")
<- chs
fmt.Println("second thing is done.")
fmt.Println("main goroutine is waiting for the third thing...")
<- chs
fmt.Println("third thing is done.")
}
相信大家明白了上述原理之后,对这段代码应该也能理解。这里贴出执行结果,读者可以对照一下。
以上是关于Golang有趣的并发的主要内容,如果未能解决你的问题,请参考以下文章