title:
style: nestedList # TOC style (nestedList|nestedOrderedList|inlineFirstLevel)
minLevel: 0 # Include headings from the specified level
maxLevel: 0 # Include headings up to the specified level
include:
exclude:
includeLinks: true # Make headings clickable
hideWhenEmpty: false # Hide TOC if no headings are found
debugInConsole: false # Print debug info in Obsidian console1 协程
1.1 概述
Go 通过协程机制实现并发,可以理解为轻量级的线程。
Cite
之前看一篇文章,说了在某情况下协程是单线程并发,又说另外情况下协程是多线程并行;但我没太看明白,感觉他讲的有点乱;
还看过关于协程机制的文章,不过理解得不是很好;
这些东西等之后有时间去研究研究,现在先跳过了。
通过下面得语法就能启动一个新的协程,跟 defer 的语法很像;而且也是函数名和参数在当前协程 (Go 启动时会开启一个默认的协程去运行 main 函数) 计算,函数体在新的协程执行。
go func(){}()GO 的协程共享相同的地址空间,因此访问同一段内存时需要同步。不过 Go 一般不用共享内存的方式实现协程间通信,而是用通信的方式共享内存 (这话是从一篇文章上学来的,说的还挺巧妙的)。
1.2 使用
我们先用这样的例子看看不用协程要花多长时间
package main
import (
"fmt"
"time"
)
func main() {
start := time.Now()
for i := 0; i < 10; i++ {
func() {
time.Sleep(time.Second)
}()
}
fmt.Println(time.Since(start))
}10.0034361s
然后在 func() {}() 前面加个 go 关键字;另外由于我们主协程运行完是不管还有没有其他协程运行的,会直接退出。所以这里我们还需要用 sync.WaitGroup{} 机制让主协程等待其他协程退出。
var wg = sync.WaitGroup{}
func main() {
start := time.Now()
for i := 0; i < 10; i++ {
wg.Add(1)
go func() {
defer wg.Done()
time.Sleep(time.Second)
}()
}
wg.Wait()
fmt.Println(time.Since(start))
}1.0007916s
一个 10 秒一个 1 秒,对比还是很明显的。
2 信道
2.1 chan
2.1.1 概述
想要在两个协程间通信,我们可以使用 chan 信道。
chan 并不是 int 这种预定义的类型,而是和 []T map[T]U 一样现生成的类型,chan 是一个关键字,后面跟上一个类型 T 用来生成一个 T 类型的信道类型。比如 chan int 表示一个 int 信道类型。
我们可以把 chan 看出一个数据传递的管道,使用 <- 运算符可以操作这个管道;具体来说,对于一个 chan int 类型的信道变量 ch:
- 如果
ch在<-运算符左边,比如ch <- 123;表示向ch中写入数据; - 如果在右边,比如
<- ch;表示从ch读取数据。
如果信道没有缓冲 (后面会讲),信道的读写双方在对方使用 <- 运算符操作信道之前,都会使当前协程处于阻塞状态;等到另一方向信道写入 (读取) 数据时,当前方会解除阻塞状态,并读取 (写入) 数据。
同一个协程不能同时是同一个信道的读方和写方,否则会造成程序崩溃。
fatal error: all goroutines are asleep - deadlock!
goroutine 1 [chan send]:
main.main()
C:/Users/baojy/Desktop/_try/go_try/main.go:7 +0x36
exit status 2
2.1.2 例子
下面来看一个使用 chan 的例子
func Factorial(n int, ch chan string) {
res := 1
for i := 2; i <= n; i++ {
res *= i
}
ch <- fmt.Sprintf("%d! = %d", n, res)
}
func main() {
ch := make(chan string)
go Factorial(1, ch)
go Factorial(2, ch)
go Factorial(3, ch)
for i := 0; i < 3; i++ {
res := <-ch
fmt.Println(res)
}
}1! = 1
3! = 6
2! = 2
上面代码中,函数 Factorial 的作用是求阶乘;它接收两个参数,一个是要求其阶乘的数,另一个是用来协程间通信的信道 ch chan string;函数体部分,其计算阶乘完成后,会通过 <- 运算符把结果发送到信道 ch 中。
然后,在 main 函数中,我们先是用 make 创建了一个信道 ch(信道是引用类型,所以需要用 make 创建,不然值为 nil);然后调用三次 Factorial 分别计算 1 2 3 的阶乘;最后,循环三次使用 <-ch 从信道中接收结果。
注意看输出的顺序,如果多运行几次,可以看到输出的顺序是不一样的,而且不是按照调用顺序输出的。我们来分析一下这个行为。
使用 go Factorial 开启三个协程后,它们会立即各自运行;然后主协程运行到 for 中第一次循环的 res := <-ch 这个位置,我们假设这时候三个 go Factorial 还没有执行完毕,那主协程就会在这里阻塞。
然后,不知道哪个 go Factorial 先运行完了,执行 ch <- fmt.Sprintf("%d! = %d", n, res) 向信道中写入内容,这时候处于接收方的主协程就会解出阻塞,读取到结果,然后输出结果。
然后剩下两个 go Factorial 谁先执行完也是不确定的。总之是重复上述过程。
就是因为三个协程一起执行,谁先结束是不确定的,所以输出的顺序也是伪随机的。
2.1.3 带缓冲的信道
在使用 make 初始化一个信道时,可以使用第二个参数为信道设置一个缓冲区。
ch := make(chan string, 2)
当一个信道有缓冲区后,
- 对于发送方,仅当缓冲区满了,无法继续写入数据了,才会阻塞
- 对于接收方,仅当缓冲区空了,无法继续读取数据了,才会阻塞
在有缓冲区的情况下,我们甚至可以在同一个协程读写信道 (只是实验,实际不应该这么搞),只要写的时候缓冲区没满、读的时候缓冲区没空,不会触发阻塞就行。
如果把下面代码注释的那行删掉,运行代码就会报错。引用缓冲区满了,但是我们的程序运行不到能够消费缓冲区的代码了 (被 ch <- 3 堵住了)。
func main() {
ch := make(chan int, 2)
ch <- 1
ch <- 2
// ch <- 3
fmt.Println(<-ch)
fmt.Println(<-ch)
}2.2 close
我们可以用 close 函数来关闭一个信道,一般应该有发送者执行这个操作,表示没有需要发送的数据了;因为只有接收者可以通过 <- ch 返回的第二个值 (v, ok := <- ch) 来判断信道是否已经关闭。
当然,我们不是必须关闭信道,因为垃圾回收会自动帮我们释放掉不再使用的信道。close 只是通知接收者通信结束的一种方式,不是必须的。
close(ch)后执行<-ch如果对一个已经被
close的信道ch执行多次<-ch,则每次都会直接返回<类型空值>, false;而不是<-ch一次后面再执行就阻塞。利用这个特性可以利用
chan实现广播通知 (而利用正常的ch <- xxx则不行,因为xxx只能被一个<-ch消费 (无缓冲情况下))
例如下面的代码中,Fib 函数所在协程每次循环都会向 ch 发送一个值,main 协程这边的任务就是接收值并打印。
不过我们并不想关心 Fib 内执行了几次循环,就可以让 Fib 在执行完后使用 close 通知一声结束了;然后主协程通过每次循环都判断是否 ok 来确定是否继续取值并打印。
func Fib(n int, ch chan int) {
a, b := 0, 1
for i := 0; i < n; i++ {
a, b = b, a+b
ch <- a
}
close(ch)
}
func main() {
ch := make(chan int)
go Fib(10, ch)
for {
v, ok := <-ch
if !ok {
break
}
fmt.Print(v, " ")
}
}1 1 2 3 5 8 13 21 34 55
2.3 for range
for range 语法糖是能识别信道的结束的。因此上面的代码也可以这样写:
for v := range ch {
fmt.Print(v, " ")
}2.4 chan 的方向
默认的 chan 是双向的,每个能访问到它的协程都可以读或写它。
但我们也可以让它有方向,使得特定协程只能读 (写),防止一个协程内即读又写。
只读信道长这样
<-chan T只写信道长这样
chan<- T双向 chan 可以赋值给只读 (写) chan,但反过来不行。(可以理解为权限只能缩小,不能放大)
让我们改一下上面阶乘的例子:
func Factorial(n int, ch chan<- string) {
res := 1
for i := 2; i <= n; i++ {
res *= i
}
ch <- fmt.Sprintf("%d! = %d", n, res)
}
func main() {
ch := make(chan string)
go Factorial(3, ch)
var r_ch <-chan string = ch
fmt.Println(<-r_ch)
}可以看到,我们用只读信道 r_ch <-chan string 来接收,用只写信道 ch chan<- string 来写入。
3 select
select 语句可以在一堆候选 chan 中选择一个准备好的,从里面读取数据,执行对应 case 的代码。
如果都没准备好,那就阻塞;如果都准备好了,那就随机选择一个。
下面的综合代码演示了 select 的用法 (注意 select 只会选择一次,要想多次选择,需要在外面套一层循环)。
package main
import (
"fmt"
"sync"
)
var wg = sync.WaitGroup{}
func Factorial(n int, ch chan<- string) {
res := 1
for i := 2; i <= n; i++ {
res *= i
}
ch <- fmt.Sprintf("%d! = %d", n, res)
wg.Done()
}
func Fib(n int, ch chan<- int) {
a, b := 0, 1
for i := 0; i < n; i++ {
a, b = b, a+b
ch <- a
}
wg.Done()
}
func main() {
wg.Add(2)
FacCh := make(chan string)
FibCh := make(chan int)
exit := make(chan struct{})
go Factorial(4, FacCh)
go Fib(5, FibCh)
go func() {
wg.Wait()
exit <- struct{}{}
}()
for {
select {
case v := <-FacCh:
fmt.Println(v)
case v := <-FibCh:
fmt.Println(v)
case <-exit:
return
}
}
}除了 case,select 也可以像 switch 那样有一个 default 分支;如果所有其他分支的 chan 都没准备好,那就执行这个分支。