Go 学习笔记10-Go并发
目录
Go 语言原生支持并发,Go 并发这个词,它包含两方面内容:
- 一个是并发的概念
- 一个是 Go 针对并发设计给出的自身的实现方案,也就是 goroutine、channel、select 这些 Go 并发的语法特性
1,goroutine
goroutine 是由 Go 运行时(runtime)负责调度的、轻量的用户级线程,为并发程序设计提供原生支持。
- 资源占用小,每个 goroutine 的初始栈大小仅为 2k;
- 一个 Go 程序中可以创建成千上万个并发的 Goroutine
- 由 Go 运行时而不是操作系统调度,goroutine 上下文切换在用户层完成,开销更小;
- 操作系统眼中只有线程,它甚至不知道有一种叫 Goroutine 的事物存在。所以,Goroutine 的调度全要靠 Go 自己完成
- 在操作系统层面,线程竞争的“CPU”资源是真实的物理 CPU
- Goroutine 们要竞争的“CPU”资源就是操作系统线程,Goroutine 调度器的任务是将 Goroutine 按照一定算法放到不同的操作系统线程中去执行。
- 在语言层面而不是通过标准库提供。goroutine 由go关键字创建,一退出就会被回收或销毁,开发体验更佳;
- 语言内置 channel 作为 goroutine 间通信原语,为并发设计提供了强大支撑。
Go 语言通过 go关键字+函数/方法
的方式创建一个 goroutine。创建后,新 goroutine 将拥有独立的代码执行流,并与创建它的 goroutine 一起被 Go 运行时调度。
多数情况下,我们不需要考虑对 goroutine 的退出进行控制:goroutine 的执行函数的返回,就意味着 goroutine 退出。
如果 main goroutine 退出了,那么也意味着整个应用程序的退出。
此外,你还要注意的是,goroutine 执行的函数或方法即便有返回值,Go 也会忽略这些返回值。所以,如果你要获取 goroutine 执行后的返回值,你需要另行考虑其他方法,比如通过 goroutine 间的通信来实现。
2,channel
channel 既可以用来实现 Goroutine 间的通信,还可以实现 Goroutine 间的同步。
channel 是用于 Goroutine 间通信的,所以绝大多数对 channel 的读写都被分别放在了不同的 Goroutine 中。
Go 在语法层面将并发原语 channel 作为一等公民对待,使得我们可以像使用普通变量那样使用 channel,比如:
- 定义 channel 类型变量
- 给 channel 变量赋值
- 将 channel 作为参数传递给函数 / 方法
- 将 channel 作为返回值从函数 / 方法中返回
- 甚至将 channel 发送到其他 channel 中
channel 也是一种复合数据类型,在声明一个 channel 类型变量时,必须给出其具体的元素类型:
// 声明一个元素为 int 类型的 channel 类型变量 ch
var ch chan int
如果 channel 类型变量在声明时没有被赋予初值,那么它的默认值为 nil。
为 channel 类型变量赋初值的唯一方法就是使用 make 函数:
ch1 := make(chan int) // 无缓冲 channel
ch2 := make(chan int, 5) // 有缓冲 channel,缓冲区长度是 5
上面两种类型的变量关于发送(send)与接收(receive)的特性是不同的。
Go 提供了<-
操作符用于对 channel 类型变量进行发送与接收操作:
ch1 <- 13 // 将整型字面值13发送到无缓冲channel类型变量ch1中
n := <- ch1 // 从无缓冲channel类型变量ch1中接收一个整型值存储到整型变量n中
ch2 <- 17 // 将整型字面值17发送到带缓冲channel类型变量ch2中
m := <- ch2 // 从带缓冲channel类型变量ch2中接收一个整型值存储到整型变量m中
无缓冲 channel 的运行时层实现不带有缓冲区,所以 Goroutine 对无缓冲 channel 的接收和发送操作是同步的。也就是说,对同一个无缓冲 channel,只有对它进行接收操作的 Goroutine 和对它进行发送操作的 Goroutine 都存在的情况下,通信才能得以进行,否则单方面的操作会让对应的 Goroutine 陷入挂起状态,比如下面示例代码:
// 这里创建了一个无缓冲的 channel 类型变量 ch1,对 ch1 的读写都放在了一个 Goroutine 中
func main() {
ch1 := make(chan int)
ch1 <- 13 // fatal error: all goroutines are asleep - deadlock!
n := <-ch1
println(n)
}
因此上面代码要进行如下改进:
func main() {
ch1 := make(chan int)
go func() {
ch1 <- 13 // 将发送操作放入一个新goroutine中执行
}()
n := <-ch1
println(n)
}
带缓冲 channel 的运行时层实现带有缓冲区,因此,对带缓冲 channel 的发送操作在缓冲区未满、接收操作在缓冲区非空的情况下是异步的(发送或接收不需要阻塞等待)。
示例:
ch2 := make(chan int, 1)
n := <-ch2 // 由于此时ch2的缓冲区中无数据,因此对其进行接收操作将导致goroutine挂起
ch3 := make(chan int, 1)
ch3 <- 17 // 向ch3发送一个整型数17
ch3 <- 27 // 由于此时ch3中缓冲区已满,再向ch3发送数据也将导致goroutine挂起
我们还可以声明只发送 channel 类型(send-only)和只接收 channel 类型(recv-only):
ch1 := make(chan<- int, 1) // 只发送channel类型
ch2 := make(<-chan int, 1) // 只接收channel类型
<-ch1 // invalid operation: <-ch1 (receive from send-only type chan<- int)
ch2 <- 13 // invalid operation: ch2 <- 13 (send to receive-only type <-chan int)
试图从一个只发送 channel 类型变量中接收数据,或者向一个只接收 channel 类型发送数据,都会导致编译错误。
通常只发送 channel 类型和只接收 channel 类型,会被用作函数的参数类型或返回值,用于限制对 channel 内的操作,或者是明确可对 channel 进行的操作的类型。
例如下面生产者和消费者的例子:
func produce(ch chan<- int) {
for i := 0; i < 10; i++ {
ch <- i + 1
time.Sleep(time.Second)
}
close(ch)
}
func consume(ch <-chan int) {
for n := range ch {
println(n)
}
}
func main() {
ch := make(chan int, 5)
var wg sync.WaitGroup
wg.Add(2)
go func() {
produce(ch)
wg.Done()
}()
go func() {
consume(ch)
wg.Done()
}()
wg.Wait()
}
在这个例子中:
- 我们启动了两个 Goroutine,分别代表生产者(produce)与消费者(consume)。
- 生产者只能向 channel 中发送数据,我们使用chan<- int作为 produce 函数的参数类型;
- 消费者只能从 channel 中接收数据,我们使用<-chan int作为 consume 函数的参数类型。
- 在消费者函数 consume 中,我们使用了 for range 循环语句来从 channel 中接收数据,for range 会阻塞在对 channel 的接收操作上,直到 channel 中有数据可接收或 channel 被关闭循环,才会继续向下执行。
- channel 被关闭后,for range 循环也就结束了。
- channel 关闭后,所有等待从这个 channel 接收数据的操作都将返回。
- 从一个已关闭的 channel 接收数据将永远不会被阻塞,并可得到对应类型的零值
- channel一旦没有人引用了,就会被gc掉,不关闭也ok。但是如果有goroutine一直在读channel,那么channel一直存在,不会关闭。直到程序退出。
n := <- ch // 当ch被关闭后,n将被赋值为ch元素类型的零值
m, ok := <-ch // 当ch被关闭后,m将被赋值为ch元素类型的零值, ok值为false
for v := range ch { // 当ch被关闭后,for range循环结束
... ...
}
channel 的一个使用惯例,那就是发送端负责关闭 channel。
一旦向一个已经关闭的 channel 执行发送操作,这个操作就会引发 panic,比如:
ch := make(chan int, 5)
close(ch)
ch <- 13 // panic: send on closed channel
3,无缓冲 channel 的惯用法
无缓冲 channel 兼具通信和同步特性,在并发程序中应用颇为广泛。
- 第一种用法:用作信号传递
- 第二种用法:用于替代锁机制
第一种用法:用作信号传递
无缓冲 channel 用作信号传递的时候,有两种情况,分别是 1 对 1 通知信号和 1 对 n 通知信号。
第二种用法:用于替代锁机制
无缓冲 channel 具有同步特性,这让它在某些场合可以替代锁,让我们的程序更加清晰,可读性也更好。
4,有缓冲 channel 的惯用法
带缓冲的 channel 与无缓冲的 channel 的最大不同之处,就在于它的异步性。也就是说,对一个带缓冲 channel,在缓冲区未满的情况下,对它进行发送操作的 Goroutine 不会阻塞挂起;在缓冲区有数据的情况下,对它进行接收操作的 Goroutine 也不会阻塞挂起。
- 第一种用法:用作消息队列
- 无论是 1 收 1 发还是多收多发,带缓冲 channel 的收发性能都要好于无缓冲 channel;
- 对于带缓冲 channel 而言,发送与接收的 Goroutine 数量越多,收发性能会有所下降;
- 对于带缓冲 channel 而言,选择适当容量会在一定程度上提升收发性能。
- 第二种用法:用作计数信号量
- len(channel) 的应用
- 当 ch 为无缓冲 channel 时,len(ch) 总是返回 0;
- 当 ch 为带缓冲 channel 时,len(ch) 返回当前 channel ch 中尚未被读取的元素个数。
那我们是否可以使用 len 函数来实现带缓冲 channel 的“判满”、“判有”和“判空”逻辑呢?
var ch chan T = make(chan T, capacity)
// 判空
if len(ch) == 0 {
// 此时channel ch空了?
}
// 判有
if len(ch) > 0 {
// 此时channel ch中有数据?
}
// 判满
if len(ch) == cap(ch) {
// 此时channel ch满了?
}
channel 原语用于多个 Goroutine 间的通信,一旦多个 Goroutine 共同对 channel 进行收发操作,len(channel) 就会在多个 Goroutine 间形成“竞态”。
单纯地依靠 len(channel) 来判断 channel 中元素状态,是不能保证在后续对 channel 的收发时 channel 状态是不变的。
5,nil channel
如果一个 channel 类型变量的值为 nil,我们称它为 nil channel,对 nil channel 的读写都会发生阻塞。
func main() {
var c chan int
<-c //阻塞
}
或者:
func main() {
var c chan int
c<-1 //阻塞
}
6,select
当涉及同时对多个 channel 进行操作时,我们会结合另外一个原语 select,一起使用。
通过 select,我们可以同时在多个 channel 上进行发送 / 接收操作:
select {
case x := <-ch1: // 从channel ch1接收数据
... ...
case y, ok := <-ch2: // 从channel ch2接收数据,并根据ok值判断ch2是否已经关闭
... ...
case ch3 <- z: // 将z值发送到channel ch3中:
... ...
default: // 当上面case中的channel通信均无法实施时,执行该默认分支
}
当 select 语句中没有 default 分支,而且所有 case 中的 channel 操作都阻塞了的时候,整个 select 语句都将被阻塞,直到某一个 case 上的 channel 变成可发送,或者某个 case 上的 channel 变成可接收,select 语句才可以继续进行下去。
(完。)
文章作者 @码农加油站
上次更改 2022-06-19