零拷貝及一些引申內容

這個屬於高頻面試題的一個,很可能是作爲 Kafka 的延伸出來,之前一直沒有詳細的去總結一下。被許多讀者問過,同事也問過,在當時理解比較模糊的情況下我回答的並不好。現在這幾天我可能刷其它作者的內容比較多,在看了的部分相關內容後,現在再回過頭去提一下子,應該算是對這個有比較好的理解了。

你會如何實現文件傳輸?

服務器提供文件傳輸功能,需要將磁盤上的文件讀取出來,通過網絡協議發送到客戶端。如果需要你自己編碼實現這個文件傳輸功能,你會怎麼實現呢?

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

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

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

這個系統調用的內容,我們可以結合下面補充的三種 “上下文切換” 來理解,上下文切換分別是進程,線程,中斷三種。

補充:進程上下文切換

Linux 按照特權等級,把進程的運行空間分爲內核空間和用戶空間,分別對應着下圖中, CPU 特權等級的 Ring 0 和 Ring 3。內核空間(Ring 0)具有最高權限,可以直接訪問所有資源,而用戶空間(Ring 3)只能訪問受限資源,不能直接訪問內存等硬件設備,必須通過系統調用陷入到內核中,才能訪問這些特權資源。

換個角度看,也就是說,進程既可以在用戶空間運行,又可以在內核空間中運行。進程在用戶空間運行時,被稱爲進程的用戶態,而陷入內核空間的時候,被稱爲進程的內核態。從用戶態到內核態的轉變,需要通過系統調用來完成。比如,當我們查看文件內容時,就需要多次系統調用來完成:首先調用 open() 打開文件,然後調用 read() 讀取文件內容,並調用 write() 將內容寫到標準輸出,最後再調用 close() 關閉文件。

那麼系統調用的過程是如何發生 CPU 上下文的切換的呢?我們再瞭解兩個概念:

知道了什麼是 CPU 上下文,我想你也很容易理解 CPU 上下文切換。CPU 上下文切換,就是先把前一個任務的 CPU 上下文(也就是 CPU 寄存器和程序計數器)保存起來,然後加載新任務的上下文到這些寄存器和程序計數器,最後再跳轉到程序計數器所指的新位置,運行新任務。而這些保存下來的上下文,會存儲在系統內核中,並在任務重新調度執行時再次加載進來。這樣就能保證任務原來的狀態不受影響,讓任務看起來還是連續運行。

回到系統調用的問題上,CPU 寄存器裏原來用戶態的指令位置,需要先保存起來。接着,爲了執行內核態代碼,CPU 寄存器需要更新爲內核態指令的新位置。最後纔是跳轉到內核態運行內核任務。而系統調用結束後,CPU 寄存器需要恢復原來保存的用戶態,然後再切換到用戶空間,繼續運行進程。所以,一次系統調用的過程,其實是發生了兩次 CPU 上下文切換。

不過,需要注意的是,系統調用過程中,並不會涉及到虛擬內存等進程用戶態的資源,也不會切換進程。這跟我們通常所說的進程上下文切換是不一樣的:

那麼,進程上下文切換跟系統調用又有什麼區別呢?首先,你需要知道,進程是由內核來管理和調度的,進程的切換隻能發生在內核態。所以,進程的上下文不僅包括了虛擬內存、棧、全局變量等用戶空間的資源,還包括了內核堆棧、寄存器等內核空間的狀態。因此,進程的上下文切換就比系統調用時多了一步:在保存當前進程的內核狀態和 CPU 寄存器之前,需要先把該進程的虛擬內存、棧等保存下來;而加載了下一進程的內核態後,還需要刷新進程的虛擬內存和用戶棧。

保存上下文和恢復上下文的過程並不是 “免費” 的,需要內核在 CPU 上運行才能完成。

每次上下文切換都需要幾十納秒到數微秒的 CPU 時間。這個時間還是相當可觀的,特別是在進程上下文切換次數較多的情況下,很容易導致 CPU 將大量時間耗費在寄存器、內核棧以及虛擬內存等資源的保存和恢復上,進而大大縮短了真正運行進程的時間。Linux 通過 TLB(Translation Lookaside Buffer)來管理虛擬內存到物理內存的映射關係。當虛擬內存更新後,TLB 也需要刷新,內存的訪問也會隨之變慢。特別是在多處理器系統上,緩存是被多個處理器共享的,刷新緩存不僅會影響當前處理器的進程,還會影響共享緩存的其他處理器的進程。

TLB,這個東西的資料比較晦澀難懂,我大致搜了一下,非常多的專業術語,不太建議大家展開了,等到我們真的要用上的時候,再去了解也不晚,大致內容我覺得如果要展開,那就展開我下面的這個部分就已經足夠了。

TLB 是一種高速緩存,內存管理硬件使用它來改善虛擬地址到物理地址的轉換速度。當前所有的個人桌面,筆記本和服務器處理器都使用 TLB 來進行虛擬地址到物理地址的映射。使用 TLB 內核可以快速的找到虛擬地址指向物理地址,而不需要請求 RAM 內存獲取虛擬地址到物理地址的映射關係。

虛擬地址和物理地址的話,大致是這麼理解的。每個進程都有自己獨立的 4G 內存空間,各個進程的內存空間具有類似的結構。一個新進程建立的時候,將會建立起自己的內存空間,此進程的數據,代碼等從磁盤拷貝到自己的進程空間,哪些數據在哪裏,都由進程控制表中的 task_struct 記錄,它會有一條鏈表,記錄中內存空間的分配情況,哪些地址有數據,哪些地址無數據,哪些可讀,哪些可寫,都可以通過這個鏈表記錄。每個進程已經分配的內存空間,都與對應的磁盤空間映射。

可是計算機明明沒有那麼多內存(n 個進程的話就需要 n*4G)內存。還有建立一個進程,就要把磁盤上的程序文件拷貝到進程對應的內存中去,對於一個程序對應的多個進程這種情況是根本不需要這樣操作的。

所以,每個進程的 4G 內存空間只是虛擬內存空間,每次訪問內存空間的某個地址,都需要把地址翻譯爲實際物理內存地址。所有進程共享同一物理內存,每個進程只把自己目前需要的虛擬內存空間映射並存儲到物理內存上。進程要知道哪些內存地址上的數據在物理內存上,哪些不在,還有在物理內存上的哪裏,需要用頁表來記錄。頁表的每一個表項分兩部分,第一部分記錄此頁是否在物理內存上,第二部分記錄物理內存頁的地址(如果在的話)。當進程訪問某個虛擬地址,去看頁表,如果發現對應的數據不在物理內存中,則缺頁異常。缺頁異常的處理過程,就是把進程需要的數據從磁盤上拷貝到物理內存中。

知道了進程上下文切換潛在的性能問題後,我們再來看,究竟什麼時候會切換進程上下文。顯然,只有在進程調度的時候,才需要切換上下文。Linux 爲每個 CPU 都維護了一個就緒隊列,將活躍進程(即正在運行和正在等待 CPU 的進程)按照優先級和等待 CPU 的時間排序,然後選擇最需要 CPU 的進程,也就是優先級最高和等待 CPU 時間最長的進程來運行。

那麼,進程在什麼時候纔會被調度到 CPU 上運行呢?最容易想到的一個時機,就是進程執行完終止了,它之前使用的 CPU 會釋放出來,這個時候再從就緒隊列裏,拿一個新的進程過來運行。其實還有很多其他場景,也會觸發進程調度,在這裏我給你逐個梳理下。

其一,爲了保證所有進程可以得到公平調度,CPU 時間被劃分爲一段段的時間片,這些時間片再被輪流分配給各個進程。這樣,當某個進程的時間片耗盡了,就會被系統掛起,切換到其它正在等待 CPU 的進程運行。

其二,進程在系統資源不足(比如內存不足)時,要等到資源滿足後纔可以運行,這個時候進程也會被掛起,並由系統調度其他進程運行。

其三,當進程通過 sleep 這樣的方法將自己主動掛起時,自然也會重新調度。

其四,當有優先級更高的進程運行時,爲了保證高優先級進程的運行,當前進程會被掛起,由高優先級進程來運行。

最後一個,發生硬件中斷時,CPU 上的進程會被中斷掛起,轉而執行內核中的中斷服務程序。

線程上下文切換

線程與進程最大的區別在於,線程是調度的基本單位,而進程則是資源擁有的基本單位。說白了,所謂內核中的任務調度,實際上的調度對象是線程;而進程只是給線程提供了虛擬內存、全局變量等資源。所以,對於線程和進程,我們可以這麼理解:

這麼一來,線程的上下文切換其實就可以分爲兩種情況:

第一種, 前後兩個線程屬於不同進程。此時,因爲資源不共享,所以切換過程就跟進程上下文切換是一樣。

第二種,前後兩個線程屬於同一個進程。此時,因爲虛擬內存是共享的,所以在切換時,虛擬內存這些資源就保持不動,只需要切換線程的私有數據、寄存器等不共享的數據。到這裏你應該也發現了,雖然同爲上下文切換,但同進程內的線程切換,要比多進程間的切換消耗更少的資源,而這,也正是多線程代替多進程的一個優勢。

中斷上下文切換

一個場景也會切換 CPU 上下文,那就是中斷。爲了快速響應硬件的事件,中斷處理會打斷進程的正常調度和執行,轉而調用中斷處理程序,響應設備事件。而在打斷其他進程時,就需要將進程當前的狀態保存下來,這樣在中斷結束後,進程仍然可以從原來的狀態恢復運行。

跟進程上下文不同,中斷上下文切換並不涉及到進程的用戶態。所以,即便中斷過程打斷了一個正處在用戶態的進程,也不需要保存和恢復這個進程的虛擬內存、全局變量等用戶態資源。中斷上下文,其實只包括內核態中斷服務程序執行所必需的狀態,包括 CPU 寄存器、內核堆棧、硬件中斷參數等。對同一個 CPU 來說,中斷處理比進程擁有更高的優先級,所以中斷上下文切換並不會與進程上下文切換同時發生。

同樣道理,由於中斷會打斷正常進程的調度和執行,所以大部分中斷處理程序都短小精悍,以便儘可能快的執行結束。另外,跟進程上下文切換一樣,中斷上下文切換也需要消耗 CPU,切換次數過多也會耗費大量的 CPU,嚴重降低系統的整體性能。

總結一下,CPU 上下文切換,是保證 Linux 系統正常工作的核心功能之一,一般情況下不需要我們特別關注。但過多的上下文切換,會把 CPU 時間消耗在寄存器、內核棧以及虛擬內存等數據的保存和恢復上,從而縮短進程真正運行的時間,導致系統的整體性能大幅下降。

回到零拷貝的事兒上

剛剛我們的場景,每處理 32KB,就有 4 次上下文切換,重複 1 萬次後就有 4 萬次切換。上下文切換的成本並不小,雖然一次切換僅消耗幾十納秒到幾微秒,但高併發服務就會放大這類時間的消耗。其次,這個方案做了 4 萬次內存拷貝,對 320MB 文件拷貝的字節數也翻了 4 倍,到了 1280MB。很顯然,過多的內存拷貝無謂地消耗了 CPU 資源,降低了系統的併發處理能力。所以要想提升傳輸文件的性能,需要從降低上下文切換的頻率和內存拷貝次數兩個方向入手。

零拷貝如何提升文件傳輸性能?

再提一句,爲什麼讀取磁盤文件時,一定要做上下文切換呢?這是因爲,讀取磁盤或者操作網卡都由操作系統內核完成。內核負責管理系統上的所有進程,它的權限最高,工作環境與用戶進程完全不同。只要我們的代碼執行 read 或者 write 這樣的系統調用,一定會發生 2 次上下文切換:首先從用戶態切換到內核態,當內核執行完任務後,再切換回用戶態交由進程代碼執行。因此,如果想減少上下文切換次數,就一定要減少系統調用的次數。解決方案就是把 read、write 兩次系統調用合併成一次,在內核中完成磁盤與網卡的數據交換。

其次,我們應該考慮如何減少內存拷貝次數。每週期中的 4 次內存拷貝,其中與物理設備相關的 2 次拷貝是必不可少的,包括:把磁盤內容拷貝到內存,以及把內存拷貝到網卡。但另外 2 次與用戶緩衝區相關的拷貝動作都不是必需的,因爲在把磁盤文件發到網絡的場景中,用戶緩衝區沒有必須存在的理由。如果內核在讀取文件後,直接把 PageCache 中的內容拷貝到 Socket 緩衝區,待到網卡發送完畢後,再通知進程,這樣就只有 2 次上下文切換,和 3 次內存拷貝。

如果網卡支持 SG-DMA(The Scatter-Gather Direct Memory Access)技術,還可以再去除 Socket 緩衝區的拷貝,這樣一共只有 2 次內存拷貝。在 DMA 傳輸數據的過程中,要求源物理地址和目標物理地址必須是連續的。可是連續的存儲器地址在物理上不一定是連續的,所以 DMA 傳輸要分成多次完成。如果在傳輸完一塊物理上連續的數據後引起一次中斷,然後再由主機進行下一塊物理上連續的數據傳輸。Scatter-gather DMA 方式則不同,它使用一個鏈表描述物理上不連續的存儲空間,然後把鏈表首地址告訴 DMA master。DMA master 在傳輸完一塊物理連續的數據後,不用發起中斷,而是根據鏈表來傳輸下一塊物理上連續的數據,直到傳輸完畢後再發起一次中斷。

實際上,這就是零拷貝技術。它是操作系統提供的新函數,同時接收文件描述符和 TCP socket 作爲輸入參數,這樣執行時就可以完全在內核態完成內存拷貝,既減少了內存拷貝次數,也降低了上下文切換次數。而且,零拷貝取消了用戶緩衝區後,不只降低了用戶內存的消耗,還通過最大化利用 socket 緩衝區中的內存,間接地再一次減少了系統調用的次數,從而帶來了大幅減少上下文切換次數的機會。

你可以回憶下,沒用零拷貝時,爲了傳輸 320MB 的文件,在用戶緩衝區分配了 32KB 的內存,把文件分成 1 萬份傳送,然而,這 32KB 是怎麼來的?爲什麼不是 32MB 或者 32 字節呢?這是因爲,在沒有零拷貝的情況下,我們希望內存的利用率最高。如果用戶緩衝區過大,它就無法一次性把消息全拷貝給 socket 緩衝區(這裏是 socket 的大小有所限制);如果用戶緩衝區過小,則會導致過多的 read/write 系統調用。

那用戶緩衝區爲什麼不與 socket 緩衝區大小一致呢?這是因爲,socket 緩衝區的可用空間是動態變化的,它既用於 TCP 滑動窗口,也用於應用緩衝區,還受到整個系統內存的影響。尤其在長肥網絡中,它的變化範圍特別大。

零拷貝使我們不必關心 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 緩存最近訪問的數據,當空間不足時淘汰最久未被訪問的緩存(即 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,進而也不應使用零拷貝技術處理。用看電影來舉例的話,就是我只想看前 10 分鐘,就要把整部都下下來,這明顯是虧的。而高併發場景處理大文件時,應當使用異步 IO 和直接 IO 來替換零拷貝技術。

異步 IO + 直接 IO

回到開頭的例子,當調用 read 方法讀取文件時,實際上 read 方法會在磁盤尋址過程中阻塞等待,導致進程無法併發地處理其他任務,如下圖所示:也就是在拉數據的過程中,一整個流程下來進程都是阻塞的意思。

異步 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 的缺點就是無法享受 PageCache 的好處,也就是內核(IO 調度算法)會試圖緩存儘量多的連續 IO 在 PageCache 中,最後合併成一個更大的 IO 再發給磁盤,這樣可以減少磁盤的尋址操作;另外,內核也會預讀後續的 IO 放在 PageCache 中,減少磁盤操作。這些它都是做不到的。

小結

基於用戶緩衝區傳輸文件時,過多的內存拷貝與上下文切換次數會降低性能。零拷貝技術在內核中完成內存拷貝,天然降低了內存拷貝次數。它通過一次系統調用合併了磁盤讀取與網絡發送兩個操作,降低了上下文切換次數。尤其是,由於拷貝在內核中完成,它可以最大化使用 socket 緩衝區的可用空間,從而提高了一次系統調用中處理的數據量,進一步降低了上下文切換次數。

零拷貝技術基於 PageCache,而 PageCache 緩存了最近訪問過的數據,提升了訪問緩存數據的性能,同時,爲了解決機械磁盤尋址慢的問題,它還協助 IO 調度算法實現了 IO 合併與預讀(這也是順序讀比隨機讀性能好的原因),這進一步提升了零拷貝的性能。幾乎所有操作系統都支持零拷貝,如果應用場景就是把文件發送到網絡中,那麼零拷貝確實是個好方法。

Tips:其實這裏如果是使用 SSD 這類固態硬盤(不用旋轉磁頭),PageCache 就沒有很大的影響,細節請參照我上一篇的那個 SSD 的文章:https://juejin.cn/post/6854573209501564941。

不過,零拷貝有一個缺點,就是不允許進程對文件內容作一些加工再發送,比如數據壓縮後再發送。另外,當 PageCache 引發負作用時,也不能使用零拷貝,此時可以用異步 IO+ 直接 IO 替換。我們通常會設定一個文件大小閾值,針對大文件使用異步 IO 和直接 IO,而對小文件使用零拷貝。

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/l_MRLyRW8lxvjtsKapT6HA