Goroutines
在go语言中,一个并发的执行单元叫做一个goroutine,也就是协程。理解协程我们可以从两个角度去比较思考。一个是线程,和线程相比,协程可以看作是用户态轻量级线程。它在用户态实现了协程调度,并且协程可以在同一进程下的不同线程上运行,实现的是一个N:M的线程模型;从另外一个角度去理解协程,可以把它和函数做对比。我们知道函数的执行是一次性的,函数执行完成后栈帧就被销毁。协程可以理解为保存运行上下文的函数,既然保存了运行的上下文,就可以做到任务的切入和切出。
go语言中,开启一个新的协程十分简单,使用go关键字即可,它不像C++的线程,你可以选择join或者deatch,它只有默认的行为:类似于deatch()
package main
import (
"fmt"
"time"
)
//主协程计算完数列后打印结果并退出
func main() {
go spinner(100 * time.Millisecond)
const n = 45
fibN := fib(n)
fmt.Println(fibN)
}
//子协程一直循环打印字符
func spinner(delay time.Duration) {
for {
for _, r := range `-|/` {
fmt.Printf("\r%c", r)
time.Sleep(delay)
}
}
}
func fib(n int) int {
if n < 2 {
return n
}
return fib(n-1) + fib(n-2)
}
当主协程执行结束时,所有的子协程都会被打断程序退出,示例如下:
package main
import (
"fmt"
)
func testGo(ptr *int) {
fmt.Println("*ptr =", *ptr)
}
func main() {
var i int = 199
go testGo(&i)
//time.Sleep(1 * time.Second)
}
主协程的休眠语句如果被注释,则执行完go语言开启子协程后,主协程执行结束并返回,所有的子协程都会被打断,终端是没有输出的。加上休眠语句等待1秒可以看见输出的值。这种方式虽然可行,但实在不够优雅,并且不是所有的任务都可以预估一个稳定的休眠时间的。
Channels
go语言在处理协程之间的通信的时候,不像c++那样使共享内存的同时用锁/内存顺序处理竞态来完成通信。它提出了一种更高级的通信机制:Channels。不同的协程额可以通过channel来发送消息,完成同步。
使用内置的make函数创建channel:
ch := make(chan int)
需要注意:channel用于复制或者函数参数传递的时候,实际上是拷贝了一个引用。channel有发送和接收两个动作,
//发送数据
ch <- x
//接收数据
x = <-ch
//顶一个一个有缓冲的chan
ch = make(chan int, 3)
无缓冲的channels
无缓冲的channels的发送操作会导致发送者goroutine阻塞,直到另一个goroutine在相同的channels上执行接收操作;反之,如果接收操作先发生,那么接收者goroutine也会阻塞,直到另一个goroutine在相同的channels执行发送操作。我们可以利用这一点完成协程之间的同步。
package main
import (
"fmt"
)
func testGo(ptr *int, ch chan int) {
fmt.Println("*ptr =", *ptr)
ch <- 1
}
func main() {
ch := make(chan int)
var i int = 199
go testGo(&i, ch)
//主协程会阻塞在这里,直到子协程执行完成后,发送数据
<-ch
}
串联的Channels
channels可以用于将多个goroutine连接在一起,一个channel的输出作为下一个channel的输入。例如下面的程序;第一个goroutine作为计数器输出数值,第二个goroutine对数字取平方并放入下一个goroutine,第三个goroutine负责打印数值。
package main
import "fmt"
func main() {
naturals := make(chan int)
squares := make(chan int)
go func() {
for x := 0; ; x++ {
naturals <- x
}
}()
go func() {
for {
x := <-naturals
squares <- x * x
}
}()
for {
fmt.Println(<-squares)
}
}
上面的程序会一直输出计数器的去平方之后的值。如果我们希望只输出前10个值的平方呢,我们可以在修改计数器当x == 10停止输出并关闭channel,否则后续的两个goroutine会一直阻塞在那里。修改如下:
package main
import "fmt"
func main() {
naturals := make(chan int)
squares := make(chan int)
go func() {
for x := 0; ; x++ {
naturals <- x
//关闭channel同时跳出循环
if x == 10 {
close(naturals)
break
}
}
}()
go func() {
for {
x := <-naturals
squares <- x * x
}
}()
for {
fmt.Println(<-squares)
}
}
关闭channel的同时跳出循环是因为对一个已经关闭的channle发送数据会导致panic异常,故需要跳出循环。但问题是:一个被关闭的channel已经发送的数据都被成功接收后,后续的接收操作不再阻塞而是立即返回一个零值。也就是说上面的例子会一直输出:0 0 0 0 0 0 0 0 0 0 0 0 0
package main
import "fmt"
func main() {
naturals := make(chan int)
squares := make(chan int)
go func() {
for x := 0; ; x++ {
naturals <- x
if x == 5 {
close(naturals)
break
}
}
}()
go func() {
for {
x, ok := <-naturals
if !ok {
close(squares)
break
}
squares <- x * x
}
}()
for {
x, ok := <-squares
if !ok {
break
}
fmt.Println(x)
}
}
使用ok字段接收判断是否成功从channel接收到值,如果失败则关闭。以上可以正常输出前5个值。但实际上有更优雅的方式来实现,我们可以使用range来遍历channel,如果channel已经关闭并且没有值可以接收的时候则结束循环。
package main
import "fmt"
func main() {
naturals := make(chan int)
squares := make(chan int)
go func() {
for x := 0; x <= 5; x++ {
naturals <- x
}
close(naturals)
}()
go func() {
for x := range naturals {
squares <- x * x
}
close(squares)
}()
for x := range squares {
fmt.Println(x)
}
}
当然:实际项目当中,每个模块可能会拆分出多个函数或者文件。我们希望有的channel只能接收/发送数据。
out := make(chan<- int)
int := make(<-chan int)
通过功能的拆分和输入/输出channel的区分,上述代码重写为:
package main
import "fmt"
func main() {
naturals := make(chan int)
squares := make(chan int)
go counter(naturals)
go square(naturals, squares)
for x := range squares {
fmt.Println(x)
}
}
//必须及时关闭channel,不然会死锁
func counter(out chan<- int) {
for i := 0; i <= 5; i++ {
out <- i
}
close(out)
}
func square(in <-chan int, out chan<- int) {
for x := range in {
out <- x * x
}
close(out)
}
带缓存的channel
我们可以通过传入make的第二个参数来指定一个带缓存的channel,对于一个缓存不为空的channel,让它执行接收/发送数据都不会阻塞。
package main
import "fmt"
func main() {
ch := make(chan int, 3)
ch <- 1
ch <- 2
fmt.Printf("len: %d, cap: %d\n", len(ch), cap(ch)) //len: 2, cap: 3
fmt.Println(<-ch) // 1
}
并发的循环
对于一个可以拆分成多个子任务的代码,我们可以将每个子任务放到单独的goroutine中,并且在这些子任务处理完成后,主gotoutine中做一些后续的处理。使用无缓冲的channel完成任务的控制固然可以,但并不很好用。我们可以使用sync.WaitGroup完成不同goroutine之间的协同。
package main
import (
"fmt"
"sync"
)
func main() {
strs := []string{"a", "b", "c", "d"}
wg := sync.WaitGroup{}
for _, str := range strs {
//每开启一个goroutine计数器加1
wg.Add(1)
go func(str string) {
fmt.Println(str)
//每个goroutine结束计数器减1
wg.Done()
}(str)
}
//等待所有goroutine结束
wg.Wait()
fmt.Println("all goroutines finished")
}
基于select的多路复用
有的时候我们需要等待不同的goroutine发送不同的消息。以下是一个火箭发射的小例子。
- 当用户在10秒内没有发送指令则火箭发射成功
- 十秒内收到终端任何指令则终止发射
可以看到,我们的程序需要同时监听两个channel的消息。select会等待case中有能够执行的case时去执行。当条件满足时,select才会去通信并执行case之后的语句;这时候其它通信是不会执行的。一个没有任何case的select语句写作select{},会永远地等待下去。
package main
import (
"fmt"
"os"
"time"
)
func main() {
abort := make(chan struct{})
go func() {
os.Stdin.Read(make([]byte, 1))
abort <- struct{}{}
}()
fmt.Println("Commencing countdown. Press q to abort")
select {
case <-time.After(10 * time.Second):
fmt.Println("launch!")
case <-abort:
fmt.Println("abort!")
}
}
需要注意的是:当有多个case都满足条件可以执行的时候,会随机挑选一个去执行:
package main
import (
"fmt"
)
func main() {
//若该channel的缓冲大小大于1,则输出是不确定的
ch := make(chan int, 1)
for i := 0; i < 10; i++ {
select {
case x := <-ch:
fmt.Println(x) // "0" "2" "4" "6" "8"
case ch <- i:
}
}
}
并发的退出
Go语言并没有提供在一个goroutine中终止另一个goroutine的方法。
一种思路是向abort的channel里发送和goroutine数目一样多的事件来退出它们。如果这些goroutine中已经有一些自己退出了,那么会导致我们的channel里的事件数比goroutine还多,这样导致我们的发送直接被阻塞。另一方面,如果这些goroutine又生成了其它的goroutine,我们的channel里的数目又太少了,所以有些goroutine可能会无法接收到退出消息。一般情况下我们是很难知道在某一个时刻具体有多少个goroutine在运行着的。另外,当一个goroutine从abort channel中接收到一个值的时候,他会消费掉这个值,这样其它的goroutine就没法看到这条信息。为了能够达到我们退出goroutine的目的,我们需要更靠谱的策略,来通过一个channel把消息广播出去,这样goroutine们能够看到这条事件消息,并且在事件完成之后,可以知道这件事已经发生过了。
例如下面的例子,有两个goroutine分别接收字符串并打印。
package main
import (
"fmt"
"time"
)
func main() {
out := make(chan string)
go printStr(out)
go printStr(out)
strs := []string{"a", "b", "c", "d", "e", "f", "g", "h", "i", "j", "k"}
for _, str := range strs {
out <- str
}
}
func printStr(in <-chan string) {
for {
time.Sleep(800 * time.Millisecond)
fmt.Println(<-in)
}
}
如何实现当用户在终端输入指令的时候,所有的goroutine都停止打印呢?我们可以利用关闭channel来实现goroutine的广播
package main
import (
"fmt"
"os"
"time"
)
func main() {
out := make(chan string)
go printStr(out)
go printStr(out)
//监听终端输入
go func() {
os.Stdin.Read(make([]byte, 1))
close(out)
}()
strs := []string{"a", "b", "c", "d", "e", "f", "g", "h", "i", "j", "k"}
for _, str := range strs {
out <- str
}
}
func printStr(in <-chan string) {
for {
time.Sleep(800 * time.Millisecond)
select {
case s := <-in:
fmt.Println(s)
default:
break
}
}
}