通过插图学习 Go 的并发性(译文) | Go主题月

368 阅读4分钟

你很可能经常听到 Go。它越来越受欢迎,这是因为 Go 很快,简单,并有一个很棒的社区。学习该语言最令人兴奋的方面之一是它的并发模型。Go 的原生并发支持使得创建并发,多线程程序变得简单而有趣。我将通过插图介绍 Go 的原生并发支持,希望这些概念在将来的学习中得到应用。这篇文章是为那些刚接触 Go 的人准备的,他们想开始学习 Go 的原生并发支持:go routines 和 channels。

单线程与多线程程序

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

image.png

这就是我们最初如何设置第一个示例的方法,一个挖掘矿石的程序。这个示例中的函数执行:寻矿挖矿炼矿。在我们的示例中,矿山和矿石表示为字符串数组,每个函数接收并返回一个“已处理”的字符串数组。对于单线程应用程序,程序设计如下。

image.png

有3个主要功能。一个寻矿者,一个矿工,一个冶炼工。在这个版本的程序中,我们的函数在一个线程上运行,一个接一个地运行,而这个线程(名为 Gary 的 gopher )将需要完成所有的工作。

func main() {
 theMine := [5]string{“rock”, “ore”, “ore”, “rock”, “ore”}
 foundOre := finder(theMine)
 minedOre := miner(foundOre)
 smelter(minedOre)
}

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

From Finder: [ore ore ore]
From Miner: [minedOre minedOre minedOre]
From Smelter: [smeltedOre smeltedOre smeltedOre]

这种编程风格的优点是易于设计,但是当您想利用多个线程并独立执行函数时会发生什么呢?这就是并发编程发挥作用的地方。

image.png

这种采矿设计效率更高。现在,多个线程( gopher 们)独立工作;因此,并不是让 Gary 完成整个行动。有一个 gopher 在寻找矿石,一只在挖矿,另一只在炼矿 — 可能都是在同一时间进行。

为了将这种功能引入到我们的代码中,我们需要两样东西:一种是创建独立工作的 gopher 的方法,另一种是 gopher 们相互通信(发送矿石)的方法。这就是 Go 的原生并发支持的用武之地:go routineschannels

Go routines

Go routines 可以被认为是轻量级线程。创建 go routines 就像在调用函数的开头添加 go 一样简单。举个简单的例子,让我们创建两个寻矿函数,使用 go 关键字调用它们,并让它们在每次找到矿中的 “矿石” 时打印出来。

以下是我们程序的输出: image.png

func main() {
 theMine := [5]string{“rock”, “ore”, “ore”, “rock”, “ore”}
 go finder1(theMine)
 go finder2(theMine)
 <-time.After(time.Second * 5) // 你现在可以忽略这个
}

以下是我们程序的输出:

Finder 1 found ore!
Finder 2 found ore!
Finder 1 found ore!
Finder 1 found ore!
Finder 2 found ore!
Finder 2 found ore!

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

这是巨大的进步!现在我们有了一个简单的方法来设置多线程(multi-gopher)程序,但是当我们需要独立的 go routines 来相互通信时会发生什么呢?欢迎来到神奇的通道世界

Channels

image.png

通道允许 go routines 相互通信。您可以将通道看作管道,go routines 可以从中发送和接收来自其他 go routines 的信息。

image.png

myFirstChannel := make(chan string)

go routines 可以在一个通道上发送和接收。这是通过使用一个箭头(<-)来完成的,该箭头指向数据传输的方向。

image.png

myFirstChannel <- "hello" // 发送
myVariable := <- myFirstChannel // 接收

现在,通过使用一个通道,我们可以让我们的寻矿地鼠把他们发现的东西立即发送给我们的炼矿地鼠,而不用等待挖完所有矿石后再去炼矿。

image.png

我已经更新了这个示例,所以寻矿代码和矿工的功能被设置为匿名函数。如果您从未见过 lambda 函数,请不要过多地关注程序的这一部分,只需知道每个函数都是用 go 关键字调用的,因此它们是在自己的 go routines 上运行的。重要的是要注意 go routines 是如何使用通道 oreChan 在彼此之间传递数据的。别担心,我会在最后解释匿名函数。

func main() {
 theMine := [5]string{“ore1”, “ore2”, “ore3”}
 oreChan := make(chan string)
 // 寻矿者
 go func(mine [5]string) {
  for _, item := range mine {
   oreChan <- item //send
  }
 }(theMine)
 // 炼矿者
 go func() {
  for i := 0; i < 3; i++ {
   foundOre := <-oreChan //receive
   fmt.Println(“Miner: Received “ + foundOre + “ from finder”)
  }
 }()
 <-time.After(time.Second * 5) // Again, ignore this for now
}

在下面的输出中,您可以看到我们的矿工通过三次读取矿石通道,一次接收一块矿石

Miner: Received ore1 from finder
Miner: Received ore2 from finder
Miner: Received ore3 from finder

很好,现在我们可以在程序中的不同 go routines (gopher)之间发送数据了。在我们开始用通道编写复杂的程序之前,让我们先介绍一些了解通道属性的关键知识。

通道阻塞

在各种情况下,通道会阻塞 Go 程序。这使得我们的 Go 程序能够在独立地进行之前彼此同步一段时间。

阻止发送

image.png

一旦一个 go routines(gopher)在一个通道上发送,发送的 go routines 就会阻塞,直到另一个 go routines 接收到该通道上发送的内容。

阻止接收

image.png

与在通道上发送后阻塞类似,go routines 可以阻塞等待从通道中获取值的过程,但尚未向其发送任何内容。

阻塞一开始可能有点混乱,但您可以将其视为两个 go routines(gopher)之间的事务。不管一只地鼠是在等钱还是在汇款,它都会等到交易中的另一方出现。

现在我们已经了解了 go routines 在通过通道进行通信时可以阻塞的不同方式,让我们讨论两种不同类型的通道:无缓冲通道缓冲通道。所选的通道类型可以决定程序如何执行。

无缓冲通道

image.png

在前面的例子中,我们都使用了无缓冲通道。它们的独特之处在于,一次只能有一段数据通过通道。

缓冲通道

image.png

在并发程序中,计时并不总是完美的。在我们的采矿示例中,我们可能会遇到这样一种情况,即我们的寻矿地鼠可以在炼矿地鼠处理一块矿石所需的时间内找到3块矿石。为了不让寻矿地鼠花费大部分时间等待炼矿地鼠,直到完成,我们可以使用缓冲通道。让我们先创建一个容量为3的缓冲通道。

bufferedChan := make(chan string, 3)

缓冲通道的工作原理与非缓冲通道类似,但只有一个捕获 — 我们可以在需要另一个 go routines 读取数据之前将多个数据段发送到通道

image.png

bufferedChan := make(chan string, 3)
go func() {
 bufferedChan <- "first"
 fmt.Println("Sent 1st")
 bufferedChan <- "second"
 fmt.Println("Sent 2nd")
 bufferedChan <- "third"
 fmt.Println("Sent 3rd")
}()
<-time.After(time.Second * 1)
go func() {
 firstRead := <- bufferedChan
 fmt.Println("Receiving..")
 fmt.Println(firstRead)
 secondRead := <- bufferedChan
 fmt.Println(secondRead)
 thirdRead := <- bufferedChan
 fmt.Println(thirdRead)
}()

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

Sent 1st
Sent 2nd
Sent 3rd
Receiving..
first
second
third

为了简单起见,我们不会在最终程序中使用缓冲通道,但是了解并发工具带中有哪些类型的通道是很重要的。

注意:使用缓冲通道不会阻止阻塞的发生。例如,如果发现的 gopher 比断路器快 10 倍,并且它们通过大小为 2 的缓冲通道通信,则查找 gopher 仍会在程序中多次阻止。

把它们放在一起

现在有了 go routines 和通道的强大功能,我们可以编写一个程序,使用 go 的原生并发支持充分利用多线程。

image.png

theMine := [5]string{"rock", "ore", "ore", "rock", "ore"}
oreChannel := make(chan string)
minedOreChan := make(chan string)
// 寻矿者
go func(mine [5]string) {
 for _, item := range mine {
  if item == "ore" {
   oreChannel <- item //send item on oreChannel
  }
 }
}(theMine)
// 炼矿者
go func() {
 for i := 0; i < 3; i++ {
  foundOre := <-oreChannel //read from oreChannel
  fmt.Println("From Finder: ", foundOre)
  minedOreChan <- "minedOre" //send to minedOreChan
 }
}()
// 矿厂
go func() {
 for i := 0; i < 3; i++ {
  minedOre := <-minedOreChan //read from minedOreChan
  fmt.Println("From Miner: ", minedOre)
  fmt.Println("From Smelter: Ore is smelted")
 }
}()
<-time.After(time.Second * 5) // Again, you can ignore this

该程序的输出如下:

From Finder:  ore
From Finder:  ore
From Miner:  minedOre
From Smelter: Ore is smelted
From Miner:  minedOre
From Smelter: Ore is smelted
From Finder:  ore
From Miner:  minedOre
From Smelter: Ore is smelted

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

为了集中精力理解通道和 go routines 的基础知识,我在上面没有提到一些重要的信息——如果你不知道,在开始编程时可能会带来一些麻烦。既然您已经了解了 go routines 和通道是如何工作的,那么在开始使用 go routines 和通道进行编码之前,让我们回顾一下您应该知道的一些信息。

在这之前,你应该知道。。

匿名 Go 程序

image.png

与使用 go 关键字设置函数在自己的 go routines 上运行类似,我们可以使用以下格式创建匿名函数在自己的 go routines 上运行:

// 匿名 go routine
go func() {
 fmt.Println("I'm running in my own go routine")
}()

main 函数是一个 go routine

image.png

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

<-time.After(time.Second * 5) //Receiving from channel after 5 sec

还记得 go routine 如何阻止读取,直到发送某些内容吗?通过添加上面的代码,这正是主程序所发生的事情。主程序将阻塞,给我们的其他 go 程序 5 秒额外的生命运行。

现在有更好的方法来处理阻塞 main 函数,直到所有其他 go routine 都完成。一种常见的做法是创建一个 done 通道,主程序在等待读取时阻塞该通道。一旦你完成你的工作,发送标识给这个频道,就结束了。

image.png

func main() {
 doneChan := make(chan string)
 go func() {
  // Do some work…
  doneChan <- “I’m all done!”
 }()
 
 <-doneChan // 封锁,直到常规信号工作完成
}

你可以在频道上使用 range

在前面的一个例子中,我们让矿工从一个 for 循环中的一个通道读取,该通道经过3次迭代。如果我们不知道究竟有多少块矿石会从这个开矿者那里出来,会发生什么?嗯,类似于在集合上执行范围,您可以在一个频道上进行范围。

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

 // 炼矿者
 go func() {
  for foundOre := range oreChan {
   fmt.Println(“Miner: Received “ + foundOre + “ from finder”)
  }
 }()

因为矿工需要阅读寻矿者发送给他的所有信息,所以通过这里的通道进行探测可以确保我们收到所有发送的信息。

注意:在一个通道上进行测距将阻塞,直到另一个项目在该通道上发送。在所有发送发生后,阻止 go routine 阻塞的唯一方法是使用 close(channel)关闭通道

您可以在频道上进行非阻塞读取

但你刚刚告诉我们频道是怎么阻止 Go 的?!是的,但是有一种技术可以使用 Go 的 select case 结构在通道上进行非阻塞读取。通过使用下面的结构,您的 go routine 将从通道中读取是否有内容,或者运行默认情况。

myChan := make(chan string)
 
go func(){
 myChan <- “Message!”
}()
 
select {
 case msg := <- myChan:
  fmt.Println(msg)
 default:
  fmt.Println(“No Msg”)
}
<-time.After(time.Second * 1)
select {
 case msg := <- myChan:
  fmt.Println(msg)
 default:
  fmt.Println(“No Msg”)
}

运行时,此示例具有以下输出:

No Msg
Message!

您还可以在通道上执行非阻塞发送

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

select {
 case myChan <- “message”:
  fmt.Println(“sent the message”)
 default:
  fmt.Println(“no message sent”)
}

下一步在哪里学习 Go 呢

image.png

有大量的讲座和博客文章,涵盖了更多的通道细节和 go routine。现在您已经对这些工具的用途和应用有了深入的了解,您应该能够从下面的文章和讨论中获得最大的收获。

Google I/O 2012-Go并发模式

Rob Pike-“并发性不是并行性”

GopherCon 2017:爱德华·穆勒-反模式

谢谢你花时间读这篇文章。我希望你能够学习 go routine,channels,以及它们给编写并发程序带来的好处。

原文连接:medium.com/@trevor4e/l…