编程人员提及最多的词,除了“bug”,可能就是“并发”了。程序的并发能力在某种程度上,也反映了软件质量及开发者的水平。可见并发在现代编程中的重要性。在本章节,我会介绍并发的相关概念,以及 Go 的原生并发能力——goroutine。

10.1 并发的意义

在前面章节的示例中,事件是顺序发生的,执行过程也是顺序执行的。也就是说,在前一行代码执行完成前,不会执行下一行代码。

为了更形象一些,我们举一个餐厅服务员为顾客点餐的例子。在顾客阅览菜单前,或者在服务员向顾客提供菜单前,服务员是不会向厨房下达做哪道菜的指令的,因为他(她)并不知道顾客想吃什么。服务员为顾客提供食物的步骤如下:

  • 为顾客提供菜单

  • 记录顾客的订单

  • 把订单内容发送给厨师

  • 从厨师那里获取食物

  • 把食物提供给顾客

如果把这一系列事件编写为代码,那么前一个事件完成前,是不能执行下一个事件的。这个过程很好的体现了代码按照它在脚本中出现的顺序执行的思想,即事情一件一件的发生。

随着应用场景的复杂化,程序也被要求能够处理更加复杂的情况,同时还要保障每次响应都能快速执行。程序按顺序执行,如果其中一行执行完成需要很长时间,则整个执行过程可能被阻塞。这会导致用户长时间等待、甚至终止操作。所以,在执行另一个操作前,不必再等待前一个事件的执行结果,这种需求正变的越来越迫切。

在实际编程中,有许多无法预测时间的因素需要处理。例如,完成网络调用耗时多久,从磁盘读取文件会花费多长时间等。

假设某个程序调用气象服务以获取当前位置的天气情况,一旦请求发出,有很多因素会影响到返回速度:

  • DNS 解析气象服务地址的速度;

  • 程序与气象服务间的网络连接速度;

  • 与气象服务建立连接的速度;

  • 气象服务应用程序响应的速度。

这些因素都超出了控制范围,因此其响应速度基本无法预测。此外,每个请求可能需要花费不同的时间来响应。面对这种情况,程序员可以选择阻塞程序并等待响应,直到响应返回为止,或者继续进行其他相关工作。大多数编程语言是提供一种等待响应的方法,但允许程序员继续处理其他事情。

伴随互联网的发展,微服务和分布式的架构已经司空见惯。一个应用可能会调用多个应用获取信息,这些应用可能处于不同的网络。所有事件都是基于网络的,因此很难可靠地预测何时完成。

回到餐厅服务员的示例中,服务员为顾客提供食物的每个步骤都有不确定性:

  • 顾客坐下来需要多久?

  • 顾客多长时间做出选择?

  • 服务员可以多快下单?

  • 厨师多快可以接收订单?

  • 厨师多快做好食物?

如果服务员按照顺序执行,他可以很好的服务该桌顾客,但却无法为其他客人提供服务。如果为每桌顾客都分配服务员,成本会非常高。这就需要一个服务员同时为多桌顾客提供服务了。他们可以在厨师烹饪食物的同时为其他顾客点餐,并且可以在其他顾客翻阅菜单时,为更早来的顾客上菜。

在生活中,大多数事情也是同时发生的。我们经常在乘坐地铁或公交时听音乐,或者在排队时闲聊。对于编程语言而言,提供一种对此建模的方法是非常有意义的。

10.2 并发与并行

我们知道了为什么需要并发。在讨论 goroutine 之前,了解并发与并行之间的区别尤为重要。为此,我将使用烘烤100个蛋糕作为例子。

顺序烤法是一次在烤箱中烘烤一个蛋糕,必须等到一块蛋糕烘烤完成,才能将另一块蛋糕放进烤箱。显然,效率低,会花费很长时间,并且完成时间也不可预测。

并发烤法是使用烤盘一次烤多个蛋糕,这样可以大大提高效率。但这并不意味着所有蛋糕都是同时烤好的,因为在烤箱中的位置不同,或者蛋糕的大小不同,需要烘烤的时间也不同。与顺序法相比,根据可以同时烘烤的数量,烘烤速度将成倍加快。

并发受许多因素限制,其中之一是烤箱大小。如果还有另外一个烤箱,可以在两个烤箱之间分配蛋糕进一步提高效率,两个烤箱并行工作,最后将两个烤箱的成果合并。并行可以选择是否利用并发,它更多的是将工作分工并最终合并。

尽管这些看起来很复杂,但可以总结为:

  • 并行:指在同一时刻,有多条指令在多个处理器上同时执行;

  • 并发:指在同一时刻,只能有一条指令被执行,但多个进程指令被快速的轮换执行,使得在宏观上具有多个进程同时执行的效果,但在微观上并不是同时执行的(有的蛋糕先烤好,有的后烤好),只是把时间分成若干段,使多个进程快速交替的执行。

从并行用到了两台烤箱可知,提高并行,需要硬件支持;从使用烤盘一次烘烤多个蛋糕可知,提高并发需要的是技术。毕竟,控制一个和同时控制一百个蛋糕的烘烤,难度系数是不同的。

10.3 通过浏览器了解并发

浏览器几乎是我们每天都在使用的工具,用来访问 Web 页面,它加载页面的速度非常快。浏览器背后的技术,就使用了大量的并发来组装并展示一个 Web 页面给用户。页面通常由来自 Internet 上不同服务器上的脚本和图片组成。

为了帮助我们理解并发,可以打开浏览器,查看正在组装的页面。打开 Google Chrome 浏览器(其他浏览器也可以),同时按下功能键(Fn)和F12键,调出开发者工具页面,并点击“Network”按钮。

在网址栏输入 https://www.aiops.red,然后回车。你将看到浏览器发出了很多请求。对于每个请求,都可以看到用了多长时间以及时间花在哪里。当然,这些都不是主要的,重要的是浏览器发出了83个请求,这些请求是并发请求,以便浏览器尽可能快的速度呈现页面(或页面的一部分)。如下图。

www.aiops.red

10.4 阻塞代码

为了进一步帮助我们了解并发,这里有一段程序。该程序使用了 time.Sleep 函数,使程序休眠,用以模拟阻塞。事实上,你可以把它理解为一个缓慢的网络调用或慢函数执行所导致的阻塞。

package main

import (
 "fmt"
 "time"
)

func slowFunc() {
 time.Sleep(2 * time.Second)
 fmt.Println("sleeper 2 second finished.")
}

func main()  {
 slowFunc()
 fmt.Println("I am not shown until slowFunc() completes.")
}

代码释义:

  • 调用 slowFunc 函数,该函数调用 time.Sleep 方法;

  • time.Sleep 将执行休眠两秒钟;

  • 两秒钟后,slowFunc 函数完成并打印”sleeper 2 second finished.”;

  • 执行函数返回到 main 函数,继续执行第二行代码,打印”I am not shown until slowFunc() completes.”。

这段代码模拟了阻塞,因为在程序等待 slowFunc() 返回时不会有其他代码执行。

10.5 使用 goroutines 处理并发

Go 提供了 goroutine 处理并发操作,在下列示例中,goroutine 和 main 函数的第二行几乎同时执行。也就是说,slowFunc 函数正常执行,并且不再阻塞 main 函数中其他行执行。

goroutine 的使用非常简单,只需要将 go 关键字放在需要 goroutine 执行的任何“函数调用”前:

package main

import (
 "fmt"
 "time"
)

func slowFunc() {
 time.Sleep(2 * time.Second)
 fmt.Println("sleeper 2 second finished.")
}

func main() {
 go slowFunc()
 fmt.Println("I am not shown until slowFunc() completes.")
}

执行代码,并没有看到 slowFunc 执行的结果。这是因为 main 函数继续,并且在 main 函数执行结束后,程序退出了。我们需要一种手段来阻止程序退出。go 提供了 channels 管理 goroutines,但这是下章的内容。本章节,我们依然使用 time.Sleep 函数:

package main

import (
 "fmt"
 "time"
)

func slowFunc() {
 time.Sleep(2 * time.Second)
 fmt.Println("sleeper 2 second finished.")
}

func main() {
 go slowFunc()
 fmt.Println("I am not shown until slowFunc() completes.")
 time.Sleep(3 * time.Second)
}

再次执行程序,结果就是我们预期的了。

10.6 使用 routines 处理延迟

程序常有访问外部站点的需求。goroutines 发挥作用的一个很好例子就是处理网络延迟,尤其是不可控的第三方网络。

假设,我们需要访问三个外部站点,每个外部站点响应请求需要1秒,如果我们的程序是顺序执行,则需要3秒才能完成。通过 goroutines 并发请求,程序仅需1秒就可以完成了。而且顺序执行中的响应顺序将与它们在脚本中声明的顺序相同,在实际场景中,这也会出现前一个请求比后一个更慢或更快的情况,无法预测。使用 goroutines,可以同时发出请求,而无需考虑它们在代码中的顺序。

假设三个需要测试的网站,我们可以保存在切片里:

urls := make([]string, 3)
urls[0] = "https://www.aiops.red/"
urls[1] = "https://www.github.com/"
urls[2] = "https://golang.google.cn/"

遍历切片,发起请求并打印响应时间:

for _, url := range urls {
    responseTime(url)
}

发起请求并记录响应时间的代码如下:

func responseTime(url string)  {
 start := time.Now()
 res, err := http.Get(url)

 if err != nil {
  log.Fatal(err)
 }

 defer res.Body.Close()

 elapsed := time.Since(start).Seconds()

 fmt.Printf("%s took %v seconds\n", url, elapsed)

}

通过 for、range 组合,urls 切片会重复调用 responseTime 函数。在执行代码前,请确保主机有网连接。网络质量与地理位置,会影响到响应速度:

package main

import (
 "fmt"
 "log"
 "net/http"
 "time"
)

func responseTime(url string) {
 start := time.Now()
 res, err := http.Get(url)

 if err != nil {
  log.Fatal(err)
 }

 defer res.Body.Close()

 elapsed := time.Since(start).Seconds()

 fmt.Printf("%s took %v seconds\n", url, elapsed)

}

func main() {
 urls := make([]string, 3)
 urls[0] = "https://www.aiops.red/"
 urls[1] = "https://www.github.com/"
 urls[2] = "https://golang.google.cn/"

 for _, url := range urls {
  responseTime(url)
 }
}

你可以把 url 换成不同地区不同国家的网站,观察下响应时间的变化。

执行代码,URL 请求的顺序始终和它们在代码中出现的顺序一致。这是因为请求是按顺序进行的,所以一次只能完成一个请求。有些时候,对网络延迟非常敏感,比如金融或证券系统。

我们在 responseTime 函数前加上 go 关键词,使用 goroutines 方式,再次执行代码。当然,别忘了在 main 函数中加 sleep:

package main

import (
 "fmt"
 "log"
 "net/http"
 "time"
)

func responseTime(url string) {
 start := time.Now()
 res, err := http.Get(url)

 if err != nil {
  log.Fatal(err)
 }

 defer res.Body.Close()

 elapsed := time.Since(start).Seconds()

 fmt.Printf("%s took %v seconds\n", url, elapsed)

}

func main() {
 urls := make([]string, 3)
 urls[0] = "https://www.aiops.red/"
 urls[1] = "https://www.github.com/"
 urls[2] = "https://golang.google.cn/"

 for _, url := range urls {
  go responseTime(url)
 }

 time.Sleep(5 * time.Second)
}

从执行结果发现,返回结果按响应时间排序了,总是响应时间最短的那个,最先返回。从结果可以说明,go 关键字使 responseTime 函数被并发调用,对网站的请求是同时发出的,而不是一个接一个的顺序发出了。当每个请求完成时,它将响应时间打印到控制台。在这个场景中使用 Goroutines 有很多优点:

  • 执行这三个请求所需的时间更短,因为它们是并发执行的,而不是一个接一个地发出请求。

  • 一旦收到响应,就可以立即使用它。不需要等待之前的请求完成。

  • url 在脚本中以何种顺序出现并不重要。一旦收到响应,数据就可用。

Goroutines 不能消除网络延迟,也不知道哪个站点响应更快。但它提供了一种通过 go 关键字提高编程效率的方法,降低了网络延迟的影响。如果你有使用过线程和锁定,你肯定知道管理并发是一件复杂且痛苦的任务。但 Go 为程序员提供了 go 关键字来实现这一点,正如其名,它是该语言最强大、最优雅的功能之一。

10.7 Goroutine 的定义

如果你有其他编程语言的使用经验,应该知道并发是编程语言的一个常见特性。例如 Node.js 使用事件循环管理并发、Java 是使用线程。总之,有很多方法可以解决并发问题,它们以不同方式使用计算机的资源,从而使开发者更便利或者更繁琐的编写高可靠软件。

和 Java 类似,Go 语言在后台使用线程管理并发。不同的是,开发者不需要直接管理线程,因为在 Go 语言的设计中引入了 goroutine 的概念,消除了开发者直接使用线程的麻烦,原生支持并发编程。goroutine 又称为 go 协程,它比线程更易用、更高效、更轻便。创建一个 goroutine 只需要几 KB 的内存,CPU 消耗也极小,一颗 CPU 调度的规模不下于每秒百万次,因此同时运行成千上万个并发任务,也不会消耗太多资源。

10.8 小结

本章节介绍了并发与并行的概念及区别,以及并发对现在程序的重要意义。介绍了 Go 语言的并发实现,以及如何创建 goroutine。我们可以认为 main 函数为主协程,go 创建的协程为工作协程。主协程退出后,工作协程也会立即退出。

文档更新时间: 2021-11-08 10:23   作者:admin