Go語言中異步拆分io.Reader

原文地址

在Go語言中處理任何stream數據時,我已經深陷io.Readerio.Writer的靈活性中不能自拔。同時我在有一點上又或多或少的受了些折磨,挑戰我的reader interface在你看來可能會覺得很簡單:那就是怎麼樣拆分讀操作。

我甚至不知道使用“拆分(split)”這個詞是否正確,我就是想通過io.Reader多次讀取接收到的東西,有時候可能還需要並行操作。但是由於readers不一定會暴露Seek方法重置讀取位置,我需要一個方法來複制它。或者可以算是clone或fork麼?

現狀

假設你有一個web服務允許用戶上傳一個文件。這個服務將會把文件存儲在雲端。但是在存儲前需要對這個文件進行一些簡單的處理。對於接下來的所有請求,你都不得不使用io.Reader去處理。

解決方案

當然,有不止一種方法可以處理這種情況。根據文件的類型,服務的吞吐量,以及文件需要的處理方式的不同有些方式可能比其他的更合適。下面,我給出了5中不同複雜度和靈活性的方法。可以想象還會有更多的方法,但是這幾個會是一個不錯的起點。

Solution #1:簡單的bytes.Reader

如果源reader沒有Seek方法,爲什麼不自己實現一個呢?你可以把所有的內容都讀取到一個bytes.Reader中,然後你想分多少次讀取都可以,只要你開心:

func handleUpload(u io.Reader)(err error) {
    //capture all bytes from upload
    b, err := ioutil.ReadAll(u)
    if err != nil {
        return err
    }

    //wrap the bytes in a ReaderSeeker
    r := bytes.NewReader(b)

    //process the metadata
    err = processMetaData(r)
    if err != nil {
        return err
    }

    r.Seek(0, 0)

    //upload the data
    err = uploadFile(r)
    if err != nil {
        return err
    }

    return nil
}

如果數據足夠小,這可能是最方便的選擇;你可以完全忘掉bytes.Reader並使用*byte slice的方式代替工作。但是假如是大文件,如視頻文件或RAW格式的照片等。這些龐然大物將吞噬你的內存,特別是如果服務還具有高流量特徵時。更何況(not to mention)你不能並行執行這些操作。

  • 優點:最簡單的方案
  • 缺點:同步,無法適應你期望的很多、很大的文件。

Solution #2:可靠的文件系統

OK,那麼將數據放到磁盤中的文件如何(藉助ioutil.TempFile),並且可以避免將數據存儲在內存中帶來的隱患。

func handleUpload(u io.Reader)(err error) {
    //create a temporary file for the upload
    f, err := ioutil.TempFile("", "upload")
    if err != nil {
        return err
    }

    //destroy the file once done
    defer func() {
        n := f.name()
        f.Close()
        os.Remove(n)
    }()

    //transfer the bytes to the file
    _, err := io.Copy(f, u)
    if err != nil {
        return err
    }

    //rewind the file
    f.Seek(0.0)

    //upload the file
    err = uploadFile(f)
    if err != nil{
        return err
    }

    return nil
}

如果最終是要將文件存儲在service運行的文件系統中,這種方法可能是最好的選擇(儘管會產生一個真實的臨時文件),但是我們假設它最終將落在雲上。繼續,如果這個文件同樣很大,則將產生顯著的,但是不必要的IO。同時,你還將面臨機器上單個文件錯誤或宕機的風險,所以如果你的數據比較敏感,我也不推薦這種方式。

  • 優點:避免大量內存佔用保存整個文件
  • 缺點:同步,潛在的佔用大量IO、磁盤空間以及數據單點故障

Solution #3:The Duct-Tape io.MultiReader

有些情況下,你需要的metadata存在於文件最開始的幾個字節。例如,識別一個JPEG格式的文件只需要檢查文件的前兩個字節是否是0xFF 0xD8。這個可以通過使用io.MultiReader同步處理。io.MultiReader將一組readers組織起來使他們看起來像一個一樣。如下是我們的JPEG示例:

func handleUpload(u io.Reader)(err error) {
    //read in the first 2 bytes
    b := make([]byte, 2)
    _, err := u.Read(b)
    if err != nil {
        return err
    }

    //check that they match the JPEG header
    jpg := []byte{0xFF, 0xD8}
    if !bytes.Equal(b, jpg) {
        return errors.New("not a JPEG")
    }

    //glue those bytes back onto the reader
    r := io.MultiReader(bytes.NewReader(b), u)

    //upload the file 
    err = uploadFile(r)
    if err != nil {
        return err
    }

    return nil
}

如果你只打算上傳JPEG文件,這是一個很好的技術。只需要兩個字節,你就可以停止傳輸(注:此處的傳輸不是文件上傳的傳輸,而是將文件拷貝到內存或磁盤進行處理的傳輸過程),而不必將整個文件拷貝到內存或存放到磁盤上。你應該也會發現,有些場景這個方法也並不適用。比如你需要讀取更多的文件內容去收集數據,如通過計算統計單詞個數等。這個過程會阻塞文件上傳,對任務密集型可能也不是理想的處理方式。最後,大多數第三方包(和大部分標準庫)將完整的消耗掉一個reader,以防止你以這種方式使用io.MultiReader.

另一種方案是使用bufio.Reader.Peek。本質上它執行相同的操作,但是你可以避開MultiReader。也就是說,它還可以讓你訪問Reader上的其他的有用的方法。

  • 優點:快速且是對文件頭的髒讀,可以作爲文件上傳的門檻。
  • 缺點:不適用於不定長讀取,處理整個文件,密集任務,或和很多第三方包一同使用。

Solution #4:The Single-Split io.TeeReader and io.Pipe

回到前面討論的大視頻文件的情況,我們稍微修改一下故事情節。你的用戶只會上傳單一格式的視頻文件,但是你希望這些視頻文件能夠被你的服務以不同格式播放。比如說,你有一個第三方轉碼器可以將io.Reader讀取的MP4格式數據轉換成WebM格式的數據輸出。你的服務將會把原始的MP4和轉碼的WebM文件都上傳到雲端。前面的方案必須同步的執行這些操作,現在你想要並行的完成這件事情。

看看io.TeeReader,它的函數簽名是這樣的:func TeeReader(r Reader, w Writer) Reader。文檔中是這樣描述的:TeeReader將從Reader r讀取的數據返回一個寫到Writer w的Reader。這個正是你所需要的!現在你怎麼確保寫到w的數據可讀?這個是通過io.Pipe實現的,它在io.PipeWriterio.PipeReader之間建立了一個連接(即棧,後入先出)。看看代碼是怎麼實現的:

func HandleUpload(u io.Reader) (err error) {
    //create the pipe and tee reader
    pr, pw := io.Pipe()
    tr := io.TeeReader(u, pw)

    //Create channels to synchronize
    done := make(chan bool)
    errs := make(chan error)
    defer close(done)
    defer close(errs)

    go func() {
        //close the PipeWriter after the 
        //TeeReader completes to trigger EOF
        defer pw.Close()

        //upload the original MP4 data
        err := uploadFile(tr)
        if err != nil {
            errs <- err
            return
        }

        done <- true
    }()

    go func() {
        //transcode to WebM
        webmr, err := transcode(pr)
        if err != nil {
            errs <- err
            return
        }

        //upload to storage
        err = uploadFile(webmr)
        if err != nil {
            errs <- err
            return
        }

        done <- true
    }()

    //wait until both are done
    //or an error occurs
    for c := 0; c < 2; {
        select {
        case err := <-errs:
            return err
        case <- done:
            c++
        }
    }

    return nil
}

因爲uploader將要消費tr,transcoder在將數據存儲前接收並處理相同的數據。所有的操作不需要額外的buffer,並且並行的執行。注意這裏使用goroutine來執行這兩天路徑。io.Pipe處於阻塞狀態直到有程序向它寫或從它讀取數據。如果嘗試在同一個線程中執行相同的io.Pipe,將會得到一個致命錯誤:fatal error;all goroutines are asleep - deadlockpanic。另一個需要注意的點是:使用Pipe時,你需要在一個合適的時間顯示的觸發一個EOF來關閉io.PipeWriter。在這個實力中,需要在TeeReader結束後關閉它。

這個示例同樣採用了channel來進行goroutines之間的“doneness”和error的同步。如果你期望在執行過程中有一些更具體的值返回,你可以使用更合適的類型替換chan bool。

  • 優點:完全獨立的,並行的處理相同的數據流
  • 缺點,使用goroutines和channel增加了複雜度

Solution #5:The Multi-Split io.MultiWriter and io.Copy

io.TeeReader在只有一個其他的流消費者時,能夠非常好的解決問題。由於service可能還需要並行的處理更多的任務(如,轉換成更多的格式),使用tee的疊加將使代碼變得臃腫。看看io.MultiWriter的解釋:“一個將writes複製並提供給多個writers的writer”。它也像前面的方法一樣使用pipes來傳播數據,不同的是,不是使用io.TeeReader,而是使用io.Copy將數據分發到所有的Pipes。示例代碼如下:

func handleUpload(u io.Reader)(err error) {
    //create the pipes
    mp4R, mp4W := io.Pipe()
    webmR, webmW := io.Pipe()
    oggR, oggW := io.Pipe()
    wavR, wavW := io.Pipe()

    //create channels to syschronize
    done := make(chan bool)
    errs := make(chan error)
    defer close(done)
    defer close(err)

    //spawn all the task goroutines. these looks identical to
    //the TeeReader example, but pulled out into separate 
    //methods for clarity
    go uploadMP4(mp4R, done, errs)
    go transcodeAndUploadWebM(webmR, done, errs)
    go transcodeAndUploadOgg(webmR, done, errs)
    go transcodeAndUploadWav(webmR, done, errs)

    go func() {
        // after completing the copy, we need to close
        // the PipeWriters to propagate the EOF to all 
        // PipeReaders to avoid deadlock
        defer mp4W.Close()
        defer webmW.Close()
        defer oggW.Close()
        defer wavW.Close()

        //build the multiwriter for all the pipes
        mw := io.MultiWriter(mp4W, webmW, oggW, wavW)

        //copy the data into the multiwriter
        _, err := io.Copy(mw, u)
        if err != nil {
            errs <- err
        }
    }()

     // wait until all are done
     // or an error occurs
     for c := 0; c < 4; c++ {
        select {
        case err := <-errs:
            return err
        case <-done:
        }
    }
    return nil
}

這個方法和前面的方法有點類似,但是當數據需要被克隆多次時,這種方法明顯的更加簡潔。因爲使用了PIPEs,同樣需要使用goroutines和同步channel,以防止死鎖。我們在copy完成了關閉了所有的pipes。

  • 優點:可以根據需要fork多份原始數據
  • 缺點:過多的依賴goroutines和channel進行協調。

關於channels?

Channels是Go提供的獨特的,強大的併發工具之一。它是goroutines之間的橋樑,同時兼顧了通信和同步。你可以創建帶buffer和不帶buffer的channel,來實現數據共享。那麼,爲什麼我不提供一個充分利用Channels的解決方案,而不僅僅是用作同步呢?

查閱了一些標準庫的top-level包,發現channels很少出現在函數簽名中:

  • time: 用於select timeout
  • reflect: …cause reflection
  • fmt: for formatting it as a pointer
  • builtin: expose the close function

io.Pipe的實現中放棄了channel,而使用sync.Mutex來安全的在reader和writer之間移動數據。我懷疑這是因爲Channel的性能並不好,所以在這裏才被Mutex替代。

當開發一個可重複利用的包的時候,我會像標準庫一樣在我公開的API中避免使用Channels,但是會在內部使用它們用作同步。如果複雜度足夠的低,使用mutex替代channel也許更加理想。這也就是說,在程序開發中,channel是更完美的抽象,比lock更好使用,更加靈活。

拋磚迎玉

我在這裏只是拋出了屈指可數的幾種方法處理從io.Reader獲取的數據,毫無疑問,肯定還有更多的方法。Go的隱式接口模型(implicit interface model)+ 標準庫的大量使用允許創造性的將不同組件組合而不用擔心數據。我希望我在這裏的一些探討對你有所幫助,正如它們對我有用一樣.

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