Go 学习笔记10 - goroutine 和 channel |Go主题月

577 阅读9分钟

一、goroutines

在 go 语言中,每一个并发执行的单元叫做 goroutine。当一个 go 程序启动时,main 函数就会在一个单独的 goroutine 中运行。只需要在一个函数或者方法调用前加上关键字 go ,这样该函数就会在一个新创建的 goroutine 中运行。

fun main() {
  go func() {
		for i := 0; i < 5; i++ {
			fmt.Println(i)
		}
	}()

	func() {
		fmt.Println("执行其他逻辑") 
	}()

	time.Sleep(time.Millisecond)
}

输出:

执行其他逻辑
0
1
2
3
4

这里在 main 函数中使用了 time.Sleep(time.Millisecond) 目的是为了让主函数多执行一段时间。main函数作为主函数,如果执行完毕退出那么整个程序都会退出。 使用了 go 关键字后 printCount 方法会被异步调用执行,所以主函数执行完毕了 printCount 函数还没来得及输出,整个程序就退出了。

当然我们在真正写代码时不能这么简单粗暴的使用 sleep 来等待一个 goroutine 执行完毕,因为如果它提前执行完了你也要等待这么多时间造成浪费,或者执行耗时长你就提前退出了。这都是不好的。稍候会讲我们通过什么方式来处理这个问题。

在 golang 中开一个 goroutine 异步执行函数操作是非常简单的,但是往往会造成很多滥用现象,从而引发资源浪费或者 goroutine 泄露,所以你要使用它,一定记得管控它的生命周期,什么时候创建,时候时候结束自己一定得像明白。

二、channel

channel 又叫通道,是为了在多个 goroutine 之间传递数据的。传统的多线程数据共享是通过加锁,争抢内存数据。所以 Rob Pike 大佬说了一句名言:

不要通过共享内存来通信,而应该通过通信来共享内存。

意思就是让大家写 golang 的时候多用 channel 来传递数据,尽量少用共享内存的方式。

1.channel 的创建

在 Go 语言中需要使用内置函数 make 来创建一个通道。第二个参数指定通道的容量,如果大于0就是一个有缓冲的 channel。

ch1 := make(chan int)  // 无缓冲的 int 类型 channel
ch2 = make(chan int, 0) // 无缓冲的 int 类型 channel
ch3 := make(chan string, 10) // 有缓冲的 string 类型 channel

和 map 类似,channel 也对应一个 make 创建的底层数据结构的引用。当我们复制一个 channel 或用于函数参数传递时,我们只是拷贝了一个 channel 引用,因此调用者和被调用者将引用同一个 channel 对象。和其它的引用类型一样,channel的零值也是nil。

两个相同类型的channel可以使用 == 运算符比较。如果两个 channel 引用的是相同的对象,那么比较的结果为真。一个 channel 也可以和 nil 进行比较。

fun main() {
  ch1 := make(chan int, 0)
  ch2 := ch1 // ch2 是 ch1 的拷贝
  ch3 := make(chan int, 0)

  go func() {
    ch2 <- 3
  }()

  fmt.Println(ch1 == ch2) // true, ch1 和 ch2引用的是相同的对象
  fmt.Println(ch1 == ch3) // false
  fmt.Println(<-ch2) // 3,ch1 和 ch2引用的是相同的对象
}

channel 收、发数据:

ch := make(chan int) // 创建 channel
x := 3
ch <- x  // 把 x 发送到 ch
x = <-ch //  把 ch 发送到 x
<-ch     // 从 ch 里收数据

channel 支持 close 操作,用来关闭一个 channel。切记:对一个已关闭的 channel 发送数据将导致 panic;对一个已关闭的 channel 进行接收操作则仍然能够收到之前已经发送成功的数据。如果已关闭的 channel 中已经没有数据了,此时会接收到一个 channel 类型对应的零值数据。

close(ch)

2.无缓冲的通道(unbuffered channel)

无缓冲的通道是指在接收前没有能力保存任何值的通道。此类通道要求发送 goroutine 和接收 goroutine 同时准备好,才能完成发送和接收操作。如果两个 goroutine没有同时准备好,通道会导致先执行发送或接收操作的 goroutine 阻塞等待。这种对通道进行发送和接收的交互行为本身就是同步的。其中任意一个操作都无法离开另一个操作单独存在。

对于无缓冲的 channel, 也就是容量为 0 的 channel,从此 channel 中收(读取)数据的调用一定 happens before 往此 channel 发送数据的调用完成。(x happens before y 表示x事件在y事件之前发生,这是 Go 语言并发内存模型的一个关键术语!)。

fun main() {
  ch := make(chan struct{})
	go func() {
		for i := 0; i < 5; i++ {
			fmt.Println(i)
		}
		<-ch
	}()

	func() {
		fmt.Println("执行其他逻辑")
	}()

	ch <- struct{}{}
}

输出:

执行其他逻辑
0
1
2
3
4

这里使用 channel 在 goroutine 之间传递数据,同时解决了使用 sleep 来使 main 延时退出的问题。

3.串联的 channel(Pipeline)

我们可以把一个 channel 的输出作为下一个 channel 的输入,这种串联的 Channel 就是所谓的管道(pipeline)。

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 // 从 naturals 取值作为这里的输入
            squares <- x * x
        }
    }()

    // Printer (这是运行在 main goroutine 中)
    for {
        fmt.Println(<-squares)
    }
}

上面的程序中会生成0、1、4、9、……形式的无穷数列。程序没有循环终止条件,所以会无限输出。这里如果只想 channel 发送有限的数据,可以使用内置函数 close

close(naturals)

之前我们已经讲过,当一个 channel 被关闭后,再向该 channel 发送数据将导致 panic。但是我们可以从一个关闭的 channel 接收值,当接收完发送的数据的后,后续还能一直收到对应 channel 类型的零值。上面的程序,close(naturals) 操作 并不能终止 Squarer 里的循环,它依然会收到一个永无休止的零值序列,然后将它们发送给 Printer goroutine。

我们可以通过下面的方式知道一个 channel 里面是否还有数据:

x, ok := <-naturals // 使用第二个参数接收一个 bool 值
if	!ok {
	// 已经没有数据啦
}

所以我们可以修改之前的程序:

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

但是这样做比较麻烦,我们可以使用 for range 形式,如果 channel 没值了它会自动跳出循环:

func main() {
    naturals := make(chan int)
    squares := make(chan int)

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

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

    // Printer (in main goroutine)
    for x := range squares {
        fmt.Println(x)
    }
}

channel 并不是一定要关闭的,只有在你需要通知接收者所有数据已经全部发送完毕时才使用。不管一个 channel 是否被关闭,当它没有被引用时将会被 Go 语言的垃圾自动回收器回收。(不要将关闭一个打开文件的操作和关闭一个 channel 操作混淆。对于每个打开的文件,都需要在不使用的时候调用对应的 Close 方法来关闭文件)

重复关闭一个 channel 将导致 panic,试图关闭一个 nil 值的 channel 也将导致 panic。

4.有缓冲的通道(buffered channel)

有缓冲的 channel 内部持有一个元素队列。队列的最大容量是在调用make函数创建channel时通过第二个参数指定的。下面的语句创建了一个可以持有三个字符串元素的带缓存 channel。

ch = make(chan string, 3) 

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

我们可以在无阻塞的情况下连续向新创建的channel发送三个值:

ch <- "A"
ch <- "B"
ch <- "C"

此刻,channel的内部缓存队列将是满的,如果有第四个发送操作将发生阻塞。

image.png

如果我们接收一个值:

fmt.Println(<-ch) // "A"

此时 channel 队列既不是满的也不是空的。因此对该 channel 执行的发送或接收操作都不会发生阻塞。通过这种方式,channel的缓存队列解耦了接收和发送的 goroutine。

image.png

在某些特殊情况下,程序可能需要知道 channel 内部缓存的容量和元素个数:

fmt.Println(cap(ch)) // "3" // 使用内置函数 cap
fmt.Println(len(ch)) // "2" // 使用内置函数 len

在继续执行两次接收操作后channel内部的缓存队列将又成为空的,如果有第四个接收操作将发生阻塞:

fmt.Println(<-ch) // "B"
fmt.Println(<-ch) // "C"

这个例子中对 channel 的操作都是在同一个 goroutine 中完成的,但是实际上我们不要这么用。channel主要是用于不同 goroutine 之间传递数据,如果只是一个goroutine 中,请使用 slice。

下面的例子展示了一个使用了带缓存 channel 的应用。它并发地向三个镜像站点发出请求,三个镜像站点分散在不同的地理位置。它们分别将收到的响应发送到带缓存channel,最后接收者只接收第一个收到的响应,也就是最快的那个响应。因此mirroredQuery函数可能在另外两个响应慢的镜像站点响应之前就返回了结果。(顺便说一下,多个goroutines并发地向同一个channel发送数据,或从同一个channel接收数据都是常见的用法。)

func mirroredQuery() string {
    responses := make(chan string, 3)
    go func() { responses <- request("asia.gopl.io") }()
    go func() { responses <- request("europe.gopl.io") }()
    go func() { responses <- request("americas.gopl.io") }()
    return <-responses // return the quickest response
}

func request(hostname string) (response string) { /* ... */ }

如果我们使用了无缓存的channel,那么两个慢的 goroutine 将会因为没有人接收而被永远卡住。这种情况,称为 goroutine 泄漏,这将是一个BUG。和垃圾变量不同,泄漏的 goroutine 并不会被自动回收,因此确保要每个不再需要的 goroutine 能正常退出。