公众号:程序员读书,欢迎关注
当我们开发一个Web服务时,我们希望可以同时处理成千上万的用户请求,当我们有大量数据要计算时,我们希望可以同时开启多个任务进行处理,随着硬件性能的提升以及应用数据的增长,有越来越多的场景需要高并发处理,而高并发是Go的强项。
在这篇文章中,我们就一起来探究一下Go并发编程!
并发与并行
在谈Go并发编程之前,我们需要对并发 与并行做一下区分。
并发
并发是指有多个任务处于运行状态,但无法确定到底任务的运行顺序,比如某一时间,有一个双核CPU,但有10个任务(线程),这些任务可能随机被分配到相同或者不同的核心上去运行,但是其运行顺序是不确定的。
并行
并行是指多个任务在某一个时刻同时运行,比如某一个时刻,一个双核心的CPU,两个核心同时都有一个任务在运行,那么就是说这两个任务是并行的。
Goroutines
Goroutine
是 Go
语言的并发单元。
什么是Goroutine
Goroutine
,中文称为协程,我们可以把 Goroutine
看作是一个轻量级的线程,而从代码层面来看,Goroutine
就是一个独立运行的函数或方法。
Goroutine的优势
- 与线程相比,创建一个
Goroutine
的开销要小得多,一个Goroutine
初始化时只需要2KB,而一个线程则要2MB,所以Go程序可以大量创建Goroutine进行并发处理。 - 虽然协程初始化只有2KB,但却可以根据需求动态扩展。
Goroutine
可以通过Channel
互相通讯,而线程只能通过共享内存互相通讯。Goroutine
由Go调度器进行调度,而线程则依赖系统的调度。
启动Goroutine
要启动一个Goroutine
非常简单,只要在函数或者方法前面加上 go
关键字就可以了:
go
package main
func Hello(){
fmt.Println("hello")
}
func main(){
go Hello()
//匿名函数
go func(){
fmt.Println("My Goroutine")
}()
}
程序启动后, main
函数单独运行在一个 Goroutine
中,这个 Goroutine
称作 Main Goroutine
,其他用go
关键字启动的Goroutine
各自运行。
如果你在控制台运行上面的程序,会发现在控制台根据没有任何输出,这是为什么呢?
原因在于虽然所有的Goroutine是独自运行的,但如果 Man Gorouine
终止的话,那么所有 Goroutine
都会退出执行。
上面的示例中,我们启动的 Goroutine
还没运行,main
函数就执行结束了,因此整个程序就退出了。
go
package main
import "time"
func Hello(){
fmt.Println("hello")
}
func main(){
go Hello()
go func(){
fmt.Println("My Goroutine")
}()
time.Sleep(time.Second)
}
上面的示例中,我们调用 time.Sleep()
函数让 Main Goroutine
休眠而不退出,这时候其他的Goroutine
就可以在 Main Goroutine
退出前执行。
关闭Goroutine
Go没有提供关闭Goroutine
的机制,一般来说要让一个Goroutine
停止有三种方式:
Goroutine
执行完成退出或者return
退出main
函数执行完成,所有Goroutine
自然就会终止- 直接终止整个程序的执行(程序崩溃或调用os.Exit()),类似第2种方式。
Channel
Go
并发编程的思想是:不要用共享内存来通讯,而是用通讯来共享内存。而这种通讯机制就是Channel
。
什么是Channel
Channel
是 Goroutine
之间的通信机制,可以把 Channel
理解为 Goroutine
之间的一条管道,就像水可以从一个管道的一端流向另一端一样,数据也可以通过 Channel
从一个 Goroutine
流向其他的一个 Goroutine
,以实现 Goroutine
之间的数据通讯。
创建Channel
创建 Channel
类型的关键字是 chan
,在 chan
后面跟一个其他的数据类型,用于表示该 channel
可发送什么类型的数据,比如一个可以发送整数的 Channel
其定义是:
go
var ch chan int
Channel
的默认值为nil,Channel
必须实例化后才能使用,使用 make()
函数实例化:
go
ch = make(chan int)
ch1 := make(chan int)
Channel与map一样是引用数据类型,在调用make()函数后,该Channel变量引用一块底层数据结构,因此当把channel变量传递给函数时,调用者与被调用者引用的是同一块数据结构。
Channel操作
Channel支持发送与接收两种操作,无论是发送还是接收,都是用 <-
运算符。
发送与接收
向Channel发送数据时,运算符 <-
放在channel变量的右边,运算符与Channel变量之间可以有空格:
go
ch <- x
接收Channel数据时,运算符 <-
放在channel变量的左边且之间不能有空格:
go
x <-ch
x <- ch //错误写法
不接收channel的结果也是可以的:
go
<-ch
一个示例:
go
package main
import "fmt"
func main() {
ch := make(chan int)
go func(ch chan int) {
ch <- 10
}(ch)
m := <-ch
fmt.Println(m)
}
关闭
使用内置 close
可以关闭 Channel
:
go
close(ch)
在关闭之后,如果再对该channel发送数据会导致panic错误:
go
close(ch)
ch <- x //panic
如果Channel中还有值未被接收,在关闭之后,还可以接收Channel里的值,如果没有值,则返回一个0值。
go
package main
import "fmt"
func main() {
ch := make(chan int)
go func(ch chan int) {
ch <- 10
close(ch) //关闭
}(ch)
m := <-ch
n := <-ch
//10,0
fmt.Println(m, n)
}
在从Channel接收值的时候,也可以多接收一个布尔值,如果为true,表示可以接收到有效值,如果没有值,则表示Channel被关闭且没有值:
go
n,ok := <-ch
关闭一个已经关闭的Channel会导致panic,关闭一个nil值的Channel也会导致panic。
遍历
Channel
也可以用for...range
语句来遍历:
go
package main
import (
"fmt"
"time"
)
func main() {
ch := make(chan int)
go func(ch chan int) {
ch <- 10
ch <- 20
}(ch)
go func(ch chan int) {
for c := range ch {
fmt.Println(c)
}
}(ch)
time.Sleep(time.Second)
}
无缓冲区Channel
上面的示例中,调用 make()
函数时没有指定第二个参数,这时创建的Channel称为无缓冲区Channel。
对于使用无缓冲区进行通讯的两个Goroutine来说,发送与阻塞都有可能会被阻塞,因此,本质使用无缓冲区的channel进行传输数据就是两个Goroutine之间的一次数据同步,无缓冲区的Channel又被称为同步Channel:
go
package main
import "fmt"
func main() {
ch := make(chan int)
go func() {
ch <- 10
}()
fmt.Println(<-ch)
}
有缓冲区Channel
调用 make()
函数实例化 Channel
时,也可以通过该函数的第二个参数指定 Channel
的容量:
go
ch := make(chan int,2)
通过 cap()
和 len()
函数可以 Channel
的长度:
go
cap(ch) //2
len(ch) //0
ch <- 10
len(ch) //1
对于带有缓冲区的Channel来说,当Channel容量满了,发送操作会阻塞,当Channel空的时候,接收操作会阻塞,只有当Channel未满且有数据时,发送与接收才不会发生阻塞。
Channel的串联
Channel是Goroutine之间沟通的管道,日常生活中,管道可以连接在一起,水可以从一条管道流向另一条管道,而Channel也是一样的,数据可以从一个Channel流向另一个Channel。
go
package main
import "fmt"
func main() {
ch1 := make(chan int)
ch2 := make(chan int)
go func() {
for x := 0; x < 100; x++ {
ch1 <- x
}
close(ch1)
}()
go func() {
for {
x, ok := <-ch1
if !ok {
break
}
ch2 <- x * x
}
close(ch2)
}()
for x := range ch2 {
fmt.Println(x)
}
}
单方向的channel
利用Channel进行通讯的大部分应用场景是一个Goroutine作为生产者,只负责发送数据,而另一个Goroutine作为消费者,接收数据。
对于生产者来说,不会对Channel执行接收的操作,对于消费者来说不会对Channel执行发送的操作
在声明Channel变量将 <-
运算符放在 chan
关键前面则该Channel只能执行接收操作:
go
//只允许接收
var ch1 <-chan int
在声明Channel变量将 <-
运算符放在 chan
关键字后面可以则该Channel只能执行发送操作:
go
//只允许发送
var ch2 chan<- int
像我们前面那正常声明一个Channel变量,则允许对该Channel执行发送和接收操作:
go
//可以发送和接收
var ch3 chan int
从一个只能发送数据的channel接收数据无法通过编译:
go
var ch chan<- int
x := <-ch //报错
向一个只有接收数据的channel发送数据无法通过编译:
go
var ch <-chan int
ch <- 10 //报错
对一个只有接收操作的 Channel
执行 close()
也无法通过编译:
go
var ch <-chan int
close(ch) //报错
select:多路复用
前面的示例中,我们在一个 Goroutine
中只向一个 Channel
发送数据或者只从一个 Channel
接收数据,因为如果同时向两个Channel接收或发送数据时,如果第一个Channel没有事件响应,程序会一直阻塞:
go
package main
import (
"fmt"
"time"
)
func main() {
ch1 := make(chan int)
ch2 := make(chan int)
go func(ch1 chan int, ch2 chan int) {
fmt.Println("向ch1发送数据前")
<-ch1
fmt.Println("从ch2接收数据前")
ch2 <- 1
}(ch1, ch2)
time.Sleep(1 * time.Second)
}
但很多场景下,我们需要在一个Goroutine中根据不同的Channel执行不同的操作:比如一个启动的Web服务器,在一个Goroutine中一边处理请求,一边监听信号量。要怎么做呢?
答案是:使用select语句,即多路复用,select语法类似switch语句,select语句块中可以包含多个case分支和一个default分支,每个case分支表示一个向Channel发送或接收的操作,select语句会选择可以执行的case分支来执行,如果没有,则执行default分支:
go
select {
case <-ch1:
// do something
case x := <-ch2:
// do somthing with x
case ch3 <- y:
// do something
default:
// dosomthing
}
下面我们通过一个案例来了解如何使用select语句,在这个例子中,我们模拟启动一个Web服务器处理来自用户的请求,而在处理请求的同时,还要可以根据接收的信息及时停止服务,我们在开启单独的一个Goroutine模拟向我们的Web发送停止信号:
go
package main
import (
"fmt"
"time"
)
func main() {
s := make(chan struct{})
go func(s chan struct{}) {
time.Sleep(time.Microsecond * 100)
s <- struct{}{}
}(s)
MyWebServer(s)
fmt.Println("服务已停止...")
}
func MyWebServer(stop chan struct{}) {
for {
select {
case <-stop:
fmt.Println("服务器接收到停止信号")
return
default:
}
//模拟处理请求
go HandleQuery()
}
}
func HandleQuery() {
fmt.Println("处理请求...")
}
Goroutine泄漏
一个 Goroutine
由于从Channel
接收或向 Channel
发送数据一直被阻塞,一直无法往下执行时,这种情况称为 Goroutine
泄漏:
go
package main
import "time"
func main() {
ch := make(chan int)
go func() {
ch <- 10
}()
time.Sleep(time.Second * 2)
}
Goroutine
执行完成退出后,由Go
内存回收机制进行回收,但是发生内存泄漏的Goroutine
并不会被回收,因此要避免发生这种情况。
小结
Go在语言层面支持并发编程,只需要在函数或者方法前加上go关键字便可以启动一个Goroutine,而Channel作为Goroutine之间的通讯管道,可以非常方便Goroutine之间的数据通讯。
好了,至此小结一下,在这篇文章中主要讲了以下几点:
- 什么是Goroutine,如何创建Goroutine
- 什么是Channel,对Channel的操作:发送、接收、关闭。
- Channel的特性:有无缓冲区,方向,串联。
- select多路复用。