Golang使用通道的同步等待組WaitGroup開發併發爬蟲

在Go的併發編程中有一句很經典的話:不要以共享內存的方式去通信,而要以通信的方式去共享內存。

在Go語言中並不鼓勵用鎖保護共享狀態的方式在不同的Goroutine中分享信息(以共享內存的方式去通信)。而是鼓勵通過channel將共享狀態或共享狀態的變化在各個Goroutine之間傳遞(以通信的方式去共享內存),這樣同樣能像用鎖一樣保證在同一的時間只有一個Goroutine訪問共享狀態。

當然,在主流的編程語言中爲了保證多線程之間共享數據安全性和一致性,都會提供一套基本的同步工具集,如鎖,條件變量,原子操作等等。Go語言標準庫也毫不意外的提供了這些同步機制,使用方式也和其他語言也差不多。

 

 

WaitGroup

WaitGroup,同步等待組。

在類型上,它是一個結構體。一個WaitGroup的用途是等待一個goroutine的集合執行完成。主goroutine調用了Add()方法來設置要等待的goroutine的數量。然後,每個goroutine都會執行並且執行完成後調用Done()這個方法。與此同時,可以使用Wait()方法來阻塞,直到所有的goroutine都執行完成。

Add()方法

Add這個方法,用來設置到WaitGroup的計數器的值。我們可以理解爲每個waitgroup中都有一個計數器 用來表示這個同步等待組中要執行的goroutin的數量。

如果計數器的數值變爲0,那麼就表示等待時被阻塞的goroutine都被釋放,如果計數器的數值爲負數,那麼就會引發恐慌,程序就報錯了。

Done()方法

Done()方法,就是當WaitGroup同步等待組中的某個goroutine執行完畢後,設置這個WaitGroup的counter數值減1。

Wait()方法

Wait()方法,表示讓當前的goroutine等待,進入阻塞狀態。一直到WaitGroup的計數器爲零。才能解除阻塞, 這個goroutine才能繼續執行。

示例代碼


 
  1. package main

    import (
        "fmt"
        "sync"
    )
    var wg sync.WaitGroup // 創建同步等待組對象
    func main()  {
        /*
        WaitGroup:同步等待組
            可以使用Add(),設置等待組中要 執行的子goroutine的數量,

            在main 函數中,使用wait(),讓主程序處於等待狀態。直到等待組中子程序執行完畢。解除阻塞

            子gorotuine對應的函數中。wg.Done(),用於讓等待組中的子程序的數量減1
         */
        //設置等待組中,要執行的goroutine的數量
        wg.Add(2)
        go fun1()
        go fun2()
        fmt.Println("main進入阻塞狀態。。。等待wg中的子goroutine結束。。")
        wg.Wait() //表示main goroutine進入等待,意味着阻塞
        fmt.Println("main,解除阻塞。。")

    }
    func fun1()  {
        for i:=1;i<=10;i++{
            fmt.Println("fun1.。。i:",i)
        }
        wg.Done() //給wg等待中的執行的goroutine數量減1.同Add(-1)
    }
    func fun2()  {
        defer wg.Done()
        for j:=1;j<=10;j++{
            fmt.Println("\tfun2..j,",j)
        }
    }

channel通道

通道可以被認爲是Goroutines通信的管道。類似於管道中的水從一端到另一端的流動,數據可以從一端發送到另一端,通過通道接收。

在前面講Go語言的併發時候,我們就說過,當多個Goroutine想實現共享數據的時候,雖然也提供了傳統的同步機制,但是Go語言強烈建議的是使用Channel通道來實現Goroutines之間的通信。

“不要通過共享內存來通信,而應該通過通信來共享內存” 這是一句風靡golang社區的經典語

接收和發送

一個通道發送和接收數據,默認是阻塞的。當一個數據被髮送到通道時,在發送語句中被阻塞,直到另一個Goroutine從該通道讀取數據。相對地,當從通道讀取數據時,讀取被阻塞,直到一個Goroutine將數據寫入該通道。

示例代碼:以下代碼加入了睡眠,可以更好的理解channel的阻塞


 
  1. package main

    import (
        "fmt"
        "time"
    )

    func main() {
        ch1 := make(chan int)
        done := make(chan bool) // 通道
        go func() {
            fmt.Println("子goroutine執行。。。")
            time.Sleep(3 * time.Second)
            data := <-ch1 // 從通道中讀取數據
            fmt.Println("data:", data)
            done <- true
        }()
        // 向通道中寫數據。。
        time.Sleep(5 * time.Second)
        ch1 <- 100

        <-done
        fmt.Println("main。。over")

    }

在上面的程序中,我們先創建了一個chan bool通道。然後啓動了一條子Goroutine,並循環打印10個數字。然後我們向通道ch1中寫入輸入true。
然後在主goroutine中,我們從ch1中讀取數據。這一行代碼是阻塞的,這意味着在子Goroutine將數據寫入到該通道之前,主goroutine將不會執行到下一行代碼。

因此,我們可以通過channel實現子goroutine和主goroutine之間的通信。當子goroutine執行完畢前,主goroutine會因爲讀取ch1中的數據而阻塞。從而保證了子goroutine會先執行完畢。這就消除了對時間的需求。

在之前的程序中,我們要麼讓主goroutine進入睡眠,以防止主要的Goroutine退出。要麼通過WaitGroup來保證子goroutine先執行完畢,主goroutine才結束。

死鎖

使用通道時要考慮的一個重要因素是死鎖。如果Goroutine在一個通道上發送數據,那麼預計其他的Goroutine應該接收數據。如果這種情況不發生,那麼程序將在運行時出現死鎖。

類似地,如果Goroutine正在等待從通道接收數據,那麼另一些Goroutine將會在該通道上寫入數據,否則程序將會死鎖。

示例代碼


 
  1. package main

    func main() {  
        ch := make(chan int)
        ch <- 5
    }

報錯:


 
  1. fatal error: all goroutines are asleep - deadlock!

    goroutine 1 [chan send]:
    main.main()
        /Users/ruby/go/src/l_goroutine/demo08_chan.go:5 +0x50

Goroutine

Goroutine 是實際併發執行的實體,它底層是使用協程(coroutine)實現併發,coroutine是一種運行在用戶態的用戶線程,類似於 greenthread,go底層選擇使用coroutine的出發點是因爲,它具有以下特點:

用戶空間 避免了內核態和用戶態的切換導致的成本
可以由語言和框架層進行調度
更小的棧空間允許創建大量的實例

Goroutine 調度器

Go併發調度: G-P-M模型

在操作系統提供的內核線程之上,Go搭建了一個特有的兩級線程模型。goroutine機制實現了M : N的線程模型,goroutine機制是協程(coroutine)的一種實現,golang內置的調度器,可以讓多核CPU中每個CPU執行一個協程。

以上內容來自 https://github.com/rubyhan1314/Golang-100-Days
主要說明一下同步等待組和通道的基本使用,以及 go 是如何處理併發的,更多可以繼續參考以上,來自千峯的 go 教程。

實戰爬蟲

前面說了這麼多只不過是爲這個腳本做鋪墊,要不然則來的太唐突。
我這裏寫了一個爬蟲腳本,用到了通道來做併發,並有同步等待組做 awit() 操作

直接來看代碼

獲取html


 
  1. func HttpGet(url string) (result string, err error) {
        resp, err1 := http.Get(url)
        if err != nil {
            err = err1
            return
        }
        defer resp.Body.Close()
        //讀取網頁的body內容
        buf := make([]byte, 4*1024)
        for true {
            n, err := resp.Body.Read(buf)
            if err != nil {
                if err == io.EOF{
                    break
                }else {
                    fmt.Println("resp.Body.Read err = ", err)
                    break
                }
            }
            result += string(buf[:n])
        }
        return
    }

爬取網頁存爲 .html 文件


 
  1. func spiderPage(url string) string {

        fmt.Println("正在爬取", url)
        //爬,將所有的網頁內容爬取下來
        result, err := HttpGet(url)
        if err != nil {
            fmt.Println(err)
        }
        //把內容寫入到文件
        filename := strconv.Itoa(rand.Int()) + ".html"
        f, err1 := os.Create(filename)
        if err1 != nil{
            fmt.Println(err1)
        }
        //寫內容
        f.WriteString(result)
        //關閉文件
        f.Close()
        return url + " 抓取成功"

    }

爬取方法方面就寫完了,接下來就到了重要的部分了

定義一個工作者函數


 
  1. func doWork(start, end int,wg *sync.WaitGroup) {
        fmt.Printf("正在爬取第%d頁到%d頁\n", start, end)
        //因爲很有可能爬蟲還沒有結束下面的循環就已經結束了,所以這裏就需要且到通道
        page := make(chan string,100)
        results := make(chan string,100)


        go sendResult(results,start,end)

        go func() {

            for i := 0; i <= 20; i++ {
                wg.Add(1)
                go asyn_worker(page, results, wg)
            }
        }()

        for i := start; i <= end; i++ {
                url := "https://tieba.baidu.com/f?kw=%E7%BB%9D%E5%9C%B0%E6%B1%82%E7%94%9F&ie=utf-8&pn=" + strconv.Itoa((i-1)*50)
                page <- url
                println("加入" + url + "到page")
            }
            println("關閉通道")
            close(page)

        wg.Wait()
        //time.Sleep(time.Second * 5)
        println(" Main 退出 。。。。。")
    }

從通道取出數據


 
  1. func asyn_worker(page chan string, results chan string,wg *sync.WaitGroup){

        defer wg.Done()  //defer wg.Done()必須放在go併發函數內

        for{
            v, ok := <- page //顯示的調用close方法關閉通道。
            if !ok{
                fmt.Println("已經讀取了所有的數據,", ok)
                break
            }
            //fmt.Println("取出數據:",v, ok)
            results <- spiderPage(v)
        }


        //for n := range page {
        //  results <- spiderPage(n)
        //}
    }

發送抓取結果


 
  1. func sendResult(results chan string,start,end int)  {

        //for i := start; i <= end; i++ {
        //  fmt.Println(<-results)
        //}

        // 發送抓取結果
        for{
            v, ok := <- results
            if !ok{
                fmt.Println("已經讀取了所有的數據,", ok)
                break
            }
            fmt.Println(v)

        }
    }

大體思路是這樣的:

可以看到我定義了兩個通道,一個是用來存入 url 的,另一個是用來存入爬取結果的,緩衝空間是 100
在方法 doWork 中, sendResult 會阻塞等待 results 通道的輸出,匿名函數則是等待 page 通道的輸出

緊接着下面就是把 200 個 url 寫入 page 通道,匿名函數得到 page 的輸出就會執行 asyn_worker 函數,也就是爬取 html 的函數了(將其存入results 通道)

然後 sendResult 函數得到 results 通道的輸出,將結果打印出來

可以看到 我在匿名函數中併發了 20 個 goroution,並且啓用了同步等待組作爲參數傳入,理論上可以根據機器的性能來定義 併發數

main函數


 
  1. func main() {
        start_time := time.Now().UnixNano()

        var wg sync.WaitGroup

        doWork(1,200, &wg)
        //輸出執行時間,單位爲毫秒。
        fmt.Printf("執行時間: %ds",(time.Now().UnixNano() - start_time) / 1000)

    }

運行爬蟲並計算運行時間,這個時間因機器而異,但應該不會相差太多

完整代碼


 
  1. package main

    import (
        "fmt"
        "io"
        "sync"
        "math/rand"
        "net/http"
        "os"
        "strconv"
        "time"
    )



    func HttpGet(url string) (result string, err error) {
        resp, err1 := http.Get(url)
        if err != nil {
            err = err1
            return
        }
        defer resp.Body.Close()
        //讀取網頁的body內容
        buf := make([]byte, 4*1024)
        for true {
            n, err := resp.Body.Read(buf)
            if err != nil {
                if err == io.EOF{
                    break
                }else {
                    fmt.Println("resp.Body.Read err = ", err)
                    break
                }
            }
            result += string(buf[:n])
        }
        return
    }


    //爬取網頁
    func spiderPage(url string) string {

        fmt.Println("正在爬取", url)
        //爬,將所有的網頁內容爬取下來
        result, err := HttpGet(url)
        if err != nil {
            fmt.Println(err)
        }
        //把內容寫入到文件
        filename := strconv.Itoa(rand.Int()) + ".html"
        f, err1 := os.Create(filename)
        if err1 != nil{
            fmt.Println(err1)
        }
        //寫內容
        f.WriteString(result)
        //關閉文件
        f.Close()
        return url + " 抓取成功"

    }

    func asyn_worker(page chan string, results chan string,wg *sync.WaitGroup){

        defer wg.Done()  //defer wg.Done()必須放在go併發函數內

        for{
            v, ok := <- page //顯示的調用close方法關閉通道。
            if !ok{
                fmt.Println("已經讀取了所有的數據,", ok)
                break
            }
            //fmt.Println("取出數據:",v, ok)
            results <- spiderPage(v)
        }

        //for n := range page {
        //  results <- spiderPage(n)
        //}
    }

    func doWork(start, end int,wg *sync.WaitGroup) {
        fmt.Printf("正在爬取第%d頁到%d頁\n", start, end)
        //因爲很有可能爬蟲還沒有結束下面的循環就已經結束了,所以這裏就需要且到通道
        page := make(chan string,100)
        results := make(chan string,100)


        go sendResult(results,start,end)

        go func() {

            for i := 0; i <= 20; i++ {
                wg.Add(1)
                go asyn_worker(page, results, wg)
            }
        }()


        for i := start; i <= end; i++ {
                url := "https://tieba.baidu.com/f?kw=%E7%BB%9D%E5%9C%B0%E6%B1%82%E7%94%9F&ie=utf-8&pn=" + strconv.Itoa((i-1)*50)
                page <- url
                println("加入" + url + "到page")
            }
            println("關閉通道")
            close(page)

        wg.Wait()
        //time.Sleep(time.Second * 5)
        println(" Main 退出 。。。。。")
    }


    func sendResult(results chan string,start,end int)  {

        //for i := start; i <= end; i++ {
        //  fmt.Println(<-results)
        //}

        // 發送抓取結果
        for{
            v, ok := <- results
            if !ok{
                fmt.Println("已經讀取了所有的數據,", ok)
                break
            }
            fmt.Println(v)

        }
    }

    func main() {
        start_time := time.Now().UnixNano()

        var wg sync.WaitGroup

        doWork(1,200, &wg)
        //輸出執行時間,單位爲毫秒。
        fmt.Printf("執行時間: %ds",(time.Now().UnixNano() - start_time) / 1000)

    }

總體來說,這個腳本就是爲了弄清楚 Go 語言的併發原理 以及 通道,同步等待組的基本使用,或者只用 go 語言的鎖,目的都是爲了防止 臨界資源的安全問題。

有了 channel 和 goroutine 之後,Go 的併發編程變得異常容易和安全,得以讓程序員把注意力留到業務上去,實現開發效率的提升。

轉至https://gzky.live/article/Golang%E9%80%9A%E9%81%93%E5%90%8C%E6%AD%A5%E7%AD%89%E5%BE%85%E7%BB%84%20%E5%B9%B6%E5%8F%91%E7%88%AC%E8%99%AB

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章