同步上載與 RAM 不足的問題
Toby
Toby

在 Raspberry Pi 上面執行類似 arozos 之類的大型系統不免會出現一個問題,就是當要處理大量 IO 的時候總會卡住(畢竟最快的儲存器( RAM、記憶體) 只有那 2 – 4GB,雖然說現在已經出到 8GB 了,但是那個價格我倒不如買台二手的 ThinkCentre M73?),所以要如何在有限 RAM 的情況下讓檔案上下載速度最佳化是其中一個很有趣的研究方向。

上載的方式

Single-thread Upload (單執行緒上載)

我們就先由正常的上載情況說起吧,我在這裡說的就是一般的 HTML5 Form 的檔案上載。每次一個檔案,很簡單吧?可是這速度實在太慢了,例如 Google Drive 之類的很久之前就已經用了多執行緒上載了,所以要一個一個檔案上載的話,速度是無法接受的。

Multi-thread Upload (多執行緒上載)

就是如此,我在這文章裡說的主要是多執行緒上載模式。簡單來說 Multi-thread Upload 就是由 Browser 對伺服器的上載接點打開多個 request,並同時向接點上載資料的方式。

那 Multi-thread Upload 又有甚麼問題?

Multi-thread Upload 雖然能夠縮短使用者上載檔案的時間,能夠盡用 bandwidth,但是伺服器也要處理短時間內同時上載的檔案,導致系統的開發有點考功夫,有時候甚至要考慮 Race Condition 問題。

在以前純 PHP 5 + Apache 2的年代,伺服器每次只能夠同時處理一個要求,因此很少會出現資源不足的這個問題。但是到現在,很多人都轉用更先進的語言來開發網頁伺服器了,自然也需要處理到資源分配的問題。以下我就以 Go 作為「思考語言」來分析一下吧。

先 Buffer (緩存)到 RAM 不就行了嗎?

Buffering 是一個常用來處理 IO 的方法,由於下載的時候就是由作業系統跟硬碟的緩存機制處理,所以我們只需要處理上載就好。我們就先假設系統採用以下的一個邏輯處理使用者上載的檔案

//建立一個 25MB 的 Buffer
r.ParseMultipartForm(25 << 20) 

//從 POST 中取得 file
file, handler, _ := r.FormFile("file")

//建立儲存的檔案
dest, _ := os.Create(handler.Filename)

//從 File 把內容複制到 dest
if _, err := io.Copy(dest, file); err != nil {
	panic(err)
}

//關閉打開了的 file descriptor (通稱 fd,即檔案描述符)
file.Close()
dest.Close()

你那看到,在檔案上載到伺服器的一刻,伺服器已經分配了 25MB 的記憶體給這個 request 了。而這個 request 會把這空間卡住,直到 file.Close() 之前都不會把記憶體還給作業系統,因此每上載一個檔案 = 吃掉一個不少於檔案大小的記憶空間。而在 file.Close() 之前還有一個 blocking 的 io.Copy() 把上載的資料複制到最終的儲存位置(例如硬碟),所以最後就是要等硬碟寫完才能把記憶體釋放出來。

簡單來說就是這樣

所以,這東西很慢!

為了解決慢的問題,我們來做多執行緒的檔案搬運吧!
Go-routine 是一個很神奇的東西,不需要幾行我們就可以寫出支援 Multi-threading 的程式,只要我們把 io.Copy 的部分改成:


//建立儲存的檔案
dest, _ := os.Create(handler.Filename)

//把複制的部分放到 Go routine,讓客戶端繼續上載下一個檔案
go func(dest *os.File, file *os.File)
    //從 File 把內容複制到 dest
    if _, err := io.Copy(dest, file); err != nil {
	panic(err)
    }
    //關閉打開了的 file descriptor (通稱 fd,即檔案描述符)
    file.Close()
    dest.Close()
}(dest, file)

新的方法看起來像這樣,比起上面的方法快多了

對,這樣檔案上載的速度快多了,而且還能同時發起幾個上載的要求,但是這還在檔案比較小,RAM 裝得下的情況下都還好,但是檔案大的時候該怎麼辦?

例如說,在只有 2GB RAM 的 Raspberry Pi 上載一個 4GB 的檔案,用上面的那個方法來處理的話,通常只會有這個結果:

  1. RAM 被上載的資料塞滿了
  2. 上傳的資料被塞進 page,然後上載得超慢(就是你硬碟的寫入速度)

所以,解決完速度問題之後就是儲存空間不足的問題啊!?

其實這個現像有一個學名,叫:Space–time tradeoff
簡單來說,對於任何一個演算法或是程式邏輯,你總是可以用儲存空間來換取運算時間,反之亦言。

舉個例子:例如說著名的「Brute Force (暴力破解)」演算法,所需的空間很少,但是需要很長時間去運算要嘗試的變數;與之相反的就是「Rainbow Table (彩虹表)」,採用預先運算的結果來測試變數,但是因為 Rainbow Table 本身就是一個超長的列表,所以需要「大量 」的儲存空間。

為甚麼儲存空間會是一個問題?

在一些 RAM 更少的開發板例如 Orange Pi Zero 或是 Nano Pi 系列,Multi-thread Upload 導致 RAM 不足的問題就更多明顯了。此方式上載檔案很容易會導政 RAM 用量不穩定,甚至有時候會超出限制的使用量,再加上使用 SD 卡作為儲存媒介,當檔案真的被寫入 page 的時候就會慢得讓你想直接拔掉電源把開發板掉出窗外。

中庸之道:有限制的多執行緒上載

如果你有用 Google Drive 的習慣,你應該也會發現 Google Drive 只會讓你每個 Tab 同時上載兩個檔案。這原來是有原因的啊!就是為了給伺服器緩衝的空間,去處理資源分配的問題,把記憶體清空了再接你下一批檔案。在這情況下無論你怎樣上載檔案,每個客戶所佔用的伺服器資源也是穩定的。

至於怎樣處理比 RAM 還要大的檔案的方法嘛?

這個之後會另外寫一篇文章講解怎樣在 Linux 下增指定 page 使用的硬碟 / SSD。