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

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

单线程与多线程程序

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

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

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

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]

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

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

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

Goroutine

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

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

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!

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

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

Channel

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

myFirstChannel := make(chan string)

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

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

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

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

func main() {
 theMine := [5]string{“ore1”, “ore2”, “ore3”}
 oreChan := make(chan string)

 // Finder
 go func(mine [5]string) {
  for _, item := range mine {
   oreChan <- item //发送数据
  }
 }(theMine)

 // Ore Breaker
 go func() {
  for i := 0; i < 3; i++ {
   foundOre := <-oreChan //接收数据
   fmt.Println(“Miner: Received “ + foundOre + “ from finder”)
  }
 }()
 <-time.After(time.Second * 5) // 现在依然可以不用管这行代码
}

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

Miner: Received ore1 from finder

Miner: Received ore2 from finder

Miner: Received ore3 from finder

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

Channel的阻塞

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

发送端阻塞

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

接收端阻塞

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

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

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

无缓冲 channel

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

缓冲 channel

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

bufferedChan := make(chan string, 3)

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

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

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

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

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

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

把这些概念放在一起

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

theMine := [5]string{"rock", "ore", "ore", "rock", "ore"}
oreChannel := make(chan string)
minedOreChan := make(chan string)

// Finder
go func(mine [5]string) {
 for _, item := range mine {
  if item == "ore" {
   oreChannel <- item //发送数据给 oreChannel
  }
 }
}(theMine)

// Ore Breaker
go func() {
 for i := 0; i < 3; i++ {
  foundOre := <-oreChannel //从 oreChannel 读取数据
  fmt.Println("From Finder: ", foundOre)
  minedOreChan <- "minedOre" //发送数据给 minedOreChan
 }
}()

// Smelter
go func() {
 for i := 0; i < 3; i++ {
  minedOre := <-minedOreChan //从 minedOreChan 读取数据
  fmt.Println("From Miner: ", minedOre)
  fmt.Println("From Smelter: Ore is smelted")
 }
}()

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

上述代码的输出如下:

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

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

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

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

匿名 goroutine

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

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

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

主函数是一个 goroutine

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

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

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

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

func main() {
 doneChan := make(chan string)

 go func() {
  // Do some work…
  doneChan <- “I’m all done!”
 }()

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

你可以在 channel 上使用 range

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

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

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

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

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

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

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

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!

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

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

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

下一步学习

有很多讲座和博客文章涵盖 channelgoroutine的更详细内容。既然你对这些工具的目的和应用有了深刻的理解,那么你应该能够充分利用以下文章和讲座。

  • https://www.youtube.com/watch?v=f6kdp27TYZs&t=938s
  • https://www.youtube.com/watch?v=cN_DpYBzKso
  • https://www.youtube.com/watch?v=ltqV6pDKZD8&t=1315s

谢谢你抽出时间来阅读这篇文章。我希望你能够了解 goroutinechannel以及它们为编写并发程序带来的好处。

原文链接: https://medium.com/@trevor4e/learning-gos-concurrency-through-illustrations-8c4aff603b3 翻译链接:https://szpzs.oschina.io/2018/06/23/learning-gos-concurrency-through-illustrations/

本文分享自微信公众号 - 我的小碗汤(mysmallsoup)

原文出处及转载信息见文内详细说明,如有侵权,请联系 yunjia_community@tencent.com 删除。

原始发表时间:2019-11-02

本文参与腾讯云自媒体分享计划,欢迎正在阅读的你也加入,一起分享。

发表于

我来说两句

0 条评论
登录 后参与评论

相关文章

来自专栏腾讯云TVP

golang 服务平滑重启小结

在业务快速增长中,前期只是验证模式是否可行,初期忽略程序发布重启带来的暂短停机影响。当模式实验成熟之后会逐渐放量,此时我们的发布停机带来的影响就会大很多。我们整...

1.5K10
来自专栏PingCAP的专栏

Hands-on! 如何给 TiDB 添加新系统表

对于一个成熟的数据库来说,通过 SQL 来查询系统本身的状态再正常不过,对于 MySQL 来说 INFOMATION_SCHEMA 和 PERFORMANCE_...

11820
来自专栏01ZOO

golang context实战

来自官方文档: https://blog.golang.org/context: Incoming requests to a server should cr...

638350
来自专栏SH的全栈笔记

go源码解析-Println的故事

Println函数接受参数a,其类型为…interface{}。用过Java的对这个应该比较熟悉,Java中也有…的用法。其作用是传入可变的参数,而interf...

10620
来自专栏王清培的专栏

golang 服务平滑重启小结

在业务快速增长中,前期只是验证模式是否可行,初期忽略程序发布重启带来的暂短停机影响。当模式实验成熟之后会逐渐放量,此时我们的发布停机带来的影响就会大很多。我们整...

8020
来自专栏SH的全栈笔记

用go-module作为包管理器搭建go的web服务器

本篇博客主要介绍了如何从零开始,使用Go Module作为依赖管理,基于Gin来一步一步搭建Go的Web服务器。并使用Endless来使服务器平滑重启,使用Sw...

14920
来自专栏容器云生态

再读Golang中的异常处理 顶

注意:如果一个没有recover的goroutine发生了panic,那么整个进程都会挂掉

7140
来自专栏技术小屋

go-web 服务端框架性能测试

由于K8s缘故涉猎go语言,发现golang的web框架很多,都号称高性能标杆;之前一直致力于c++高性能服务端框架研究,出于好奇,想单从性能层面客观比较一下g...

22430
来自专栏SH的全栈笔记

Go中使用Seed得到重复随机数的问题

可能不熟悉seed用法的看到这里会很疑惑,我不是都用了seed吗?为何我随机出来的数字都是一样的?不应该每次都不一样吗?

6520
来自专栏EffectiveCoding

Go panic & recover

之前针对于go 的错误和异常做了简单的介绍,对于panic介绍的不算多,本篇从原理和源码的角度来看一下panic 和 recover是怎么运作的。 panic...

8420

扫码关注云+社区

领取腾讯云代金券

年度创作总结 领取年终奖励