GO语言入门:Goroutines和Channels | 青训营

43 阅读6分钟

Go语言中的并发程序可以用两种手段来实现:CSP,多线程共享内存

CSP(“顺序通信进程”(communicating sequential processes))是一种现代的并发编程模型,在这种编程模型中值会在不同的运行实例(goroutine)中传递,尽管大多数情况下仍然是被限制在单一实例中

Goroutines

在Go语言中,每一个并发的执行单元叫作一个goroutine,可以简单地把goroutine类比作一个线程

当一个程序启动时,其主函数即在一个单独的goroutine中运行:main goroutine,新的goroutine会用go语句来创建(在普通函数或方法前加上go关键字),语句/函数就可以在新创建的goroutine中运行

Channels

如果goroutine是Go语言程序的并发体的话,那么channels则是它们之间的通信机制。一个channel是一个通信机制,可以让一个goroutine通过它给另一个goroutine发送值信息

每个channel都有一个特殊的类型,即channels可发送数据的类型,一个可以发送int类型数据的channel一般写为chan int

使用内置的make函数可创建channel:

  • 无缓存的channel:ch := make(chan int)
  • 带缓存的channel:ch = make(chan int, 3)

channel有发送和接受两个主要操作,都是通信行为,都使用<-运算符

一个发送语句将一个值从一个goroutine通过channel发送到另一个执行接收操作的goroutine

  • 在发送语句中,<-运算符分割channel和要发送的值
  • 在接收语句中,<-运算符写在channel对象之前
  • 不使用接收结果的接收操作也是合法的

close操作可用于关闭channel:close(ch),随后对基于该channel的任何发送操作都将导致panic异常,但是依然可以接受到之前已成功发送的数据;如果channel中已经没有数据将产生一个零值的数据

不带缓存的channels

一个基于无缓存Channels的发送操作将导致发送者goroutine阻塞,直到另一个goroutine在相同的Channels上执行接收操作,当发送的值通过Channels成功传输之后,两个goroutine可以继续执行后面的语句。反之,如果接收操作先发生,那么接收者goroutine也将阻塞,直到有另一个goroutine在相同的Channels上执行发送操作

基于无缓存Channels的发送和接收操作将导致两个goroutine做一次同步操作,因此,无缓存Channels有时候也被称为同步Channels

当两个goroutine并发访问了相同的变量时,有必要保证某些事件的执行顺序,以避免出现某些并发问题

串联的Channels

一个Channel的输出作为下一个Channel的输入,这种串联的Channels就是管道(pipeline)

两个Channels将三个goroutine串联,如下图:

// 将生成0、1、4、9、……形式的无穷数列
func main() {
    naturals := make(chan int)
    squares := make(chan int)

    // Counter
    go func() {
        for x := 0; ; x++ {
            naturals <- x
        }
    }()

    // Squarer
    go func() {
        for {
            x := <-naturals
            squares <- x * x
        }
    }()

    // Printer (in main goroutine)
    for {
        fmt.Println(<-squares)
    }
}

没有办法直接测试一个channel是否被关闭,但是接收操作有一个变体形式:它多接收一个结果,多接收的第二个结果是一个布尔值ok,ture表示成功从channels接收到值,false表示channels已经被关闭并且里面没有值可接收

// Squarer
go func() {
    for {
        x, ok := <-naturals
        if !ok {
            break // channel was closed and drained
        }
        squares <- x * x
    }
    close(squares)
}()

单方向的Channels

当一个channel作为一个函数参数时,它一般总是被专门用于只发送或者只接收(单方向)

箭头<-和关键字 chan 的相对位置表明了channel的方向:

  • 类型chan<- int表示一个只发送 int 的 channel,只能发送不能接收
  • 类型<-chan int表示一个只接收 int 的 channel,只能接收不能发送

关闭操作只用于断言不再向channel发送新的数据,所以只有在发送者所在的goroutine才会调用close函数,因此对一个只接收的channel调用close将编译错误

带缓存的Channels

带缓存的Channel内部持有一个元素队列,队列的最大容量是在调用make函数创建channel时通过第二个参数指定的

ch = make(chan string, 3)

向缓存Channel的发送操作就是向内部缓存队列的尾部插入元素,接收操作则是从队列的头部删除元素

如果内部缓存队列是满的,那么发送操作将阻塞直到因另一个goroutine执行接收操作而释放了新的队列空间。相反,如果channel是空的,接收操作将阻塞直到有另一个goroutine执行发送操作而向队列插入元素。

内置的cap函数获取channel内部缓存的容量:cap(ch)

内置的len函数获取channel内部缓存队列中有效元素的个数:len(ch)

如果多个goroutines并发地向同一个无缓存channel发送数据,可能会出现goroutines泄漏,bug。和垃圾变量不同,泄漏的goroutines并不会被自动回收,因此确保每个不再需要的goroutine能正常退出是重要的

基于select的多路复用

select语句,和switch语句稍微有点相似,也会有几个case和最后的default选择分支。每一个case代表一个通信操作(在某个channel上进行发送或者接收),并且会包含一些语句组成的一个语句块。一个接收表达式可能只包含接收表达式自身(不把接收到的值赋值给变量什么的),就像下面的第一个case,或者包含在一个简短的变量声明中,像第二个case里一样;第二种形式能够引用接收到的值

select {
case <-ch1:
    // ...
case x := <-ch2:
    // ...use x...
case ch3 <- y:
    // ...
default: // 当其它的操作都不能够马上被处理时程序需要执行哪些逻辑
    // ...
}

select会等待case中有能够执行的case时去执行。当条件满足时,select才会去通信并执行case之后的语句;这时候其它通信是不会执行的。一个没有任何case的select语句写作select{},会永远地等待下去

如果多个case同时就绪时,select会随机地选择一个执行,这样来保证每一个channel都有平等的被select的机会

并发的退出

并发的退出是指在 Go 语言中,如何让一个或多个 goroutine 在满足一定条件后停止运行的问题

Go 语言并没有提供终止 goroutine 的接口,即不能从外部去停止一个 goroutine,只能由 goroutine 内部退出

有多种方法可以实现并发的退出,例如:

  • 使用 for-range 结构:这种方法适用于从单一通道上获取数据并执行任务的场景。当通道关闭时,for-range 循环会自动结束,从而退出 goroutine
  • 使用 for-select 结构:这种方法适用于监听多个通道的场景。可以定义一个特定的退出通道,用于接收退出信号。当收到退出信号时,可以使用 return 语句或者将通道赋值为 nil 来退出 goroutine
  • 使用 context 包:这是官方提供的一个用于控制多个 goroutine 协作的包。可以使用 context.WithCancel 函数来创建一个可取消的子 context,并传递给需要退出的 goroutine。当调用 cancel 函数时,所有监听 context.Done 通道的 goroutine 都会收到关闭信号,并退出