示例: 并发的Web爬虫

在5.6节中,我们做了一个简单的 web 爬虫,用 bfs(广度优先)算法来抓取整个网站。在本节中,我们会让这个爬虫并行化,这样每一个彼此独立的抓取命令可以并行进行 IO,最大化利用网络资源。crawl 函数和 gopl.io/ch5/findlinks3 中的是一样的。

ch8/crawl1
Unresolved include directive in modules/ROOT/pages/ch8/ch8-06.adoc - include::example$/ch8/crawl1/findlinks.go[]

主函数和5.6节中的 breadthFirst (广度优先)类似。像之前一样,一个 worklist 是一个记录了需要处理的元素的队列,每一个元素都是一个需要抓取的 URL 列表,不过这一次我们用 channel 代替 slice 来做这个队列。每一个对 crawl 的调用都会在他们自己的 goroutine 中进行并且会把他们抓到的链接发送回 worklist 。

Unresolved include directive in modules/ROOT/pages/ch8/ch8-06.adoc - include::example$/ch8/crawl1/findlinks.go[]

注意这里的 crawl 所在的 goroutine 会将 link 作为一个显式的参数传入,来避免“循环变量快照”的问题(在5.6.1中有讲解)。另外注意这里将命令行参数传入 worklist 也是在一个另外的 goroutine 中进行的,这是为了避免 channel 两端的 main goroutine 与 crawler goroutine 都尝试向对方发送内容,却没有一端接收内容时发生死锁。当然,这里我们也可以用 buffered channel 来解决问题,这里不再赘述。

现在爬虫可以高并发地运行起来,并且可以产生一大坨的 URL 了,不过还是会有俩问题。一个问题是在运行一段时间后可能会出现在 log 的错误信息里的:

$ go build gopl.io/ch8/crawl1
$ ./crawl1 http://gopl.io/
http://gopl.io/
https://golang.org/help/
https://golang.org/doc/
https://golang.org/blog/
...
2015/07/15 18:22:12 Get ...: dial tcp: lookup blog.golang.org: no such host
2015/07/15 18:22:12 Get ...: dial tcp 23.21.222.120:443: socket: too many open files
...

最初的错误信息是一个让人莫名的 DNS 查找失败,即使这个域名是完全可靠的。而随后的错误信息揭示了原因:这个程序一次性创建了太多网络连接,超过了每一个进程的打开文件数限制,既而导致了在调用 net.Dial 像 DNS 查找失败这样的问题。

这个程序实在是太他妈并行了。无穷无尽地并行化并不是什么好事情,因为不管怎么说,你的系统总是会有一些个限制因素,比如 CPU 核心数会限制你的计算负载,比如你的硬盘转轴和磁头数限制了你的本地磁盘 IO 操作频率,比如你的网络带宽限制了你的下载速度上限,或者是你的一个 web 服务的服务容量上限等等。为了解决这个问题,我们可以限制并发程序所使用的资源来使之适应自己的运行环境。对于我们的例子来说,最简单的方法就是限制对 links.Extract 在同一时间最多不会有超过 n 次调用,这里的 n 一般小于文件描述符的上限值,比如 20。这和一个夜店里限制客人数目是一个道理,只有当有客人离开时,才会允许新的客人进入店内。

我们可以用一个有容量限制的 buffered channel 来控制并发,这类似于操作系统里的计数信号量概念。从概念上讲,channel 里的 n 个空槽代表 n 个可以处理内容的 token(通行证),从 channel 里接收一个值会释放其中的一个 token,并且生成一个新的空槽位。这样保证了在没有接收介入时最多有 n 个发送操作。(这里可能我们拿 channel 里填充的槽来做 token 更直观一些,不过还是这样吧。)由于 channel 里的元素类型并不重要,我们用一个零值的 struct{} 来作为其元素。

让我们重写 crawl 函数,将对 links.Extract 的调用操作用获取、释放 token 的操作包裹起来,来确保同一时间对其只有20个调用。信号量数量和其能操作的 IO 资源数量应保持接近。

ch8/crawl2
Unresolved include directive in modules/ROOT/pages/ch8/ch8-06.adoc - include::example$/ch8/crawl2/findlinks.go[]

第二个问题是这个程序永远都不会终止,即使它已经爬到了所有初始链接衍生出的链接。(当然,除非你慎重地选择了合适的初始化URL或者已经实现了练习8.6中的深度限制,你应该还没有意识到这个问题。)为了使这个程序能够终止,我们需要在 worklist 为空或者没有 crawl 的 goroutine 在运行时退出主循环。

ch8/crawl2
Unresolved include directive in modules/ROOT/pages/ch8/ch8-06.adoc - include::example$/ch8/crawl2/findlinks.go[]

这个版本中,计数器 n 对 worklist 的发送操作数量进行了限制。每一次我们发现有元素需要被发送到 worklist 时,我们都会对 n 进行 ++ 操作,在向 worklist 中发送初始的命令行参数之前,我们也进行过一次 ++ 操作。这里的操作 ++ 是在每启动一个 crawler 的 goroutine 之前。主循环会在 n 减为 0 时终止,这时候说明没活可干了。

现在这个并发爬虫会比5.6节中的深度优先搜索版快上20倍,而且不会出什么错,并且在其完成任务时也会正确地终止。

下面的程序是避免过度并发的另一种思路。这个版本使用了原来的 crawl 函数,但没有使用计数信号量,取而代之用了 20 个常驻的 crawler goroutine,这样来保证最多 20 个 HTTP 请求在并发。

ch8/crawl3
Unresolved include directive in modules/ROOT/pages/ch8/ch8-06.adoc - include::example$/ch8/crawl3/findlinks.go[]

所有的爬虫 goroutine 现在都是被同一个 channel - unseenLinks 喂饱的了。主 goroutine 负责拆分它从 worklist 里拿到的元素,然后把没有抓过的经由 unseenLinks channel 发送给一个爬虫的 goroutine。

seen 这个 map 被限定在 main goroutine 中;也就是说这个 map 只能在 main goroutine 中进行访问。类似于其它的信息隐藏方式,这样的约束可以让我们从一定程度上保证程序的正确性。例如,内部变量不能够在函数外部被访问到;变量(§2.3.4)在没有发生变量逃逸(译注:局部变量被全局变量引用地址导致变量被分配在堆上)的情况下是无法在函数外部访问的;一个对象的封装字段无法被该对象的方法以外的方法访问到。在所有的情况下,信息隐藏都可以帮助我们约束我们的程序,使其不发生意料之外的情况。

crawl 函数爬到的链接在一个专有的 goroutine 中被发送到 worklist 中来避免死锁。为了节省篇幅,这个例子的终止问题我们先不进行详细阐述了。


练习 8.6: 为并发爬虫增加深度限制。也就是说,如果用户设置了depth=3,那么只有从首页跳转三次以内能够跳到的页面才能被抓取到。

练习 8.7: 完成一个并发程序来创建一个线上网站的本地镜像,把该站点的所有可达的页面都抓取到本地硬盘。为了省事,我们这里可以只取出现在该域下的所有页面(比如golang.org开头,译注:外链的应该就不算了。)当然了,出现在页面里的链接你也需要进行一些处理,使其能够在你的镜像站点上进行跳转,而不是指向原始的链接。