你很可能从某种途径听说过 Go 语言。它越来越受欢迎,并且有充分的理由可以证明。 Go 快速、简单,有强大的社区支持。学习这门语言最令人兴奋的一点是它的并发模型。 Go 的并发原语使创建多线程并发程序变得简单而有趣。我将通过插图介绍 Go 的并发原语,希望能点透相关概念以方便后续学习。本文是写给 Go 语言编程新手以及准备开始学习 Go 并发原语 (goroutines 和 channels) 的同学。

单线程程序 vs. 多线程程序

你可能已经写过一些单线程程序。一个常用的编程模式是组合多个函数来执行一个特定任务,并且只有前一个函数准备好数据,后面的才会被调用。

首先我们将用上述模式编写第一个例子的代码,一个描述挖矿的程序。它包含三个函数,分别负责执行寻矿、挖矿和练矿任务。在本例中,我们用一组字符串表示 rock(矿山) 和 ore(矿石),每个函数都以它们作为输入,并返回一组 “处理过的” 字符串。对于一个单线程的应用而言,该程序可能会按如下方式来设计:

它有三个主要的函数:finderminer 和 smelter。该版本的程序的所有函数都在单一线程中运行,一个接着一个执行,并且这个线程 (名为 Gary 的 gopher) 需要处理全部工作。

  1. func main() {
  2. theMine := [5]string{"rock", "ore", "ore", "rock", "ore"}
  3. foundOre := finder(theMine)
  4. minedOre := miner(foundOre)
  5. smelter(minedOre)
  6. }

在每个函数最后打印出 "ore" 处理后的结果,得到如下输出:

  1. From Finder: [ore ore ore]
  2. From Miner: [minedOre minedOre minedOre]
  3. From Smelter: [smeltedOre smeltedOre smeltedOre]

  

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

这种设计使得 “挖矿” 更高效。现在多个线程 (gophers) 是独立运行的,从而 Gary 不再承担全部工作。其中一个 gopher 负责寻矿,一个负责挖矿,另一个负责练矿,这些工作可能同时进行。

为了将这种并发特性引入我们的代码,我们需要创建独立运行的 gophers 的方法以及它们之间彼此通信 (传送矿石) 的方法。这就需要用到 Go 的并发原语:goroutines 和 channels。

Goroutines

Goroutines 可以看作是轻量级线程。创建一个 goroutine 非常简单,只需要把 go 关键字放在函数调用语句前。为了说明这有多么简单,我们创建两个 finder 函数,并用 go 调用,让它们每次找到 "ore" 就打印出来。

  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) //you can ignore this for now
  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!

可以看出,两个 finder 是并发运行的。哪一个先找到矿石没有确定的顺序,当执行多次程序时,这个顺序并不总是相同的。

这是一个很大的进步!现在我们有一个简单的方法来创建多线程 (multi-gopher) 程序,但是当我们需要独立的 goroutines 之间彼此通信会发生什么呢?欢迎来到神奇的 channels 世界。

Channels

Channels 允许 go routines 之间相互通信。你可以把 channel 看作管道,goroutines 可以往里面发消息,也可以从中接收其它 go routines 的消息。

  1. myFirstChannel := make(chan string)

Goroutines 可以往 channel 发送消息,也可以从中接收消息。这是通过箭头操作符 (<-) 完成的,它指示 channel 中的数据流向。

  1. myFirstChannel <-"hello" // Send
  2. myVariable := <- myFirstChannel // Receive

现在通过 channel 我们可以让寻矿 gopher 一找到矿石就立即传送给开矿 gopher ,而不用等发现所有矿石。

我重写了挖矿程序,把寻矿和开矿函数改写成了未命名函数。如果你从未见过 lambda 函数,不必过多关注这部分,只需要知道每个函数将通过 go 关键字调用并运行在各自的 goroutine 中。重要的是,要注意 goroutine 之间是如何通过 channel oreChan 传递数据的。别担心,我会在最后面解释未命名函数的。

  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 //send
  9. }
  10. }(theMine)
  11.  
  12. // Ore Breaker
  13. go func() {
  14. for i := 0; i < 3; i++ {
  15. foundOre := <-oreChan //receive
  16. fmt.Println("Miner: Received " + foundOre + " from finder")
  17. }
  18. }()
  19. <-time.After(time.Second * 5) // Again, ignore this for now
  20. }

  

从下面的输出,可以看到 Miner 从 oreChan 读取了三次,每次接收一块矿石。

  1. Miner: Received ore1 from finder
  2. Miner: Received ore2 from finder
  3. Miner: Received ore3 from finder

太棒了,现在我们能在程序的 goroutines(gophers) 之间发送数据了。在开始用 channels 写复杂的程序之前,我们先来理解它的一些关键特性。

Channel Blocking

Channels 阻塞 goroutines 发生在各种情形下。这能在 goroutines 各自欢快地运行之前,实现彼此之间的短暂同步。

Blocking on a Send

一旦一个 goroutine(gopher) 向一个 channel 发送数据,它就被阻塞了,直到另一个 goroutine 从该 channel 取走数据。

Blocking on a Receive

和发送时情形类似,一个 goroutine 可能阻塞着等待从一个 channel 获取数据,如果还没有其他 goroutine 往该 channel 发送数据。

一开始接触阻塞的概念可能令人有些困惑,但你可以把它想象成两个 goroutines(gophers) 之间的交易。 其中一个 gopher 无论是等着收钱还是送钱,都需要等待交易的另一方出现。

既然已经了解 goroutine 通过 channel 通信可能发生阻塞的不同情形,让我们讨论两种不同类型的 channels: unbuffered 和 buffered 。选择使用哪一种 channel 可能会改变程序的运行表现。

Unbuffered Channels

在前面的例子中我们一直在用 unbuffered channels,它们与众不同的地方在于每次只有一份数据可以通过。

Buffered Channels

在并发程序中,时间协调并不总是完美的。在挖矿的例子中,我们可能遇到这样的情形:开矿 gopher 处理一块矿石所花的时间,寻矿 gohper 可能已经找到 3 块矿石了。为了不让寻矿 gopher 浪费大量时间等着给开矿 gopher 传送矿石,我们可以使用 buffered channel。我们先创建一个容量为 3 的 buffered channel。

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

buffered 和 unbuffered channels 工作原理类似,但有一点不同—在需要另一个 gorountine 取走数据之前,我们可以向 buffered channel 发送多份数据。

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

  

两个 goroutines 之间的打印顺序如下:

  1. Sent 1st
  2. Sent 2nd
  3. Sent 3rd
  4. Receiving..
  5. first
  6. second
  7. third

为了简单起见,我们在最终的程序中不使用 buffered channels。但知道该使用哪种 channel 是很重要的。

注意: 使用 buffered channels 并不会避免阻塞发生。例如,如果寻矿 gopher 比开矿 gopher 执行速度快 10 倍,并且它们通过一个容量为 2 的 buffered channel 进行通信,那么寻矿 gopher 仍会发生多次阻塞。

把这些都放到一起

现在凭借 goroutines 和 channels 的强大功能,我们可以使用 Go 的并发原语编写一个充分发挥多线程优势的程序了。

  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 //send item on oreChannel
  10. }
  11. }
  12. }(theMine)
  13.  
  14. // Ore Breaker
  15. go func() {
  16. for i := 0; i < 3; i++ {
  17. foundOre := <-oreChannel //read from oreChannel
  18. fmt.Println("From Finder:", foundOre)
  19. minedOreChan <-"minedOre" //send to minedOreChan
  20. }
  21. }()
  22.  
  23. // Smelter
  24. go func() {
  25. for i := 0; i < 3; i++ {
  26. minedOre := <-minedOreChan //read from minedOreChan
  27. fmt.Println("From Miner:", minedOre)
  28. fmt.Println("From Smelter: Ore is smelted")
  29. }
  30. }()
  31.  
  32. <-time.After(time.Second * 5) // Again, you can ignore this

  

程序输出如下:

  1. From Finder: ore
  2. From Finder: ore
  3. From Miner: minedOre
  4. From Smelter: Ore is smelted
  5. From Miner: minedOre
  6. From Smelter: Ore is smelted
  7. From Finder: ore
  8. From Miner: minedOre
  9. From Smelter: Ore is smelted

相比最初的例子,已经有了很大改进!现在每个函数都独立地运行在各自的 goroutines 中。此外,每次处理完一块矿石,它就会被带进挖矿流水线的下一个阶段。

为了专注于理解 goroutines 和 channel 的基本概念,上文有些重要的信息我没有提,如果不知道的话,当你开始编程时它们可能会造成一些麻烦。既然你已经理解了 goroutines 和 channel 的工作原理,在开始用它们编写代码之前,让我们先了解一些你应该知道的其他信息。

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

匿名的 Goroutines

类似于如何利用 go 关键字使一个函数运行在自己的 goroutine 中,我们可以用如下方式创建一个匿名函数并运行在它的 goroutine 中:

  1. // Anonymous go routine
  2. go func() {
  3. fmt.Println("I'm running in my own go routine")
  4. }()

  

如果只需要调用一次函数,通过这种方式我们可以让它在自己的 goroutine 中运行,而不需要创建一个正式的函数声明。

main 函数是一个 goroutine

main 函数确实运行在自己的 goroutine 中!更重要的是要知道,一旦 main 函数返回,它将关掉当前正在运行的其他 goroutines。这就是为什么我们在 main 函数的最后设置了一个定时器—它创建了一个 channel,并在 5 秒后发送一个值。

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

还记得 goroutine 从 channel 中读数据如何被阻塞直到有数据发送到里面吧?通过添加上面这行代码,main routine 将会发生这种情况。它会阻塞,以给其他 goroutines 5 秒的时间来运行。

现在有更好的方式阻塞 main 函数直到其他所有 goroutines 都运行完。通常的做法是创建一个 done channel, main 函数在等待读取它时被阻塞。一旦完成工作,向这个 channel 发送数据,程序就会结束了。

  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 // block until go routine signals work is done
  10. }

  

你可以遍历 channel

在前面的例子中我们让 miner 在 for 循环中迭代 3 次从 channel 中读取数据。如果我们不能确切知道将从 finder 接收多少块矿石呢?

好吧,类似于对集合数据类型 (注: 如 slice) 进行遍历,你也可以遍历一个 channel。

更新前面的 miner 函数,我们可以这样写:

  1. // Ore Breaker
  2. go func() {
  3. for foundOre := range oreChan {
  4. fmt.Println("Miner: Received " + foundOre + " from finder")
  5. }
  6. }()

  

由于 miner 需要读取 finder 发送给它的所有数据,遍历 channel 能确保我们接收到已经发送的所有数据。

遍历 channel 会阻塞,直到有新数据被发送到 channel。在所有数据发送完之后避免 go routine 阻塞的唯一方法就是用 "close(channel)" 关掉 channel。

对 channel 进行非阻塞读

但你刚刚告诉我们 channel 如何阻塞 goroutine 的各种情形?!没错,不过还有一个技巧,利用 Go 的 select case 语句可以实现对 channel 的非阻塞读。通过使用这这种语句,如果 channel 有数据,goroutine 将会从中读取,否则就执行默认的分支。

  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. <-time.After(time.Second * 1)
  14.  
  15. select {
  16. case msg := <- myChan:
  17. fmt.Println(msg)
  18. default:
  19. fmt.Println("No Msg")
  20. }

  

程序输出如下:

  1. No Msg
  2. Message!

对 channel 进行非阻塞写

非阻塞写也是使用同样的 select case 语句来实现,唯一不同的地方在于,case 语句看起来像是发送而不是接收。

  1. select {
  2. case myChan <- "message":
  3. fmt.Println("sent the message")
  4. default:
  5. fmt.Println("no message sent")
  6. }

图解 Go 并发的更多相关文章

  1. 8成以上的java线程状态图都画错了,看看这个-图解java并发第二篇

    本文作为图解java并发编程的第二篇,前一篇访问地址如下所示: 图解进程线程.互斥锁与信号量-看完还不懂你来打我 图形说明 在开始想写这篇文章之前,我去网上搜索了很多关于线程状态转换的图,我惊讶的发现 ...

  2. 图解并发与并行-分别从CPU和线程的角度理解

    本文作为图解java并发编程的第三篇,前2篇访问地址如下所示: 图解进程线程.互斥锁与信号量-看完还不懂你来打我 8成以上的java线程状态图都画错了--图解java并发第二篇 一.CPU角度的并发与 ...

  3. 图解Janusgraph系列-并发安全:锁机制(本地锁+分布式锁)分析

    图解Janusgraph系列-并发安全:锁机制(本地锁+分布式锁)分析 大家好,我是洋仔,JanusGraph图解系列文章,实时更新~ 图数据库文章总目录: 整理所有图相关文章,请移步(超链):图数据 ...

  4. 事务的ACID属性,图解并发事务带来问题以及事务的隔离级别

    事务的概述 事务是指作为单个逻辑工作单元执行的一系列操作,要么完全地执行,要么完全地不执行. 事务处理可以确保除非事务性单元内的所有操作都成功完成,否则不会永久更新面向数据的资源.通过将一组相关操作组 ...

  5. MYSQL处理高并发,防止库存超卖(图解)

    抢购场景完全靠数据库来扛,压力是非常大的,我们在最近的一次抢购活动改版中,采用了redis队列+mysql事务控制的方案,画了个简单的流程图: 先来就库存超卖的问题作描述:一般电子商务网站都会遇到如团 ...

  6. UNIX网络编程读书笔记:图解TCP端口号和并发服务器

               图1 TCP服务器在端口21上执行被动打开                                                          图2 客户对服务器的 ...

  7. 图解 Kafka 超高并发网络架构演进过程

    阅读本文大约需要 30 分钟. 大家好,我是 华仔, 又跟大家见面了. 上一篇作为专题系列的第一篇,我们深度剖析了关于 Kafka 存储架构设计的实现细节,今天开启第二篇,我们来深度剖析下「Kafka ...

  8. Java 并发工具包 java.util.concurrent 用户指南

    1. java.util.concurrent - Java 并发工具包 Java 5 添加了一个新的包到 Java 平台,java.util.concurrent 包.这个包包含有一系列能够让 Ja ...

  9. 图解AngularJS Wijmo5和LightSwitch

    Visual Studio 2013 中的 LightSwitch 有新增功能,包括更好的团队开发支持以及在构建 HTML 客户端桌面和 Office 365 应用程序方面的改进.本文结合最新发布的W ...

随机推荐

  1. CentOS部署Kubernetes1.13集群-1(使用kubeadm安装K8S)

    参考:https://www.kubernetes.org.cn/4956.html 1.准备 说明:准备工作需要在集群所有的主机上执行 1.1系统配置 在安装之前,需要先做如下准备.三台CentOS ...

  2. 初识Qt图片显示、平移及旋转

    1.新建一个Qt Gui应用,项目名称为myPicture,基类选择为QMainWindow,类名设置为MainWindow. 2.在mainwindow.h头文件中添加void paintEvent ...

  3. PCB设计工程师面试题

    网上的一套PCB设计工程师面试题,测下你能不能拿90分?  [复制链接]           一.填空 1.PCB上的互连线按类型可分为()和() . 2.引起串扰的两个因素是()和(). 3.EMI ...

  4. vbs获取当前主机IP

    Function GetIP GetIP = ""        Dim objWMIService, colAdapters, objAdapter    strComputer ...

  5. jekyll建站详细教程

    Jekyll是一款静态博客生成器,也是github page支持的后台引擎,所以如果你有以下需求,极力推荐使用jekyll搭建博客,>>浏览我的博客 个性化的展示界面,站点逻辑 个性化的域 ...

  6. 大数据入门第五天——离线计算之hadoop(下)hadoop-shell与HDFS的JavaAPI入门

    一.Hadoop Shell命令 既然有官方文档,那当然先找到官方文档的参考:http://hadoop.apache.org/docs/current/hadoop-project-dist/had ...

  7. 2017-2018-1 20155318 《信息安全系统设计基础》第九周课下实践——实现mypwd

    2017-2018-1 20155318 <信息安全系统设计基础>第九周课下实践--实现mypwd 相关知识 man -k 查找含有关键字的内容 与管道命令结合使用:man -k k1 | ...

  8. Cenos6.6 升级 python3.5.2 安装配置 django1.10

    1 准备编译环境(环境如果不对的话,可能遇到各种问题,比如wget无法下载https链接的文件) yum groupinstall 'Development Tools' yum install zl ...

  9. 【转载】COM 组件设计与应用(十六)——连接点(vc.net)

    原文:http://vckbase.com/index.php/wv/1257.html 一.前言 上回书介绍了回调接口,在此基础上,我们理解连接点就容易多了. 二.原理 图一.连接点组件原理图.左侧 ...

  10. 【HNOI2014】江南乐

    题面 题解 知识引入 - \(SG\)函数 任何一个公平组合游戏都可以通过把每个局面看成一个顶点,对每个局面和它的子局面连一条有向边来抽象成这个"有向图游戏".下面我们就在有向无环 ...