你很可能或多或少听说过Go。它越来越受欢迎。Go快速,简单,并且拥有一个很棒的社区。并发模型是学习这门语言最令人兴奋的方面之一。Go的并发原语使创建并发的多线程程序变得简单而有趣。我将通过插图介绍Go的并发原语,希望能够将这些概念讲清楚以供将来学习。本文适用于Go的新手,以及想要了解Go的并发原语:go routine 和 channel 的学习者。

单线程与多线程程序

你可能以前编写过多个单线程程序。编程中的一个常见模式是具有执行特定任务的多个函数,但是直到程序的前一部分为下一个函数准备好数据时,才会调用这些函数。

通过插图学习 Go 语言的并发_Go

这就是我们最初设定的第一个例子,即开采矿石。本例中的函数执行:寻找矿石、开采矿石和冶炼矿石。在我们的例子中,矿和矿石被表示为一个字符串数组,每个函数接收并返回一个“已处理的”字符串数组。对于单线程应用程序,程序设计如下。

通过插图学习 Go 语言的并发_Go_02

有3个主要函数。finder, miner 和 smelter。在这个版本的程序中,我们的函数在单个线程上运行,一个接一个地运行 -- 而这个单线程(名为Gary的土拨鼠)需要完成所有工作。

  1. func main() {

  2. theMine := [5]string{“rock”, “ore”, “ore”, “rock”, “ore”}

  3. foundOre := finder(theMine)

  4. minedOre := miner(foundOre)

  5. smelter(minedOre)

  6. }

在每个函数的末尾打印得到的“矿石”数组,我们得到以下输出:

  1. From Finder: [ore ore ore]

  2.  

  3. From Miner: [minedOre minedOre minedOre]

  4.  

  5. From Smelter: [smeltedOre smeltedOre smeltedOre]

这种编程方式具有易于设计的优点,但是当你希望利用多线程并执行彼此独立的函数时,会发生什么情况呢?这就是并发编程发挥作用的地方。

通过插图学习 Go 语言的并发_Go_03

这种采矿设计效率要高得多。现在多线程( 土拨鼠们 )独立工作;因此,整个过程并不全由Gray这土拨鼠来做。有一个地鼠寻找矿石,有一个土拨鼠在开采矿石,另一个土拨鼠在冶炼矿石——可能所有这些都是同时发生的。

为了将这种功能引入我们的代码,我们需要两件事:一是创建独立工作的土拨鼠,二是土拨鼠相互通信(发送矿石 )的方式。这就是Go的并发原语: goroutine 和 channel。

Goroutine

Goroutine 可以被认为是轻量的线程。创建一个goroutine只需在调用函数的前面加上 go 这个关键字如此简单。

举个简单的例子,让我们创建两个寻找矿石的函数,使用 go关键字调用它们,并让它们在每次发现矿井中的“矿石”时把他们打印出来。

通过插图学习 Go 语言的并发_Go_04

  1. func main() {

  2. theMine := [5]string{“rock”, “ore”, “ore”, “rock”, “ore”}

  3. go finder1(theMine)

  4. go finder2(theMine)

  5. <-time.After(time.Second * 5) //现在你可以忽略这行代码

  6. }

以下是我们程序的输出结果:

  1. Finder 1 found ore!

  2. Finder 2 found ore!

  3. Finder 1 found ore!

  4. Finder 1 found ore!

  5. Finder 2 found ore!

  6. Finder 2 found ore!

从上面的输出可以看出,寻找矿石函数同时运行。谁先找到矿石没有真正的顺序,当多次运行时,顺序并不总是一样的。

这是很大的进步!现在我们有一个简单的方法来建立多线程(多个土拨鼠 )程序,但是当我们需要独立的 goroutine来相互通信时会发生什么呢?欢迎来到神奇的 channel世界。

Channel

通过插图学习 Go 语言的并发_Go_05

channel允许 goroutine相互通信。你可以将 channel视为管道, goroutine可以从管道发送和接收来自其他 goroutine的信息。

通过插图学习 Go 语言的并发_Go_06

  1. myFirstChannel := make(chan string)

goroutine可以在一个 channel上发送和接收数据。这是通过使用指向数据方向的箭头( <- )来实现的。

通过插图学习 Go 语言的并发_Go_07

  1. myFirstChannel <- "hello" // 发送数据

  2. myVariable := <- myFirstChannel // 接收数据

现在通过使用一个 channel,我们可以让我们的寻找矿石的土拨鼠立即将它们发现的矿石发送给我们的矿石采集土拨鼠,而无需等待发现所有矿石后才将矿石送给矿石采集土拨鼠。

通过插图学习 Go 语言的并发_Go_08

我已经更新了示例代码,以便将寻找矿石函数和采矿函数设置为未命名的函数。如果你从来没有见过 lambda函数就先不需要太关注程序的那部分,只要知道每个函数都是用 go关键字调用的,所以它们就运行在自己的 goroutine里。重要的是要注意如何使用叫做 oreChan的 channel来相互传递数据。别担心,我会在最后解释未命名的函数。

  1. func main() {

  2. theMine := [5]string{“ore1”, “ore2”, “ore3”}

  3. oreChan := make(chan string)

  4.  

  5. // Finder

  6. go func(mine [5]string) {

  7. for _, item := range mine {

  8. oreChan <- item //发送数据

  9. }

  10. }(theMine)

  11.  

  12. // Ore Breaker

  13. go func() {

  14. for i := 0; i < 3; i++ {

  15. foundOre := <-oreChan //接收数据

  16. fmt.Println(“Miner: Received “ + foundOre + “ from finder”)

  17. }

  18. }()

  19. <-time.After(time.Second * 5) // 现在依然可以不用管这行代码

  20. }

在下面的输出中,你可以看到我们的矿工通过三次读取叫做“oreChan”的 channel,每一次收到一块“矿石”。

  1. Miner: Received ore1 from finder

  2.  

  3. Miner: Received ore2 from finder

  4.  

  5. Miner: Received ore3 from finder

太好了,现在我们可以在程序中的不同 goroutine( 土拨鼠 )之间发送数据了。在我们开始编写带有 channel的复杂程序之前,让我们先介绍一些理解 channel属性的关键内容。

Channel的阻塞

在各种情况下, channel会阻塞 goroutine。这就让我们的 goroutine在各自独立快乐的道路上同步了一会儿。

发送端阻塞

通过插图学习 Go 语言的并发_Go_09

一旦一个 goroutine(土拨鼠)在 channel上发送数据,这个发送数据的 goroutine就会阻塞,直到另一个 goroutine从 channel接收到发送的数据。

接收端阻塞

通过插图学习 Go 语言的并发_Go_10

类似于在 channel上发送数据之后的阻塞, goroutine可以阻塞在等待从没有任何数据的 channel上获取数据。

一开始阻塞这个概念可能有点让人不好理解,但你可以把它看作是两个 goroutine( 土拨鼠 )之间的事务。无论一个土拨鼠是在等钱还是在送钱,它都要等到交易中的另一个伙伴出现。

现在我们已经了解了 goroutine在通过 channel进行通信时阻塞的不同方式,让我们讨论两种不同类型的 channel:无缓冲 channel和缓冲 channel。选择你使用的 channel类型可以改变程序的行为方式。

无缓冲 channel

通过插图学习 Go 语言的并发_Go_11

在前面的例子中,我们一直使用无缓冲 channel。使它们独一无二的是,一次只有一条数据适合通过 channel。

缓冲 channel

通过插图学习 Go 语言的并发_Go_12

在并发程序中,时序并不总是完美的。在我们的采矿案例中,我们可能会遇到这样一种情况:我们的寻找矿石土拨鼠可以在采矿土拨鼠处理一块矿石的时间内找到三块矿石。为了不让寻找矿石的土拨鼠将大部分时间花费在发送矿石给采矿土拨鼠并一直等待它接收处理完成,我们可以使用缓冲 channel。让我们开始做一个容量为3的缓冲 channel。

  1. bufferedChan := make(chan string, 3)

缓冲 channel的工作原理与非缓冲 channel相似,只是有一点需要注意:我们可以在需要另外的 goroutine读取 channel之前将多条数据发送到 channel。

通过插图学习 Go 语言的并发_Go_13

  1. bufferedChan := make(chan string, 3)

  2.  

  3. go func() {

  4. bufferedChan <- "first"

  5. fmt.Println("Sent 1st")

  6. bufferedChan <- "second"

  7. fmt.Println("Sent 2nd")

  8. bufferedChan <- "third"

  9. fmt.Println("Sent 3rd")

  10. }()

  11.  

  12. <-time.After(time.Second * 1)

  13.  

  14. go func() {

  15. firstRead := <- bufferedChan

  16. fmt.Println("Receiving..")

  17. fmt.Println(firstRead)

  18. secondRead := <- bufferedChan

  19. fmt.Println(secondRead)

  20. thirdRead := <- bufferedChan

  21. fmt.Println(thirdRead)

  22. }()

我们两个 goroutine之间的打印顺序是:

  1. Sent 1st

  2. Sent 2nd

  3. Sent 3rd

  4. Receiving..

  5. first

  6. second

  7. third

为了简单起见,我们不会在最终示例程序中使用缓冲 channel,但了解并发工具中可用的 channel类型很重要。

注意:使用缓冲 channel不会阻止发生阻塞。例如,如果寻矿土拨鼠比采矿土拨鼠快10倍,并且他们通过大小为2的缓冲 channel进行通信,则寻矿土拨鼠仍将在程序中多次被阻塞。

把这些概念放在一起

现在通过 goroutine和 channel的强大功能,我们可以编写一个程序,使用Go的并发原语充分利用多个线程。

通过插图学习 Go 语言的并发_Go_14

  1. theMine := [5]string{"rock", "ore", "ore", "rock", "ore"}

  2. oreChannel := make(chan string)

  3. minedOreChan := make(chan string)

  4.  

  5. // Finder

  6. go func(mine [5]string) {

  7. for _, item := range mine {

  8. if item == "ore" {

  9. oreChannel <- item //发送数据给 oreChannel

  10. }

  11. }

  12. }(theMine)

  13.  

  14. // Ore Breaker

  15. go func() {

  16. for i := 0; i < 3; i++ {

  17. foundOre := <-oreChannel //从 oreChannel 读取数据

  18. fmt.Println("From Finder: ", foundOre)

  19. minedOreChan <- "minedOre" //发送数据给 minedOreChan

  20. }

  21. }()

  22.  

  23. // Smelter

  24. go func() {

  25. for i := 0; i < 3; i++ {

  26. minedOre := <-minedOreChan //从 minedOreChan 读取数据

  27. fmt.Println("From Miner: ", minedOre)

  28. fmt.Println("From Smelter: Ore is smelted")

  29. }

  30. }()

  31.  

  32. <-time.After(time.Second * 5) // 依然可以忽略这行代码

上述代码的输出如下:

  1. From Finder: ore

  2.  

  3. From Finder: ore

  4.  

  5. From Miner: minedOre

  6.  

  7. From Smelter: Ore is smelted

  8.  

  9. From Miner: minedOre

  10.  

  11. From Smelter: Ore is smelted

  12.  

  13. From Finder: ore

  14.  

  15. From Miner: minedOre

  16.  

  17. From Smelter: Ore is smelted

这比我们原来的例子有了很大的改进!现在,我们的每个函数都是独立运行的。而且,每次有一块矿石被加工,它就进入我们采矿线的下一个阶段。

为了将注意力集中在理解 channel和 goroutine上,我上面没有提到一些重要的信息——如果你不知道这些信息,在你开始编程时可能会引起一些麻烦。现在你已经了解了 goroutine和 channel的工作方式,让我们先看一看你应该知道的一些信息,然后再开始使用 goroutine和 channel进行编程。

在你开始之前,你应该知道..

匿名 goroutine

通过插图学习 Go 语言的并发_Go_15

与我们如何用关键字 go 设置一个函数运行在它自己的 goroutine里相似,我们可以用如下格式来创建一个匿名函数运行在它自己的 goroutine里:

  1. // 匿名`goroutine`

  2. go func() {

  3. fmt.Println("I'm running in my own go routine")

  4. }()

如此一来,如果我们只需要调用一次函数,我们可以将它放在它自己的 goroutine中运行,而不用创建正式的函数声明。

主函数是一个 goroutine

通过插图学习 Go 语言的并发_Go_16

主函数确实在其自己的 goroutine中运行!更重要的是要知道,一旦主函数返回,它将关闭当前正在运行的其他所有 goroutine。这就是为什么我们在主函数底部有一个定时器 -- 它创建了一个 channel,并在5秒后发送了一个值。

  1. <-time.After(time.Second * 5) //5秒后从channel获得一个值

还记得一个 goroutine会如何在一个 channel读取数据的时候一直阻塞到有数据发送给了这个 channel吗?通过添加上面的代码,主函数会阻塞,给我们其他的 goroutine5秒额外的时间来运行。

现在有更好的方法来处理阻塞主函数,直到所有其他的 goroutine完成。通常的做法是创建 done channel,主函数在它上面读取数据从而被阻塞。一旦你完成你的工作,写入这个 channel,程序将结束。

通过插图学习 Go 语言的并发_Go_17

  1. func main() {

  2. doneChan := make(chan string)

  3.  

  4. go func() {

  5. // Do some work…

  6. doneChan <- “I’m all done!”

  7. }()

  8.  

  9. <-doneChan // 阻塞到上面的goroutine给这个doenChan写入数据

  10. }

你可以在 channel 上使用 range

在前面的一个例子中,我们让矿工从for循环中经过3次迭代从 channel中读取数据。如果我们不知道到底有多少矿石会从发现者那里送过来,会发生什么?好吧,类似于在集合上使用 range,你可以在一个 channel使用 range。

更新我们以前的矿工函数,我们可以写成这样:

  1. // Ore Breaker

  2. go func() {

  3. for foundOre := range oreChan {

  4. fmt.Println(“Miner: Received “ + foundOre + “ from finder”)

  5. }

  6. }()

因为矿工需要读取寻矿者发送给他的所有内容,所以通过在这里的 channel使用 range可以确保我们收到发送的所有内容。

注意:在一个 channel上使用 range将被阻塞,直到在 channel上发送了另一个数据。在所有需要发送的数据发送完后,停止 goroutine被阻塞的唯一方法是用“close(channel)”关闭 channel

你可以在 channel 上进行非阻塞读取

但你不是刚刚告诉我们 channel如何阻塞 goroutine的吗?!确实如此,但有一种技术可以使用Go的select case结构在 channel上进行非阻塞式读取。通过使用下面的结构,如果有某些事情发生,你的 goroutine将从 channel中读取,或运行默认情况。

  1. myChan := make(chan string)

  2.  

  3. go func(){

  4. myChan <- “Message!”

  5. }()

  6.  

  7. select {

  8. case msg := <- myChan:

  9. fmt.Println(msg)

  10. default:

  11. fmt.Println(“No Msg”)

  12. }

  13.  

  14. <-time.After(time.Second * 1)

  15.  

  16. select {

  17. case msg := <- myChan:

  18. fmt.Println(msg)

  19. default:

  20. fmt.Println(“No Msg”)

  21. }

运行后,上述例子的输出如下:

  1. No Msg

  2. Message!

你也可以在 channel 上进行非阻塞发送

非阻塞发送使用相同的select case结构来执行它们的非阻塞操作,唯一的区别是我们的情况看起来像发送而不是接收。

  1. select {

  2. case myChan <- “message”:

  3. fmt.Println(“sent the message”)

  4. default:

  5. fmt.Println(“no message sent”)

  6. }

通过插图学习 Go 语言的并发_Go_18