Go巢狀併發實現EDM,附坑點分析#1

熱愛coding的稻草發表於2019-01-27

看著身邊優秀的小夥伴們早就開始寫部落格,自己深感落後,還好遲做總比不做好,勉勵自己見賢思齊。趁著年前最後一個週末,陽光正好,寫下第一篇部落格,為2019年開個頭,以期完成今年為自己立下的flags。

從PHPer轉Gopher,很大一個原因就是業務對效能和併發的持續需求,另一個主要原因就是Go語言原生的併發特性,可以在提供同等高可用的能力下,使用更少的機器資源,節約可觀的成本。因此本文就結合自己在學習Go併發的實戰demo中,把遇到的一些坑點寫下來,共享進步。

1. 在Go語言中實現併發控制,目前主要有三種方式:

a) Channel - 分為無緩衝、有緩衝通道;

b) WaitGroup - sync包提供的goroutine間的同步機制;

c) Context - 在呼叫鏈不同goroutine間傳遞和共享資料;

本文demo中主要用到了前兩種,基本使用請檢視官方文件。

2. Demo需求與分析:

需求:實現一個EDM的高效郵件傳送:需要支援多個國家(可以看成是多個任務),需要記錄每條任務傳送的狀態(當前成功、失敗條數),需要支援可暫停(stop)、重新傳送(run)操作。

分析:從需求可以看出,在郵件傳送中可以通過併發實現多個國家(多個任務)併發、單個任務分批次併發實現快速、高效EDM需求。

3. Demo實戰原始碼:

3.1 main.go

package main

import (
  "bufio"
  "fmt"
  "io"
  "log"
  "os"
  "strconv"
  "sync"
  "time"
)

var (
  batchLength = 20
  wg          sync.WaitGroup
  finish      = make(chan bool)
)

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

  for i := 1; i <= 3; i++ {
    filename := "./task/edm" + strconv.Itoa(i) + ".txt"
    start := 60

    go RunTask(filename, start, batchLength)
  }

  // main 阻塞等待goroutine執行完成
  fmt.Println(<-finish)

  fmt.Println("finished all tasks.")

  endTime := time.Now().UnixNano()
  fmt.Println("Total cost(ms):", (endTime-startTime)/1e6)
}

// 單任務
func RunTask(filename string, start, length int) (retErr error) {
  for {
    readLine, err := ReadLines(filename, start, length)
    if err == io.EOF {
      fmt.Println("Read EOF:", filename)
      retErr = err
      break
    }
    if err != nil {
      fmt.Println(err)
      retErr = err
      break
    }

    fmt.Println("current line:", readLine)

    start += length

    // 等待一批完成才進入下一批
    //wg.Wait()
  }

  wg.Wait()
  finish <- true

  return retErr
}
複製程式碼

注意上面wg.Wait()的位置(下面有討論),在finish channel之前,目的是為了等待子goroutine執行完,再通過一個無緩衝通道finish通知main goroutine,然後main執行結束。

func ReadLines()讀取指定行資料:

// 讀取指定行資料
func ReadLines(filename string, start, length int) (line int, retErr error) {
  fmt.Println("current file:", filename)

  fileObj, err := os.Open(filename)
  if err != nil {
    panic(err)
  }
  defer fileObj.Close()

  // 跳過開始行之前的行-ReadString方式
  startLine := 1
  endLine := start + length
  reader := bufio.NewReader(fileObj)
  for {
    line, err := reader.ReadString(byte('\n'))
    if err == io.EOF {
      fmt.Println("Read EOF:", filename)
      retErr = err
      break
    }
    if err != nil {
      log.Fatal(err)
      retErr = err
      break
    }

    if startLine > start && startLine <= endLine {
      wg.Add(1)
      // go併發執行
      go SendEmail(line)
      if startLine == endLine {
        break
      }
    }

    startLine++
  }

  return startLine, retErr
}

// 模擬郵件傳送
func SendEmail(email string) error {
  defer wg.Done()

  time.Sleep(time.Second * 1)
  fmt.Println(email)

  return nil
}
複製程式碼

執行上面main.go,3個任務在1s內併發完成所有郵件(./task/edm1.txt中一行表示一個郵箱)傳送。

true

finished all tasks.

Total cost(ms): 1001
複製程式碼

那麼問題來了:沒有實現分批每次併發batchLength = 20,因為如果不分批傳送,只要其中某個任務或某一封郵件出錯了,那下次重新run的時候,會不知道哪些使用者已經傳送過了,出現重複傳送。而分批傳送即使中途出錯了,下一次重新run可從上次出錯的end行開始,最多是[start - end]一個batchLength 傳送失敗,可以接受。

於是,將倒數第5行wg.Wait()註釋掉,倒數第8行註釋開啟,如下:

// 單任務
func RunTask(filename string, start, length int) (retErr error) {
  for {
    readLine, err := ReadLines(filename, start, length)
    if err == io.EOF {
      fmt.Println("Read EOF:", filename)
      retErr = err
      break
    }
    if err != nil {
      fmt.Println(err)
      retErr = err
      break
    }

    fmt.Println("current line:", readLine)

    start += length

    // 等待一批完成才進入下一批
    wg.Wait()
  }

  //wg.Wait()
  finish <- true

  return retErr
}
複製程式碼

執行就報錯:

panic: sync: WaitGroup is reused before previous Wait has returned
複製程式碼

提示WaitGroupgoroutine之間重用了,雖然是全域性變數,看起來是使用不當。怎麼調整呢?

3.2 main.go

package main

import (
  "bufio"
  "fmt"
  "io"
  "log"
  "os"
  "strconv"
  "sync"
  "time"
)

var (
  batchLength = 10
  outerWg     sync.WaitGroup
)

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

  for i := 1; i <= 3; i++ {
    filename := "./task/edm" + strconv.Itoa(i) + ".txt"
    start := 60

    outerWg.Add(1)
    go RunTask(filename, start, batchLength)
  }

  // main 阻塞等待goroutine執行完成
  outerWg.Wait()

  fmt.Println("finished all tasks.")

  endTime := time.Now().UnixNano()
  fmt.Println("Total cost(ms):", (endTime-startTime)/1e6)
}

// 單任務
func RunTask(filename string, start, length int) (retErr error) {
  for {
    isFinish := make(chan bool)
    readLine, err := ReadLines(filename, start, length, isFinish)
    if err == io.EOF {
      fmt.Println("Read EOF:", filename)
      retErr = err
      break
    }
    if err != nil {
      fmt.Println(err)
      retErr = err
      break
    }

    // 等待一批完成才進入下一批
    fmt.Println("current line:", readLine)
    start += length
    <-isFinish

    // 關閉channel,釋放資源
    close(isFinish)
  }

  outerWg.Done()

  return retErr
}
複製程式碼

從上面可以看出:調整的思路是外層用WaitGroup控制,裡層用channel 控制,執行又報錯 : (

fatal error: all goroutines are asleep - deadlock!



goroutine 1 [semacquire]:

sync.runtime_Semacquire(0x55fe7c)

	/usr/local/go/src/runtime/sema.go:56 +0x39

sync.(*WaitGroup).Wait(0x55fe70)

	/usr/local/go/src/sync/waitgroup.go:131 +0x72

main.main()

	/home/work/data/www/docker_env/www/go/src/WWW/edm/main.go:31 +0x1ab



goroutine 5 [chan send]:

main.ReadLines(0xc42001c0c0, 0xf, 0x3c, 0xa, 0xc42008e000, 0x0, 0x0, 0x0)
複製程式碼

仔細檢查,發現上面程式碼中定義的isFinish 是一個無緩衝channel,在發郵件SendMail() 子協程沒有完成時,讀取一個無資料的無緩衝通道將阻塞當前goroutine,其他goroutine也是一樣的都被阻塞,這樣就出現了all goroutines are asleep - deadlock!

於是將上面程式碼改為有緩衝繼續嘗試:

isFinish := make(chan bool, 1)
// 讀取指定行資料
func ReadLines(filename string, start, length int, isFinish chan bool) (line int, retErr error) {
  fmt.Println("current file:", filename)

  // 控制每一批發完再下一批
  var wg sync.WaitGroup

  fileObj, err := os.Open(filename)
  if err != nil {
    panic(err)
  }
  defer fileObj.Close()

  // 跳過開始行之前的行-ReadString方式
  startLine := 1
  endLine := start + length
  reader := bufio.NewReader(fileObj)
  for {
    line, err := reader.ReadString(byte('\n'))
    if err == io.EOF {
      fmt.Println("Read EOF:", filename)
      retErr = err
      break
    }
    if err != nil {
      log.Fatal(err)
      retErr = err
      break
    }

    if startLine > start && startLine <= endLine {

      wg.Add(1)
      // go併發執行
      go SendEmail(line, wg)
      if startLine == endLine {
        isFinish <- true
        break
      }
    }

    startLine++
  }

  wg.Wait()

  return startLine, retErr
}

// 模擬郵件傳送
func SendEmail(email string, wg sync.WaitGroup) error {
  defer wg.Done()

  time.Sleep(time.Second * 1)
  fmt.Println(email)

  return nil
}
複製程式碼

執行,又報錯了 : (

fatal error: all goroutines are asleep - deadlock!

goroutine 1 [semacquire]:

sync.runtime_Semacquire(0x55fe7c)

	/usr/local/go/src/runtime/sema.go:56 +0x39

sync.(*WaitGroup).Wait(0x55fe70)
複製程式碼

這次提示有點不一樣,看起來是裡層的WaitGroup 導致了死鎖,繼續檢查發現裡層wg 是值傳遞,應該使用指標傳引用。

// go併發執行
go SendEmail(line, wg)
複製程式碼

最後修改程式碼如下:

// 讀取指定行資料
func ReadLines(filename string, start, length int, isFinish chan bool) (line int, retErr error) {
  fmt.Println("current file:", filename)

  // 控制每一批發完再下一批
  var wg sync.WaitGroup

  fileObj, err := os.Open(filename)
  if err != nil {
    panic(err)
  }
  defer fileObj.Close()

  // 跳過開始行之前的行-ReadString方式
  startLine := 1
  endLine := start + length
  reader := bufio.NewReader(fileObj)
  for {
    line, err := reader.ReadString(byte('\n'))
    if err == io.EOF {
      fmt.Println("Read EOF:", filename)
      retErr = err
      break
    }
    if err != nil {
      log.Fatal(err)
      retErr = err
      break
    }

    if startLine > start && startLine <= endLine {

      wg.Add(1)
      // go併發執行
      go SendEmail(line, &wg)
      if startLine == endLine {
        isFinish <- true
        break
      }
    }

    startLine++
  }

  wg.Wait()

  return startLine, retErr
}

// 模擬郵件傳送
func SendEmail(email string, wg *sync.WaitGroup) error {
  defer wg.Done()

  time.Sleep(time.Second * 1)
  fmt.Println(email)

  return nil
}
複製程式碼

趕緊執行一下,這次終於成功啦 : )

current line: 100

current file: ./task/edm2.txt

Read EOF: ./task/edm2.txt

Read EOF: ./task/edm2.txt

finished all tasks.

Total cost(ms): 4003
複製程式碼

每個任務模擬的是100行,從第60行開始執行,四個任務併發執行,每個任務分批內再次併發,並且控制了每一批次完成後再進行下一批,所以總執行時間約4s,符合期望值。完整原始碼請閱讀原文或移步GitHub:github.com/astraw99/ed…

4. 小結:

本文通過兩層巢狀Go 併發,模擬實現了高效能併發EDM,具體的一些出錯行控制、任務中斷與再次執行將在下次繼續討論,主要邏輯已跑通,幾個坑點小結如下:

a) WaitGroup 一般用於main 主協程等待全部子協程退出後,再優雅退出主協程;巢狀使用時注意wg.Wait()放的位置;

b) 合理使用channel,無緩衝chan將阻塞當前goroutine,有緩衝chan在cap未滿的情況下不會阻塞當前goroutine,使用完記得釋放chan資源;

c) 注意函式間傳值或傳引用(本質上還是傳值,傳的指標的指標記憶體值)的合理使用;



後記:第一篇部落格寫到這裡差不多算完成了,一不小心一個下午就過去了,寫的邏輯、可讀性可能不太好請見諒,歡迎留言批評指正。感謝您的閱讀。

稻草人生

相關文章