用Golang寫爬蟲(二) - 併發

原文連接: strconv.com/posts/web-c…linux

在上篇文章裏面我用Go寫了一個爬蟲,可是它的執行是串行的,效率很低,這篇文章把它改爲併發的。因爲這個程序只抓取10個頁面,大概1s多就完成了,爲了對比咱們先給以前的doubanCrawler1.go加一點Sleep的代碼,讓它跑的「慢」些:git

func parseUrls(url string) {
    ...
	time.Sleep(2 * time.Second)
}
```go 這樣運行起來大致能夠計算出來程序跑完約須要21s+,咱們運行一下試試: ```bash
❯ go run doubanCrawler2.go
...
Took 21.315744555s
複製代碼

已經很慢了。接着咱們開始讓它變得更快~github

goroutine的錯誤用法

先修改爲用Go原生支持的併發方案goroutine來作。在Golang中使用goroutine很是方便,直接使用Go關鍵字就能夠,咱們看一個版本:golang

func main() {
	start := time.Now()
	for i := 0; i < 10; i++ {
		go parseUrls("https://movie.douban.com/top250?start=" + strconv.Itoa(25*i))
	}
	elapsed := time.Since(start)
	fmt.Printf("Took %s", elapsed)
}
複製代碼

就是在parseUrls函數前加了go關鍵字。但其實這樣就是不對的,運行的話不會抓取到任何結果。由於協程剛生成,整個程序就結束了,goroutine還沒抓完呢。怎麼辦呢?能夠結束前Sleep一個時間,這個時間應該要大於全部goroutine執行最慢的那個,這樣就保證了所有協程都能正常運行完再結束(doubanCrawler3.go):web

func main() {
    start := time.Now()
    for i := 0; i < 10; i++ {
        go parseUrls("https://movie.douban.com/top250?start=" + strconv.Itoa(25*i))
    }
    time.Sleep(4 * time.Second)
    elapsed := time.Since(start)
    fmt.Printf("Took %s", elapsed)
}
複製代碼

在for循環後加了Sleep 4秒。固然這個Sleep的時間不要控制,假設某次請求花的時間超了,讓整體時間超過4s就看到結果程序結束了,假如所有goroutine都在3秒(2秒固定Sleep+1秒程序運行)結束,那麼多Sleep的一秒就浪費了!運行一下:安全

❯ go run doubanCrawler3.go
...
Took 4.000849896s  # 這個時間大體就是4s
複製代碼

goroutine的正確用法

那怎麼用goroutine呢?有沒有像Python多進程/線程的那種等待子進/線程執行完的join方法呢?固然是有的,可讓Go 協程之間信道(channel)進行通訊:從一端發送數據,另外一端接收數據,信道須要發送和接收配對,不然會被阻塞:bash

func parseUrls(url string, ch chan bool) {
    ...
    ch <- true
}

func main() {
    start := time.Now()
    ch := make(chan bool)
    for i := 0; i < 10; i++ {
        go parseUrls("https://movie.douban.com/top250?start="+strconv.Itoa(25*i), ch)
    }

    for i := 0; i < 10; i++ {
        <-ch
    }

    elapsed := time.Since(start)
    fmt.Printf("Took %s", elapsed)
}
複製代碼

在上面的改法中,parseUrls都是在goroutine中執行,可是注意函數簽名改了,多接收了信道參數ch。當函數邏輯執行結束會給信道ch發送一個布爾值。閉包

而在main函數中,在用一個for循環,<- ch 會等待接收數據(這裏只是接收,至關於確認任務完成)。這樣的流程就實現了一個更好的併發方案:併發

❯ go run doubanCrawler4.go
...
Took 2.450826901s  # 這個時間比以前的寫死了4s的那個優化太多了!
複製代碼

sync.WaitGroup

還有一個好的方案sync.WaitGroup。咱們這個程序只是打印抓到到的對應內容,因此正好用WaitGroup:等待一組併發操做完成:函數

import (
	...
	"sync"
)
...
func main() {
	start := time.Now()
	var wg sync.WaitGroup
	wg.Add(10)

	for i := 0; i < 10; i++ {
		go func() {
			defer wg.Done()
			parseUrls("https://movie.douban.com/top250?start="+strconv.Itoa(25*i))
		}()
	}

	wg.Wait()

	elapsed := time.Since(start)
	fmt.Printf("Took %s", elapsed)
}
複製代碼

一開始咱們給調用wg.Add添加要等待的goroutine量,咱們的頁面總數就是10,因此這裏能夠直接寫出來。

另外這裏使用了defer關鍵字來調用wg.Done,以確保在退出goroutine的閉包以前,向WaitGroup代表了咱們已經退出。因爲要執行wg.Done和parseUrls2件事,因此不能直接用go關鍵字,須要把語句包一下。

(感謝 @bhblinux 指出)不過要注意,在閉包中須要把參數i做爲func的參數傳入,要否則i會使用最後一次循環的那個值:

// 錯誤代碼👇
for i := 0; i < 10; i++ {
    go func() {
        defer wg.Done()
        parseUrls("https://movie.douban.com/top250?start="+strconv.Itoa(25*i))
    }()
}
❯ go run crawler/doubanCrawler5.go
Fetch Url https://movie.douban.com/top250?start=75
Fetch Url https://movie.douban.com/top250?start=250
Fetch Url https://movie.douban.com/top250?start=250
Fetch Url https://movie.douban.com/top250?start=250
Fetch Url https://movie.douban.com/top250?start=250
Fetch Url https://movie.douban.com/top250?start=250
Fetch Url https://movie.douban.com/top250?start=250
Fetch Url https://movie.douban.com/top250?start=250
Fetch Url https://movie.douban.com/top250?start=250
Fetch Url https://movie.douban.com/top250?start=200
...
複製代碼

咦,看代碼,i在等於9的時候循環結束,start應該是225(9 * 25),但爲何250呢?這是由於在最後還有個i++,雖然不符合條件沒有進行循環,可是i的值確實發生了改變!

在這樣的用法中,WaitGroup至關因而一個協程安全的併發計數器:調用Add增長計數,調用Done減小計數。調用Wait會阻塞並等待至計數器歸零。這樣也實現了併發和等待所有goroutine執行完成:

❯ go run doubanCrawler5.go
...
Took 2.382876529s  # 這個時間和以前的信道用法效果一致!
複製代碼

後記

好啦,這篇文章先寫到這裏啦~

代碼地址

完整代碼均可以在這個地址找到。

相關文章
相關標籤/搜索