Golang 用 goroutine 減少大量檔案讀取時間

之前做了一個可以掃描多資料夾下檔案變動的程式

但只要檔案變多

速度就會變很慢

所以這次要用 golang 自帶的 goroutine 來優化

另外做了一份精簡版

只掃描某資料夾下的所有檔案

並產生完 md5 後印出(不存在資料庫)

完整程式:

package main

import (
    "crypto/md5"
    "encoding/hex"
    "fmt"
    "io"
    "log"
    "os"
    "path/filepath"
    "time"
)

var fileCount int32

func main() {
    start := time.Now()
    fileCount = 0

    ReadDir("/Users/herb/Desktop/Projects")

    end := time.Now()

    fmt.Println("Handled ", fileCount, " files")
    fmt.Println("Spent time: ", end.Sub(start).Seconds(), " s")
}

func ReadDir(dirPath string) {
    openDir, err := os.Open(dirPath)
    if err != nil {
        fmt.Println("error opening directory")
        fmt.Println(err.Error())

        return
    }
    defer openDir.Close()

    files, err := openDir.Readdir(-1)
    if err != nil {
        fmt.Println("Read dir failed.")
        fmt.Println(err.Error())

        return
    }

    if len(files) > 0 {
        handleFiles(files, dirPath)
    }
}

func handleFiles(files []os.FileInfo, dirPath string) {
    filesLen := len(files)
    if filesLen == 0 {
        return
    }
    for _, file := range files {
        fullPath := dirPath + "/" + file.Name()
        if file.IsDir() '' !file.Mode().IsRegular() {
            ReadDir(fullPath)
        } else {
            fileCount++
            fmt.Println(fullPath + " md5: " + genMd5(fullPath))
        }
    }
}

func genMd5(Abspath string) string {
    path, err := filepath.Abs(Abspath)
    if err != nil {
        panic("Convert file absolute path error: " + path)
    }

    f, err := os.Open(Abspath)
    if err != nil {
        log.Fatal(err)
    }
    defer f.Close()

    h := md5.New()
    if _, err := io.Copy(h, f); err != nil {
        log.Fatal(err)
    }

    return hex.EncodeToString(h.Sum(nil))
}

最後輸出

Handled 196339 files
Spent time: 85.100083581 s

可以看到處理了快20萬個檔案

用了大約85秒

現在開始優化成 goroutine 的方式吧

思路是這樣的

用一個全域的 Waitgroup 等待所有 goroutine 處理

每呼叫一次 go func 都 wg.Add(1)

並在每個 func 內 defer wg.Done()

最後再用 divide and conquer 的思維

把檔案列表拆成兩半來處理

完整程式如下:

package main

import (
    "crypto/md5"
    "encoding/hex"
    "fmt"
    "io"
    "log"
    "os"
    "path/filepath"
    "sync"
    "sync/atomic"
    "time"
)

var wg = &sync.WaitGroup{}

var fileCount int32

func main() {
    start := time.Now()

    fileCount = 1
    wg.Add(1)
    go ReadDir("/Users/herb/Desktop/Projects")
    fmt.Println("Read start...")
    wg.Wait()

    end := time.Now()

    fmt.Println("Handled ", fileCount, " files")
    fmt.Println("Spent time: ", end.Sub(start).Seconds(), " s")
}

func ReadDir(dirPath string) {
    defer wg.Done()

    openDir, err := os.Open(dirPath)
    if err != nil {
        fmt.Println("error opening directory")
        fmt.Println(err.Error())

        return
    }
    defer openDir.Close()

    files, err := openDir.Readdir(-1)
    if err != nil {
        fmt.Println("Read dir failed.")
        fmt.Println(err.Error())

        return
    }

    if len(files) > 0 {
        wg.Add(1)
        go handleFiles(files, dirPath)
    }
}

func handleFiles(files []os.FileInfo, dirPath string) {
    defer wg.Done()

    filesLen := len(files)
    if filesLen == 0 {
        return
    }

    if filesLen == 1 {
        file := files[0]
        fullPath := dirPath + "/" + file.Name()

        if file.IsDir() '' !file.Mode().IsRegular() {
            wg.Add(1)
            go ReadDir(fullPath)
        } else {
            fmt.Println(fullPath + " md5: " + genMd5(fullPath))
            atomic.AddInt32(&fileCount, 1)
        }
    } else {
        wg.Add(2)
        go handleFiles(files[:filesLen/2], dirPath)
        go handleFiles(files[filesLen/2:], dirPath)
    }
}

func genMd5(Abspath string) string {
    path, err := filepath.Abs(Abspath)
    if err != nil {
        panic("Convert file absolute path error: " + path)
    }

    f, err := os.Open(Abspath)
    if err != nil {
        log.Fatal(err)
    }
    defer f.Close()

    h := md5.New()
    if _, err := io.Copy(h, f); err != nil {
        log.Fatal(err)
    }

    return hex.EncodeToString(h.Sum(nil))
}

可以順利跑起來

但到一半會突然跳錯誤並中斷

錯誤是:

2017/12/30 10:15:01 open /xxx/ooo/package.json: too many open files
exit status 1

看來是 goroutine 開太多

導致 os.Open 開啟的檔案數量超過 OS 的限制了

查了一下

大多都是說調整 OS 的可開啟檔案數量限制

但這總有達到限制的時候

再另外找尋方法

發現一個可以限制 goroutine 數量的方式

用有限的 channel

這方法晚點會獨立一篇文章出來放著

主要核心思想是建立一個有限的 channel

並在執行 func 之前對這個 channel 塞值

若這個 channel 滿了就會塞不進去

並等待其他 goroutine 接值回去才能塞 並往下執行

所以程式碼片段就會變成這樣

// limit goroute number
var sem = make(chan int, 12)

func ReadDir(dirPath string) {
    sem <- 1
    defer func() {
        <-sem
    }()
    defer wg.Done()

    ......
}

完整程式碼就變成:

package main

import (
    "crypto/md5"
    "encoding/hex"
    "fmt"
    "io"
    "log"
    "os"
    "path/filepath"
    "sync"
    "sync/atomic"
    "time"
)

var wg = &sync.WaitGroup{}

// limit goroute number
var sem = make(chan int, 12)

var fileCount int32

func main() {
    start := time.Now()

    fileCount = 1
    wg.Add(1)
    go ReadDir("/Users/herb/Desktop/Projects")
    fmt.Println("Read start...")
    wg.Wait()

    end := time.Now()

    fmt.Println("Handled ", fileCount, " files")
    fmt.Println("Spent time: ", end.Sub(start).Seconds(), " s")
}

func ReadDir(dirPath string) {
    sem <- 1
    defer func() {
        <-sem
    }()
    defer wg.Done()

    openDir, err := os.Open(dirPath)
    if err != nil {
        fmt.Println("error opening directory")
        fmt.Println(err.Error())

        return
    }
    defer openDir.Close()

    files, err := openDir.Readdir(-1)
    if err != nil {
        fmt.Println("Read dir failed.")
        fmt.Println(err.Error())

        return
    }

    if len(files) > 0 {
        wg.Add(1)
        go handleFiles(files, dirPath)
    }

    // files, err := ioutil.ReadDir(dirPath)
    // if err != nil {
    //  fmt.Println("Read dir failed. " + err.Error())
    // }

}

func handleFiles(files []os.FileInfo, dirPath string) {
    sem <- 1
    defer func() {
        <-sem
    }()
    defer wg.Done()

    filesLen := len(files)
    if filesLen == 0 {
        return
    }

    if filesLen == 1 {
        // for _, file := range files {

        // }
        file := files[0]
        fullPath := dirPath + "/" + file.Name()

        // fmt.Println(file.Mode().IsRegular())
        if file.IsDir() '' !file.Mode().IsRegular() {
            wg.Add(1)
            go ReadDir(fullPath)
        } else {
            fmt.Println(fullPath + " md5: " + genMd5(fullPath))
            atomic.AddInt32(&fileCount, 1)
        }
    } else {
        wg.Add(2)
        go handleFiles(files[:filesLen/2], dirPath)
        go handleFiles(files[filesLen/2:], dirPath)
    }
}

func genMd5(Abspath string) string {
    path, err := filepath.Abs(Abspath)
    if err != nil {
        panic("Convert file absolute path error: " + path)
    }

    f, err := os.Open(Abspath)
    if err != nil {
        log.Fatal(err)
    }
    defer f.Close()

    h := md5.New()
    if _, err := io.Copy(h, f); err != nil {
        log.Fatal(err)
    }

    return hex.EncodeToString(h.Sum(nil))
}

最終執行結果:

Handled 196340 files
Spent time: 47.253868566 s

可以發現多了一個檔案XD

其實我也不知道為什麼會多一個

該不會是自己?

但也是滿怪

總之執行時間少了快一半

這次的改善算很成功!

看更多