diff --git a/ch8/ch8-06.md b/ch8/ch8-06.md index 869df85..1fda169 100644 --- a/ch8/ch8-06.md +++ b/ch8/ch8-06.md @@ -1,3 +1,168 @@ ## 8.6. 示例: 併髮的Web爬蟲 -TODO +在5.6节中,我们做了一个简单的web爬虫,用bfs(广度优先)算法来抓取整个网站。在本节中,我们会让这个这个爬虫并行化,这样每一个彼此独立的抓取命令可以并行进行IO,最大化利用网络资源。crawl函数和gopl.io/ch5/findlinks3中的是一样的。 + +```go +gopl.io/ch8/crawl1 +func crawl(url string) []string { + fmt.Println(url) + list, err := links.Extract(url) + if err != nil { + log.Print(err) + } + return list +} +``` + +主函数和5.6节中的breadthFirst(深度优先)类似。像之前一样,一个worklist是一个记录了需要处理的元素的队列,每一个元素都是一个需要抓取的URL列表,不过这一次我们用channel代替slice来做这个队列。每一个对crawl的调用都会在他们自己的goroutine中进行并且会把他们抓到的链接发送回worklist。 + +```go +func main() { + worklist := make(chan []string) + + // Start with the command-line arguments. + go func() { worklist <- os.Args[1:] }() + + // Crawl the web concurrently. + seen := make(map[string]bool) + for list := range worklist { + for _, link := range list { + if !seen[link] { + seen[link] = true + go func(link string) { + worklist <- crawl(link) + }(link) + } + } + } +} +``` + +注意这里的crawl所在的goroutine会将link作为一个显式的参数传入,来避免“循环变量快照”的问题(在5.6.1中有讲解)。另外注意这里将命令行参数传入worklist也是在一个另外的goroutine中进行的,这是为了避免在main goroutine和crawler goroutine中同时向另一个goroutine通过channel发送内容时发生死锁(因为另一边的接收操作还没有准备好)。当然,这里我们也可以用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是fd的limit-20,一般情况下。这个一个夜店里限制客人数目是一个道理,只有当有客人离开时,才会允许新的客人进入店内(译注:作者你个老流氓)。 + +我们可以用一个有容量限制的buffered channel来控制并发,这类似于操作系统里的计数信号量概念。从概念上讲,channel里的n个空槽代表n个可以处理内容的token(通行证),从channel里接收一个值会释放其中的一个token,并且生成一个新的空槽位。这样保证了在没有接收介入时最多有n个发送操作。(这里可能我们拿channel里填充的槽来做token更直观一些,不过还是这样吧~)。由于channel里的元素类型并不重要,我们用一个零值的struct{}来作为其元素。 + +让我们重写crawl函数,将对links.Extract的调用操作用获取、释放token的操作包裹起来,来确保同一时间对其只有20个调用。信号量数量和其能操作的IO资源数量应保持接近。 + +```go +gopl.io/ch8/crawl2 +// tokens is a counting semaphore used to +// enforce a limit of 20 concurrent requests. +var tokens = make(chan struct{}, 20) + +func crawl(url string) []string { + fmt.Println(url) + tokens <- struct{}{} // acquire a token + list, err := links.Extract(url) + <-tokens // release the token + if err != nil { + log.Print(err) + } + return list +} +``` + +The second problem is that the program never terminates, even when it has discovered all the links reachable from the initial URLs. (Of course, you’re unlikely to notice this problem unless you choose the initial URLs carefully or implement the depth-limiting feature of Exercise 8.6.) For the program to terminate, we need to break out of the main loop when the worklist is empty and no crawl goroutines are active. + + +```go +func main() { + worklist := make(chan []string) + var n int // number of pending sends to worklist + + // Start with the command-line arguments. + n++ + go func() { worklist <- os.Args[1:] }() + + + // Crawl the web concurrently. + seen := make(map[string]bool) + + for ; n > 0; n-- { + list := <-worklist + for _, link := range list { + if !seen[link] { + seen[link] = true + n++ + go func(link string) { + worklist <- crawl(link) + }(link) + } + } + } +} + +``` + +这个版本中,计算器n对worklist的发送操作数量进行了限制。每一次我们发现有元素需要被发送到worklist时,我们都会对n进行++操作,在向worklist中发送初始的命令行参数之前,我们也进行过一次++操作。这里的操作++是在每启动一个crawler的goroutine之前。主循环会在n减为0时终止,这时候说明没活可干了。 + +现在这个并发爬虫会比5.6节中的深度优先搜索版快上20倍,而且不会出什么错,并且在其完成任务时也会正确地终止。 + +下面的程序是避免过度并发的另一种思路。这个版本使用了原来的crawl函数,但没有使用计数信号量,取而代之用了20个长活的crawler goroutine,这样来保证最多20个HTTP请求在并发。 + +```go +func main() { + worklist := make(chan []string) // lists of URLs, may have duplicates + unseenLinks := make(chan string) // de-duplicated URLs + + // Add command-line arguments to worklist. + go func() { worklist <- os.Args[1:] }() + + // Create 20 crawler goroutines to fetch each unseen link. + for i := 0; i < 20; i++ { + go func() { + for link := range unseenLinks { + foundLinks := crawl(link) + go func() { worklist <- foundLinks }() + } + }() + } + + // The main goroutine de-duplicates worklist items + // and sends the unseen ones to the crawlers. + seen := make(map[string]bool) + for list := range worklist { + for _, link := range list { + if !seen[link] { + seen[link] = true + unseenLinks <- link + } + } + } +} +``` + +所有的爬虫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结尾,译注:外链的应该就不算了。)当然了,出现在页面里的链接你也需要进行一些处理,使其能够在你的镜像站点上进行跳转,而不是指向原始的链接。 + + +译注: +拓展阅读: +http://marcio.io/2015/07/handling-1-million-requests-per-minute-with-golang/