天天看點

如何高效實作檔案傳輸:小檔案采用零拷貝、大檔案采用異步io+直接io

一般會如何實作檔案傳輸?

伺服器提供檔案傳輸功能,需要将磁盤上的檔案讀取出來,通過網絡協定發送到用戶端。如果需要你自己編碼實作這個檔案傳輸功能,你會怎麼實作呢?

通常,你會選擇最直接的方法:從網絡請求中找出檔案在磁盤中的路徑後,如果這個檔案比較大,假設有 320MB,可以在記憶體中配置設定 32KB 的緩沖區,再把檔案分成一萬份,每份隻有 32KB,這樣,從檔案的起始位置讀入 32KB 到緩沖區,再通過網絡 API 把這 32KB 發送到用戶端。接着重複一萬次,直到把完整的檔案都發送完畢。如下圖所示: 

如何高效實作檔案傳輸:小檔案采用零拷貝、大檔案采用異步io+直接io

 不過這個方案性能并不好,主要有兩個原因。

上下文切換:

首先,它至少經曆了 4 萬次使用者态與核心态的上下文切換。因為每處理 32KB 的消息,就需要一次 read 調用和一次 write 調用,每次系統調用都得先從使用者态切換到核心态,等核心完成任務後,再從核心态切換回使用者态。可見,每處理 32KB,就有 4 次上下文切換,重複 1 萬次後就有 4 萬次切換。

上下文切換的成本并不小,雖然一次切換僅消耗幾十納秒到幾微秒,但高并發服務會放大這類時間的消耗。

記憶體拷貝:

其次,這個方案做了 4 萬次記憶體拷貝,對 320MB 檔案拷貝的位元組數也翻了 4 倍,到了 1280MB。很顯然,過多的記憶體拷貝無謂地消耗了 CPU 資源,降低了系統的并發處理能力。

是以要想提升傳輸檔案的性能,需要從降低上下文切換的頻率和記憶體拷貝次數兩個方向入手。

 零拷貝如何提升檔案傳輸性能?

首先,我們來看如何降低上下文切換的頻率。

為什麼讀取磁盤檔案時,一定要做上下文切換呢?這是因為,讀取磁盤或者操作網卡都由作業系統核心完成。核心負責管理系統上的所有程序,它的權限最高,工作環境與使用者程序完全不同。隻要我們的代碼執行 read 或者 write 這樣的系統調用,一定會發生 2 次上下文切換:首先從使用者态切換到核心态,當核心執行完任務後,再切換回使用者态交由程序代碼執行。

是以,如果想減少上下文切換次數,就一定要減少系統調用的次數。解決方案就是把 read、write 兩次系統調用合并成一次,在核心中完成磁盤與網卡的資料交換。

其次,我們應該考慮如何減少記憶體拷貝次數。

每周期中的 4 次記憶體拷貝,其中與實體裝置相關的 2 次拷貝是必不可少的,包括:把磁盤内容拷貝到記憶體,以及把記憶體拷貝到網卡。但另外 2 次與使用者緩沖區相關的拷貝動作都不是必需的,因為在把磁盤檔案發到網絡的場景中,使用者緩沖區沒有必須存在的理由。

如果核心在讀取檔案後,直接把 PageCache 中的内容拷貝到 Socket 緩沖區,待到網卡發送完畢後,再通知程序,這樣就隻有 2 次上下文切換,和 3 次記憶體拷貝。

如何高效實作檔案傳輸:小檔案采用零拷貝、大檔案采用異步io+直接io

如果網卡支援 SG-DMA(The Scatter-Gather Direct Memory Access)技術,還可以再去除 Socket 緩沖區的拷貝,這樣一共隻有 2 次記憶體拷貝。

如何高效實作檔案傳輸:小檔案采用零拷貝、大檔案采用異步io+直接io

實際上,這就是零拷貝技術。

它是作業系統提供的新函數,同時接收檔案描述符和 TCP socket 作為輸入參數,這樣執行時就可以不需要使用者層緩存,完全在核心态完成記憶體拷貝,既減少了記憶體拷貝次數,也降低了上下文切換次數。

而且,零拷貝取消了使用者緩沖區後,不隻降低了使用者記憶體的消耗,還通過最大化利用 socket 緩沖區中的記憶體,間接地再一次減少了系統調用的次數,進而帶來了大幅減少上下文切換次數的機會!

你可以回憶下,沒用零拷貝時,為了傳輸 320MB 的檔案,在使用者緩沖區配置設定了 32KB 的記憶體,把檔案分成 1 萬份傳送,然而,這 32KB 是怎麼來的?為什麼不是 32MB 或者 32 位元組呢?這是因為,在沒有零拷貝的情況下,我們希望記憶體的使用率最高。如果使用者緩沖區過大,它就無法一次性把消息全拷貝給 socket 緩沖區;如果使用者緩沖區過小,則會導緻過多的 read/write 系統調用。

那使用者緩沖區為什麼不與 socket 緩沖區大小一緻呢?這是因為,socket 緩沖區的可用空間是動态變化的,它既用于 TCP 滑動視窗,也用于應用緩沖區,還受到整個系統記憶體的影響(我在《Web 協定詳解與抓包實戰》第 5 部分課程對此有詳細介紹,這裡不再贅述)。尤其在長肥網絡中,它的變化範圍特别大。

零拷貝使我們不必關心 socket 緩沖區的大小。比如,調用零拷貝發送方法時,盡可以把發送位元組數設為檔案的所有未發送位元組數,例如 320MB,也許此時 socket 緩沖區大小為 1.4MB,那麼一次性就會發送 1.4MB 到用戶端,而不是隻有 32KB。這意味着對于 1.4MB 的 1 次零拷貝,僅帶來 2 次上下文切換,而不使用零拷貝且使用者緩沖區為 32KB 時,經曆了 176 次(4 * 1.4MB/32KB)上下文切換。

綜合上述各種優點,零拷貝可以把性能提升至少一倍以上!對文章開頭提到的 320MB 檔案的傳輸,當 socket 緩沖區在 1.4MB 左右時,隻需要 4 百多次上下文切換,以及 4 百多次記憶體拷貝,拷貝的資料量也僅有 640MB,這樣,不隻請求時延會降低,處理每個請求消耗的 CPU 資源也會更少,進而支援更多的并發請求。

此外,零拷貝還使用了 PageCache 技術,通過它,零拷貝可以進一步提升性能,我們接下來看看 PageCache 是如何做到這一點的。 

 PageCache,磁盤高速緩存

回顧上文中的幾張圖,你會發現,讀取檔案時,是先把磁盤檔案拷貝到 PageCache 上,再拷貝到程序中。為什麼這樣做呢?有兩個原因所緻。

第一,由于磁盤比記憶體的速度慢許多,是以我們應該想辦法把讀寫磁盤替換成讀寫記憶體,比如把磁盤中的資料複制到記憶體中,就可以用讀記憶體替換讀磁盤。但是,記憶體空間遠比磁盤要小,記憶體中注定隻能複制一小部分磁盤中的資料。

選擇哪些資料複制到記憶體呢?通常,剛被通路的資料在短時間内再次被通路的機率很高(這也叫“時間局部性”原理),用 PageCache 緩存最近通路的資料,當空間不足時淘汰最久未被通路的緩存(即 LRU 算法)。讀磁盤時優先到 PageCache 中找一找,如果資料存在便直接傳回,這便大大提升了讀磁盤的性能。

第二,讀取磁盤資料時,需要先找到資料所在的位置,對于機械磁盤來說,就是旋轉磁頭到資料所在的扇區,再開始順序讀取資料。其中,旋轉磁頭耗時很長,為了降低它的影響,PageCache 使用了預讀功能。

也就是說,雖然 read 方法隻讀取了 0-32KB 的位元組,但核心會把其後的 32-64KB 也讀取到 PageCache,這後 32KB 讀取的成本很低。如果在 32-64KB 淘汰出 PageCache 前,程序讀取到它了,收益就非常大。這一講的傳輸檔案場景中這是必然發生的。

從這兩點可以看到 PageCache 的優點,它在 90% 以上場景下都會提升磁盤性能,但在某些情況下,PageCache 會不起作用,甚至由于多做了一次記憶體拷貝,造成性能的降低。在這些場景中,使用了 PageCache 的零拷貝也會損失性能。

具體是什麼場景呢?就是在傳輸大檔案的時候。比如,你有很多 GB 級的檔案需要傳輸,每當使用者通路這些大檔案時,核心就會把它們載入到 PageCache 中,這些大檔案很快會把有限的 PageCache 占滿。

然而,由于檔案太大,檔案中某一部分内容被再次通路到的機率其實非常低。這帶來了 2 個問題:首先,由于 PageCache 長期被大檔案占據,熱點小檔案就無法充分使用 PageCache,它們讀起來變慢了;其次,PageCache 中的大檔案沒有享受到緩存的好處,但卻耗費 CPU 多拷貝到 PageCache 一次。

是以,高并發場景下,為了防止 PageCache 被大檔案占滿後不再對小檔案産生作用,大檔案不應使用 PageCache,進而也不應使用零拷貝技術處理。

 異步 IO + 直接 IO

高并發場景處理大檔案時,應當使用異步 IO 和直接 IO 來替換零拷貝技術。

仍然回到本講開頭的例子,當調用 read 方法讀取檔案時,實際上 read 方法會在磁盤尋址過程中阻塞等待,導緻程序無法并發地處理其他任務,如下圖所示:

如何高效實作檔案傳輸:小檔案采用零拷貝、大檔案采用異步io+直接io

異步 IO(異步 IO 既可以處理網絡 IO,也可以處理磁盤 IO,這裡我們隻關注磁盤 IO)可以解決阻塞問題。它把讀操作分為兩部分,前半部分向核心發起讀請求,但不等待資料就位就立刻傳回,此時程序可以并發地處理其他任務。當核心将磁盤中的資料拷貝到程序緩沖區後,程序将接收到核心的通知,再去處理資料,這是異步 IO 的後半部分。如下圖所示:

如何高效實作檔案傳輸:小檔案采用零拷貝、大檔案采用異步io+直接io

從圖中可以看到,異步 IO 并沒有拷貝到 PageCache 中,這其實是異步 IO 實作上的缺陷。經過 PageCache 的 IO 我們稱為緩存 IO,它與虛拟記憶體系統耦合太緊,導緻異步 IO 從誕生起到現在都不支援緩存 IO。

繞過 PageCache 的 IO 是個新物種,我們把它稱為直接 IO。對于磁盤,異步 IO 隻支援直接 IO。

直接 IO 的應用場景并不多,主要有兩種:第一,應用程式已經實作了磁盤檔案的緩存,不需要 PageCache 再次緩存,引發額外的性能消耗。比如 MySQL 等資料庫就使用直接 IO;第二,高并發下傳輸大檔案,我們上文提到過,大檔案難以命中 PageCache 緩存,又帶來額外的記憶體拷貝,同時還擠占了小檔案使用 PageCache 時需要的記憶體,是以,這時應該使用直接 IO。

當然,直接 IO 也有一定的缺點。除了緩存外,核心(IO 排程算法)會試圖緩存盡量多的連續 IO 在 PageCache 中,最後合并成一個更大的 IO 再發給磁盤,這樣可以減少磁盤的尋址操作;另外,核心也會預讀後續的 IO 放在 PageCache 中,減少磁盤操作。直接 IO 繞過了 PageCache,是以無法享受這些性能提升。

有了直接 IO 後,異步 IO 就可以無阻塞地讀取檔案了。現在,大檔案由異步 IO 和直接 IO 處理,小檔案則交由零拷貝處理,至于判斷檔案大小的門檻值可以靈活配置(參見 Nginx 的 directio 指令)。