《Go语言圣经》——Goroutines和Channels

135 阅读8分钟

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
       }
    }
}