8.6. 示例: 并发的Web爬虫

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

gopl.io/ch8/crawl1

  1. func crawl(url string) []string {
  2. fmt.Println(url)
  3. list, err := links.Extract(url)
  4. if err != nil {
  5. log.Print(err)
  6. }
  7. return list
  8. }

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

  1. func main() {
  2. worklist := make(chan []string)
  3. // Start with the command-line arguments.
  4. go func() { worklist <- os.Args[1:] }()
  5. // Crawl the web concurrently.
  6. seen := make(map[string]bool)
  7. for list := range worklist {
  8. for _, link := range list {
  9. if !seen[link] {
  10. seen[link] = true
  11. go func(link string) {
  12. worklist <- crawl(link)
  13. }(link)
  14. }
  15. }
  16. }
  17. }

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

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

  1. $ go build gopl.io/ch8/crawl1
  2. $ ./crawl1 http://gopl.io/
  3. http://gopl.io/
  4. https://golang.org/help/
  5. https://golang.org/doc/
  6. https://golang.org/blog/
  7. ...
  8. 2015/07/15 18:22:12 Get ...: dial tcp: lookup blog.golang.org: no such host
  9. 2015/07/15 18:22:12 Get ...: dial tcp 23.21.222.120:443: socket: too many open files
  10. ...

最初的错误信息是一个让人莫名的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资源数量应保持接近。

gopl.io/ch8/crawl2

  1. // tokens is a counting semaphore used to
  2. // enforce a limit of 20 concurrent requests.
  3. var tokens = make(chan struct{}, 20)
  4. func crawl(url string) []string {
  5. fmt.Println(url)
  6. tokens <- struct{}{} // acquire a token
  7. list, err := links.Extract(url)
  8. <-tokens // release the token
  9. if err != nil {
  10. log.Print(err)
  11. }
  12. return list
  13. }

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

  1. func main() {
  2. worklist := make(chan []string)
  3. var n int // number of pending sends to worklist
  4. // Start with the command-line arguments.
  5. n++
  6. go func() { worklist <- os.Args[1:] }()
  7. // Crawl the web concurrently.
  8. seen := make(map[string]bool)
  9. for ; n > 0; n-- {
  10. list := <-worklist
  11. for _, link := range list {
  12. if !seen[link] {
  13. seen[link] = true
  14. n++
  15. go func(link string) {
  16. worklist <- crawl(link)
  17. }(link)
  18. }
  19. }
  20. }
  21. }

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

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

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

  1. func main() {
  2. worklist := make(chan []string) // lists of URLs, may have duplicates
  3. unseenLinks := make(chan string) // de-duplicated URLs
  4. // Add command-line arguments to worklist.
  5. go func() { worklist <- os.Args[1:] }()
  6. // Create 20 crawler goroutines to fetch each unseen link.
  7. for i := 0; i < 20; i++ {
  8. go func() {
  9. for link := range unseenLinks {
  10. foundLinks := crawl(link)
  11. go func() { worklist <- foundLinks }()
  12. }
  13. }()
  14. }
  15. // The main goroutine de-duplicates worklist items
  16. // and sends the unseen ones to the crawlers.
  17. seen := make(map[string]bool)
  18. for list := range worklist {
  19. for _, link := range list {
  20. if !seen[link] {
  21. seen[link] = true
  22. unseenLinks <- link
  23. }
  24. }
  25. }
  26. }

所有的爬虫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开头,译注:外链的应该就不算了。)当然了,出现在页面里的链接你也需要进行一些处理,使其能够在你的镜像站点上进行跳转,而不是指向原始的链接。

译注: 拓展阅读 Handling 1 Million Requests per Minute with Go