接下来了几篇文章,我们会重点讨论有关 Go 的并发编程以及常见的技术手段。当然,所有问题我们都需要从实例出发,所有的实例均来源于《The Go Programming Language》一书的第 8 章。

1. 缩略图计算程序

这个例子的目的很简单,给定多幅 jpg 图片,通过调用 Go 里的缩略图处理函数,生成相应的缩略图存储到本地。我们的目的不是写一个如何将图像处理成缩略图的函数,所以,你不必再大废周折的再写一套这样的函数

有关缩略图的计算程序,在《The Go Programming Language》附带的源码里已经提供,我们好好的做调包侠就行。如果你觉得下载他的源码很麻烦,你就用我这里已经准备好的代码:

​https://gitee.com/ivan_allen/gopl​​​ (代码路径 ​​gopl/goroutine/thumbnail​​). 另外,我还为你精心准备了 4 幅 jpg 图片放在该目录下,省去你找图片的时间。

一切准备就绪后,我们的第一个示例如下:

// demo01.go
// +build ignore

package main

import (
"fmt"
"log"
"os"
"time"

"gopl/goroutine/thumbnail"
)

func makeThumbnails(filenames []string) {
for _, f := range filenames {
// thumbnail 包里的 ImageFile 专门用于计算图像文件的缩略图
if _, err := thumbnail.ImageFile(f); err != nil {
log.Println(err)
}
}
}

func elapse() func() {
now := time.Now()
return func() {
fmt.Printf("elapse:%.3f ms\n", 1000*time.Since(now).Seconds())
}
}

func main() {
defer elapse()()
makeThumbnails(os.Args[1:])
}

上面这段程序有必要先解释一下:

  • 第二行注释​​// +build ignore​​​ 是有必要存在的。这里需要它的原因是我们把 main 包和 thumbnail 写到了同一个文件夹下面。如果没有这一行你使用​​go run demo01.go​​ 运行就会失败。换句话说,这行注释是写给 go 编译器看的。未来我们会学习到更多类似这样的注释,这里知道即可。
  • ​makeThumbnails​​ 函数的参数是一个 string 数组,保存的是图片文件名称。这个函数将为所有图片文件生成缩略图
  • ​elapse​​​ 函数是一个结合 defer 语法和闭包的时间打点函数,很久以前你就学习过了,千万别在问我原理。在这里我们使用​​elapse​​​ 函数来统计​​makeThumbnails​​ 函数的运行时间。

OK,一切准备就绪,我们就来运行一下看看:

$ go run demo01.go 1.jpeg 2.jpeg 3.jpeg 4.jpeg

在我机器上会输出:

elapse:41.962 ms

同时你的文件夹下面会生成 ​​1.thumb.jpeg 2.thumb.jpeg 3.thumb.jpeg 4.thumb.jpeg​​ 这几个缩略图文件。

下面是我统计的一个生成不同数量缩略图时的耗时情况:


067-Go 并发编程(一)_goroutine


图1 不同数量图片执行耗时


可以看到,随着数量增加,耗时也在增加。因为在上面的代码里,我们的程序是串行计算的。换句话说,我们的程序是在计算完 1.jpeg 后,再计算 2.jpeg。

很明显,串行计算并不能充分的发挥多核 cpu 的优势。再说了,生成这 4 幅 jpg 图片的缩略图顺序对我们来说根本无关紧要,而且它们之间也不会相互影响。既然如此,我们就可以——开启 goroutine,并发的去生成每幅图的缩略图。

2. 并发计算缩略图

2.1 版本一

接下来,我们把重心放到 makeThumbnails 函数上面。我们把新的程序保存在 demo02.go 中。

// +build ignore

package main

import (
"fmt"
"os"
"time"

"gopl/goroutine/thumbnail"
)

func makeThumbnails(filenames []string) {
for _, f := range filenames {
// 忽略了 ImageFile 返回的错误
go thumbnail.ImageFile(f)
}
}

func elapse() func() {
now := time.Now()
return func() {
fmt.Printf("elapse:%.3f ms\n", 1000*time.Since(now).Seconds())
}
}

func main() {
defer elapse()()
makeThumbnails(os.Args[1:])
}

不幸的是,这个程序有 2 个 bug. 先来看看怎么回事:


067-Go 并发编程(一)_golang_02


图2 并行计算缩略图


这里使用了不同数量的图片,结果发现无论几张图片,消耗的时间都非常短~~~这并行计算提供幅度也太高了吧?先不要偷着乐,这肯定出问题了。在图 1 里我们看到计算一张图要 18ms 左右,现在计算 4 张图却只要 0.003 ms. 另一方面,缩略图也确实没有生成。

2.2 版本二

我们第一次在并行编程上就遇到了挫折。冷静下来先分析一下:

makeThumbnails 函数里开启了 goroutine 后,迅速返回了。然而开启的 goroutine 还没来得及执行,main 函数就已经执行结束。

这是我们发现的第一个 bug. 解决方案有很多,如果你还记得我 channel 的特性的话,利用 channel 会非常容易解决。下面是改进后的 demo03.go 版本,这里我就只粘贴 makeThumbnails 函数,顺带我们还解决了第 2 个 bug.

func makeThumbnails(filenames []string) {
ch := make(chan struct{})
for _, f := range filenames {
// 想想为什么要通过传参数将 f 传进去,而不能直接使用循环变量 f ?
go func(f string) {
thumbnail.ImageFile(f)
ch <- struct{}{}
}(f)
}

for range

分析一下:

  • bug 1 我们通过 channel 解决了 goroutine 还没来得及开始就结束的问题。原理很简单,这里利用了 channel 的特性。
  • bug 2 是一个称之为循环变量快照的问题(The problem of loop variable capture)。如果不通过传参的形式,结果会导致所有的 goroutine 看到的都是同一个 f,这样最后你就只能生成最后一个缩略图了。

循环变量快照问题在很多支持闭包的语言里都会出现,不仅仅是 Golang.

OK,来看看这个『正确』的版本耗时如何?


067-Go 并发编程(一)_并行计算_03


图3 『正确』的并行计算程序


可以看到这个版本的计算看下来正常了很多(确实也是正确的),相比图 1 中的版本,这个版本要快了很多。

3. 总结

  • 深入理解 goroutine
  • 掌握如何使用 channel 进行协程同步
  • 理解什么是循环变量快照

如果你足够细心的话,你会发现图 3 的标题,我给正确两字加了引号。不是说我们写的程序是错误的,而是我们对这个程序处理的还不够仔细——我们没有处理程序出错的情况,这显然不够。说的夸张点,在程序开发里,几乎有一半以上的代码都是在处理异常,所以衡量程序好不好,一个重要的指标就看程序对异常处理是否足够优雅和仔细,而不仅仅是看功能是否实现,复杂度等这些因素。