写在前面:本节读书笔记对应原书第八章。
Go中的并发程序可以用两种手段来实现,第八章主要是围绕goroutine
和channel
展开讲的,他们都支持顺序通信进程(CSP)。
CSP是一种现代的并发编程模型, 在这种编程模型中值会在不同的运行实例(goroutine)中传递 。
goroutine
的概念类似于线程,当需要让某个任务并发执行的时候,你只需要把这个任务包装成一个函数,开启一个goroutine
去执行这个函数就可以了,就是这么简单粗暴。
Go语言中使用goroutine
非常简单,只需要在调用函数的时候在前面加上go
关键字,就可以为一个函数创建一个goroutine
。
一个goroutine
必定对应一个函数,可以创建多个goroutine
去执行相同的函数。特别的,当我们启动程序的时候,main
函数也是在一个单独的go routine
中运行的,称之为main goroutine
。
启动goroutine的方式非常简单,只需要在调用的函数(普通函数和匿名函数)前面加上一个go
关键字。
举个例子如下:
func hello() {
fmt.Println("Hello Goroutine!")
}
func main() {
hello()
fmt.Println("main goroutine done!")
}
这个示例中hello函数和下面的语句是串行的,执行的结果是打印完Hello Goroutine!
后打印main goroutine done!
。
接下来我们在调用hello函数前面加上关键字go
,也就是启动一个goroutine去执行hello这个函数。
func main() {
go hello() // 启动另外一个goroutine去执行hello函数
fmt.Println("main goroutine done!")
}
这一次的执行结果只打印了main goroutine done!
,并没有打印Hello Goroutine!
。为什么呢?
在程序启动时,Go程序就会为main()
函数创建一个默认的goroutine
。
当main()函数返回的时候该goroutine
就结束了,所有在main()
函数中启动的goroutine
会一同结束,除了主函数退出或者终止程序之外,没有其他方法在外部强制结束一个正在执行goroutine
,但还有一些其他方法可以去思考,比如说goroutine之间通过channel进行通信,这里先不展开记录了,先mark下->goroutine退出方式的总结。
所以我们要想办法让main函数等一等hello函数,最简单粗暴的方式就是time.Sleep
了。
func main() {
go hello() // 启动另外一个goroutine去执行hello函数
fmt.Println("main goroutine done!")
time.Sleep(time.Second)
}
执行上面的代码你会发现,这一次先打印main goroutine done!
,然后紧接着打印Hello Goroutine!
。
首先为什么会先打印main goroutine done!
是因为我们在创建新的goroutine的时候需要花费一些时间,而此时main函数所在的goroutine
是继续执行的。
在Go语言中实现并发就是这样简单,我们还可以启动多个goroutine
。让我们再来一个例子: (这里使用了sync.WaitGroup
来实现goroutine的同步)
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都结束
}
多次执行上面的代码,会发现每次打印的数字的顺序都不一致。这是因为10个goroutine
是并发执行的,而goroutine
的调度是随机的。
goroutine实现的是go中的并发,channel则是不同goroutine之间的通信机制。或许一开始也会疑惑,用共享内存进行数据交换不就行了,当看到第九章的时候,就会意识到共享内存在不同的goroutine
中容易发生竞态问题,这可能需要采取互斥等方式保证数据交换的正确性,互斥是不是要加锁,加锁有对性能有影响,所以还是用channel
吧。
说清楚了这个,也就不难明白为什么笔记的开篇说,Go语言的并发模型是`CSP(Communicating Sequential Processes),提倡通过通信共享内存而不是通过共享内存而实现通信。
Go 语言中的通道(channel)是一种特殊的类型。通道像一个传送带或者队列,总是遵循先入先出(First In First Out)的规则,保证收发数据的顺序。每一个通道都是一个具体类型的导管,也就是声明channel的时候需要为其指定元素类型。
channel
是一种类型,一种引用类型。声明通道类型的格式如下:
var 变量 chan 元素类型
举几个例子:
var ch1 chan int // 声明一个传递整型的通道
var ch2 chan bool // 声明一个传递布尔型的通道
var ch3 chan []int // 声明一个传递int切片的通道
通道是引用类型,通道类型的空值是nil
。
var ch chan int
fmt.Println(ch) //
声明的通道后需要使用make
函数初始化之后才能使用。
创建channel的格式如下:
make(chan 元素类型, [缓冲大小])
channel的缓冲大小是可选的。
举几个例子:
ch4 := make(chan int)
ch5 := make(chan bool)
ch6 := make(chan []int)
通道有发送(send)、接收(receive)和关闭(close)三种操作。
发送和接收都使用<-
符号。
现在我们先使用以下语句定义一个通道:
ch := make(chan int)
ch
的类型就是chan int
,表示一个可以发送int类型数据的channel。channel
对应一个make
创建的底层数据结构的引用,当我们复制一个channel
或用于函数传参的时候,会拷贝一个channel
引用,因此调用者和被调用者引用的都是同一个channel
对象。
两个相同类型的channel可以使用==
运算符比较,如果两个channel引用的是相同的对象,那么比较结果为真,当然channel
可以和nil
进行比较。
将一个值发送到通道中。
ch <- 10 // 把10发送到ch中
从一个通道中接收值。
x := <- ch // 从ch中接收值并赋值给变量x
<-ch // 从ch中接收值,忽略结果,这也是合法的
我们通过调用内置的close
函数来关闭通道。
close(ch)
关于关闭通道需要注意的事情是,只有在通知接收方goroutine所有的数据都发送完毕的时候才需要关闭通道。通道是可以被垃圾回收机制回收的,它和关闭文件是不一样的,在结束操作之后关闭文件是必须要做的,但关闭通道不是必须的。
关闭通道有以下特点:
无缓冲的通道又称为阻塞的通道。我们来看一下下面的代码:
func main() {
ch := make(chan int)
ch <- 10
fmt.Println("发送成功")
}
上面这段代码能够通过编译,但是执行的时候会出现以下错误:
fatal error: all goroutines are asleep - deadlock!
goroutine 1 [chan send]:
main.main()
/root/go/src/TestDemoServer/TestDemoServer/main.go:19 +0x54
exit status 2
为什么会出现deadlock
错误呢?
因为我们使用ch := make(chan int)
创建的是无缓冲的通道,无缓冲的通道只有在有人接收值的时候才能发送值。就像你住的小区没有快递柜和代收点,快递员给你打电话必须要把这个物品送到你的手中,简单来说就是无缓冲的通道必须有接收才能发送。
上面的代码会阻塞在ch <- 10
这一行代码形成死锁,那如何解决这个问题呢?
一种方法是启用一个goroutine
去接收值,例如:
func recv(c chan int) {
ret := <-c
fmt.Println("接收成功", ret)
}
func main() {
ch := make(chan int)
go recv(ch) // 启用goroutine从通道接收值
ch <- 10
fmt.Println("发送成功")
}
无缓冲通道上的发送操作会阻塞,直到另一个goroutine
在该通道上执行接收操作,这时值才能发送成功,两个goroutine
将继续执行。相反,如果接收操作先执行,接收方的goroutine将阻塞,直到另一个goroutine
在该通道上发送一个值。
使用无缓冲通道进行通信将导致发送和接收的goroutine
同步化。因此,无缓冲通道也被称为同步通道
。
解决上面问题的方法还有一种就是使用有缓冲区的通道。我们可以在使用make函数初始化通道的时候为其指定通道的容量,例如:
func main() {
ch := make(chan int, 1) // 创建一个容量为1的有缓冲区通道
ch <- 10
fmt.Println("发送成功")
}
只要通道的容量大于零,那么该通道就是有缓冲的通道,通道的容量表示通道中能存放元素的数量。
如果此时再将一个值发送到通道中,会再次造成deadlock
问题,这是为什么呢,明明已经使用了有缓冲区的通道啊,这是因为我们创建的通道容量大小为1,并且我们没有接收上一次发送到通道的值,此时通道没有多余的容量放下第二个值了。
就好比小区的快递柜一共有一个格子,唯一的格子已经放了快递,只有别人取走这个快递之后,快递小哥才能往里再放一个。
所以:我们可以将缓冲区通道容量扩大,或者及时接收通道中的值。
len
函数获取通道内元素的数量cap
函数获取通道的容量 当通过通道发送有限的数据时,我们可以通过close函数关闭通道来告知从该通道接收值的goroutine停止等待。当通道被关闭时,往该通道发送值会引发panic,从该通道里接收的值一直都是类型零值。那如何判断一个通道是否被关闭了呢?
func main() {
ch := make(chan int, 1)
ch <- 10
ret := <-ch
fmt.Println("[未关闭通道]发送成功", ret)
close(ch)
ret = <-ch
fmt.Println("[关闭通道]", ret)
}
输出:
[未关闭通道]发送成功 10
[关闭通道] 0
我们来看下面这个例子:
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)
}
}
上面的例子中出现了两种方式判断通道是否关闭:
ok
,ok
是个布尔值,true表示从通道中接收到值,false表示通道关闭并且没有值可以接收。 有的时候我们会将通道作为参数在多个任务函数间传递,很多时候我们在不同的任务函数中使用通道都会对其进行限制,比如限制通道在函数中只能发送或只能接收。
Go语言中提供了单向通道来处理这种情况。例如,我们把上面的例子改造如下:
func counter(out chan<- int) {
for i := 0; i < 100; 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<- int
是一个只写单向通道(只能对其写入int类型值),可以对其执行发送操作但是不能执行接收操作;<-chan int
是一个只读单向通道(只能从其读取int类型值),可以对其执行接收操作但是不能执行发送操作。 在函数传参及任何赋值操作中可以将双向通道转换为单向通道,但反过来是不可以的。
关闭已经关闭的channel
也会引发panic
。
书上内容的补充:
在工作中我们通常会使用可以指定启动的goroutine数量–worker pool
模式,控制goroutine
的数量,防止goroutine
泄漏和暴涨。
一个简易的work pool
示例代码如下:
func worker(id int, jobs <-chan int, results chan<- int) {
for j := range jobs {
fmt.Printf("worker:%d start job:%d\n", id, j)
time.Sleep(time.Second)
fmt.Printf("worker:%d end job:%d\n", id, j)
results <- j * 2
}
}
func main() {
jobs := make(chan int, 100)
results := make(chan int, 100)
// 开启3个goroutine
for w := 1; w <= 3; w++ {
go worker(w, jobs, results)
}
// 5个任务
for j := 1; j <= 5; j++ {
jobs <- j
}
close(jobs)
// 输出结果
for a := 1; a <= 5; a++ {
<-results
}
}
输出:
worker:3 start job:1
worker:2 start job:2
worker:1 start job:3
worker:3 end job:1
worker:3 start job:4
worker:1 end job:3
worker:1 start job:5
worker:2 end job:2
worker:1 end job:5
worker:3 end job:4
在某些场景下我们需要同时从多个通道接收数据。通道在接收数据时,如果没有数据可以接收将会发生阻塞。你也许会写出如下代码使用遍历的方式来实现:
for{
// 尝试从ch1接收值
data, ok := <-ch1
// 尝试从ch2接收值
data, ok := <-ch2
…
}
这种方式虽然可以实现从多个通道接收值的需求,但是运行性能会差很多。为了应对这种场景,Go内置了select
关键字,可以同时响应多个通道的操作。
select
的使用类似于switch语句,它有一系列case分支和一个默认的分支。每个case会对应一个通道的通信(接收或发送)过程。select
会一直等待,直到某个case
的通信操作完成时,就会执行case
分支对应的语句。如果没有任何case
的select
语句写作select{}
,会永远等待下去。
好了,问题来了,如果多个case都满足,那要执行哪一个呢?答案是挑一个随机执行,这样每一个channel都有相同的机会。
使用select的具体格式如下:
select{
case <-ch1:
...
case data := <-ch2:
...
case ch3<-data:
...
default:
默认操作
}
当我们希望从channel中发送或者接收值的时候,有可能会出现channel没有准备好写或者读时,我们可以通过default设置当其他的操作都不能够马上被处理时程序需要执行哪些逻辑。
举个小例子来演示下select
的使用:
func main() {
ch := make(chan int, 1)
for i := 0; i < 10; i++ {
select {
case x := <-ch:
fmt.Println(time.Now().Format("2006-01-02 15:04:05"), " [接收来自通道中的值]:", x)
case ch <- i:
fmt.Println(time.Now().Format("2006-01-02 15:04:05"), " [向通道中发送msg]:", i)
}
}
}
输出:
2020-06-24 19:38:23 [向通道中发送msg]: 0
2020-06-24 19:38:23 [接收来自通道中的值]: 0
2020-06-24 19:38:23 [向通道中发送msg]: 2
2020-06-24 19:38:23 [接收来自通道中的值]: 2
2020-06-24 19:38:23 [向通道中发送msg]: 4
2020-06-24 19:38:23 [接收来自通道中的值]: 4
2020-06-24 19:38:23 [向通道中发送msg]: 6
2020-06-24 19:38:23 [接收来自通道中的值]: 6
2020-06-24 19:38:23 [向通道中发送msg]: 8
2020-06-24 19:38:23 [接收来自通道中的值]: 8
使用select
语句能提高代码的可读性。
case
同时满足,select
会随机选择一个。case
的select{}
会一直等待,可用于阻塞main函数。 说到这里,要提一下,之前介绍说channel
的零值为nil,咦?对一个nil
的channel
发送和接收操作会永远阻塞!针对这个特性,在select
语句中操作nil
的channel
是不是永远也不会被select
到了呢!
基于此,我们可以通过nil
去激活或者禁用case
,来完成处理输入输出事件超时和取消的逻辑。
4
2020-06-24 19:38:23 [向通道中发送msg]: 6
2020-06-24 19:38:23 [接收来自通道中的值]: 6
2020-06-24 19:38:23 [向通道中发送msg]: 8
2020-06-24 19:38:23 [接收来自通道中的值]: 8
使用`select`语句能提高代码的可读性。
- 可处理一个或多个channel的发送/接收操作。
- 如果多个`case`同时满足,`select`会随机选择一个。
- 对于没有`case`的`select{}`会一直等待,可用于阻塞main函数。
说到这里,要提一下,之前介绍说`channel`的零值为nil,咦?对一个`nil`的`channel`发送和接收操作会永远阻塞!针对这个特性,在`select`语句中操作`nil`的`channel`是不是永远也不会被`select`到了呢!
基于此,我们可以通过`nil`去激活或者禁用`case`,来完成处理输入输出事件超时和取消的逻辑。