前言
零拷貝(Zero-copy)技術(shù)指在計(jì)算機(jī)執(zhí)行操作時(shí),CPU 不需要先將數(shù)據(jù)從一個(gè)內(nèi)存區(qū)域復(fù)制到另一個(gè)內(nèi)存區(qū)域打洼,從而可以減少上下文切換以及 CPU 的拷貝時(shí)間涯竟。它的作用是在數(shù)據(jù)報(bào)從網(wǎng)絡(luò)設(shè)備到用戶程序空間傳遞的過(guò)程中稳诚,減少數(shù)據(jù)拷貝次數(shù),減少系統(tǒng)調(diào)用,實(shí)現(xiàn) CPU 的零參與李破,徹底消除 CPU 在這方面的負(fù)載。實(shí)現(xiàn)零拷貝用到的最主要技術(shù)是 DMA 數(shù)據(jù)傳輸技術(shù)和內(nèi)存區(qū)域映射技術(shù)堕战。
- 零拷貝機(jī)制可以減少數(shù)據(jù)在內(nèi)核緩沖區(qū)和用戶進(jìn)程緩沖區(qū)之間反復(fù)的 I/O 拷貝操作默刚。
- 零拷貝機(jī)制可以減少用戶進(jìn)程地址空間和內(nèi)核地址空間之間因?yàn)樯舷挛那袚Q而帶來(lái)的 CPU 開(kāi)銷。
正文
1. 物理內(nèi)存和虛擬內(nèi)存
由于操作系統(tǒng)的進(jìn)程與進(jìn)程之間是共享 CPU 和內(nèi)存資源的芭毙,因此需要一套完善的內(nèi)存管理機(jī)制防止進(jìn)程之間內(nèi)存泄漏的問(wèn)題筋蓖。為了更加有效地管理內(nèi)存并減少出錯(cuò),現(xiàn)代操作系統(tǒng)提供了一種對(duì)主存的抽象概念退敦,即是虛擬內(nèi)存(Virtual Memory)粘咖。虛擬內(nèi)存為每個(gè)進(jìn)程提供了一個(gè)一致的、私有的地址空間侈百,它讓每個(gè)進(jìn)程產(chǎn)生了一種自己在獨(dú)享主存的錯(cuò)覺(jué)(每個(gè)進(jìn)程擁有一片連續(xù)完整的內(nèi)存空間)瓮下。
1.1. 物理內(nèi)存
物理內(nèi)存(Physical memory)是相對(duì)于虛擬內(nèi)存(Virtual Memory)而言的。物理內(nèi)存指通過(guò)物理內(nèi)存條而獲得的內(nèi)存空間钝域,而虛擬內(nèi)存則是指將硬盤的一塊區(qū)域劃分來(lái)作為內(nèi)存讽坏。內(nèi)存主要作用是在計(jì)算機(jī)運(yùn)行時(shí)為操作系統(tǒng)和各種程序提供臨時(shí)儲(chǔ)存。在應(yīng)用中网梢,自然是顧名思義震缭,物理上,真實(shí)存在的插在主板內(nèi)存槽上的內(nèi)存條的容量的大小战虏。
1.2. 虛擬內(nèi)存
虛擬內(nèi)存是計(jì)算機(jī)系統(tǒng)內(nèi)存管理的一種技術(shù)拣宰。 它使得應(yīng)用程序認(rèn)為它擁有連續(xù)的可用的內(nèi)存(一個(gè)連續(xù)完整的地址空間)。而實(shí)際上烦感,虛擬內(nèi)存通常是被分隔成多個(gè)物理內(nèi)存碎片巡社,還有部分暫時(shí)存儲(chǔ)在外部磁盤存儲(chǔ)器上,在需要時(shí)進(jìn)行數(shù)據(jù)交換手趣,加載到物理內(nèi)存中來(lái)晌该。 目前肥荔,大多數(shù)操作系統(tǒng)都使用了虛擬內(nèi)存,如 Windows 系統(tǒng)的虛擬內(nèi)存朝群、Linux 系統(tǒng)的交換空間等等燕耿。
虛擬內(nèi)存地址和用戶進(jìn)程緊密相關(guān),一般來(lái)說(shuō)不同進(jìn)程里的同一個(gè)虛擬地址指向的物理地址是不一樣的姜胖,所以離開(kāi)進(jìn)程談虛擬內(nèi)存沒(méi)有任何意義誉帅。每個(gè)進(jìn)程所能使用的虛擬地址大小和 CPU 位數(shù)有關(guān)。在 32 位的系統(tǒng)上右莱,虛擬地址空間大小是 2 ^ 32 = 4G蚜锨,在 64位系統(tǒng)上,虛擬地址空間大小是 2 ^ 64 = 2 ^ 34G慢蜓,而實(shí)際的物理內(nèi)存可能遠(yuǎn)遠(yuǎn)小于虛擬內(nèi)存的大小亚再。每個(gè)用戶進(jìn)程維護(hù)了一個(gè)單獨(dú)的頁(yè)表(Page Table),虛擬內(nèi)存和物理內(nèi)存就是通過(guò)這個(gè)頁(yè)表實(shí)現(xiàn)地址空間的映射的晨抡。下面給出兩個(gè)進(jìn)程 A氛悬、B 各自的虛擬內(nèi)存空間以及對(duì)應(yīng)的物理內(nèi)存之間的地址映射示意圖:
當(dāng)進(jìn)程執(zhí)行一個(gè)程序時(shí),需要先從先內(nèi)存中讀取該進(jìn)程的指令凄诞,然后執(zhí)行圆雁,獲取指令時(shí)用到的就是虛擬地址。這個(gè)虛擬地址是程序鏈接時(shí)確定的(內(nèi)核加載并初始化進(jìn)程時(shí)會(huì)調(diào)整動(dòng)態(tài)庫(kù)的地址范圍)帆谍。為了獲取到實(shí)際的數(shù)據(jù)伪朽,CPU 需要將虛擬地址轉(zhuǎn)換成物理地址,CPU 轉(zhuǎn)換地址時(shí)需要用到進(jìn)程的頁(yè)表(Page Table)汛蝙,而頁(yè)表(Page Table)里面的數(shù)據(jù)由操作系統(tǒng)維護(hù)烈涮。
其中頁(yè)表(Page Table)可以簡(jiǎn)單的理解為單個(gè)內(nèi)存映射(Memory Mapping)的鏈表(當(dāng)然實(shí)際結(jié)構(gòu)很復(fù)雜),里面的每個(gè)內(nèi)存映射(Memory Mapping)都將一塊虛擬地址映射到一個(gè)特定的地址空間(物理內(nèi)存或者磁盤存儲(chǔ)空間)窖剑。每個(gè)進(jìn)程擁有自己的頁(yè)表(Page Table)坚洽,和其它進(jìn)程的頁(yè)表(Page Table)沒(méi)有關(guān)系。
通過(guò)上面的介紹西土,我們可以簡(jiǎn)單的將用戶進(jìn)程申請(qǐng)并訪問(wèn)物理內(nèi)存(或磁盤存儲(chǔ)空間)的過(guò)程總結(jié)如下:
- 用戶進(jìn)程向操作系統(tǒng)發(fā)出內(nèi)存申請(qǐng)請(qǐng)求
- 系統(tǒng)會(huì)檢查進(jìn)程的虛擬地址空間是否被用完讶舰,如果有剩余,給進(jìn)程分配虛擬地址
- 系統(tǒng)為這塊虛擬地址創(chuàng)建的內(nèi)存映射(Memory Mapping)需了,并將它放進(jìn)該進(jìn)程的頁(yè)表(Page Table)
- 系統(tǒng)返回虛擬地址給用戶進(jìn)程跳昼,用戶進(jìn)程開(kāi)始訪問(wèn)該虛擬地址
- CPU 根據(jù)虛擬地址在此進(jìn)程的頁(yè)表(Page Table)中找到了相應(yīng)的內(nèi)存映射(Memory Mapping),但是這個(gè)內(nèi)存映射(Memory Mapping)沒(méi)有和物理內(nèi)存關(guān)聯(lián)肋乍,于是產(chǎn)生缺頁(yè)中斷
- 操作系統(tǒng)收到缺頁(yè)中斷后鹅颊,分配真正的物理內(nèi)存并將它關(guān)聯(lián)到頁(yè)表相應(yīng)的內(nèi)存映射(Memory Mapping)。中斷處理完成后 CPU 就可以訪問(wèn)內(nèi)存了
- 當(dāng)然缺頁(yè)中斷不是每次都會(huì)發(fā)生墓造,只有系統(tǒng)覺(jué)得有必要延遲分配內(nèi)存的時(shí)候才用的著堪伍,也即很多時(shí)候在上面的第 3 步系統(tǒng)會(huì)分配真正的物理內(nèi)存并和內(nèi)存映射(Memory Mapping)進(jìn)行關(guān)聯(lián)锚烦。
在用戶進(jìn)程和物理內(nèi)存(磁盤存儲(chǔ)器)之間引入虛擬內(nèi)存主要有以下的優(yōu)點(diǎn):
- 地址空間:提供更大的地址空間,并且地址空間是連續(xù)的帝雇,使得程序編寫涮俄、鏈接更加簡(jiǎn)單
- 進(jìn)程隔離:不同進(jìn)程的虛擬地址之間沒(méi)有關(guān)系,所以一個(gè)進(jìn)程的操作不會(huì)對(duì)其它進(jìn)程造成影響
- 數(shù)據(jù)保護(hù):每塊虛擬內(nèi)存都有相應(yīng)的讀寫屬性摊求,這樣就能保護(hù)程序的代碼段不被修改禽拔,數(shù)據(jù)塊不能被執(zhí)行等刘离,增加了系統(tǒng)的安全性
- 內(nèi)存映射:有了虛擬內(nèi)存之后室叉,可以直接映射磁盤上的文件(可執(zhí)行文件或動(dòng)態(tài)庫(kù))到虛擬地址空間。這樣可以做到物理內(nèi)存延時(shí)分配硫惕,只有在需要讀相應(yīng)的文件的時(shí)候茧痕,才將它真正的從磁盤上加載到內(nèi)存中來(lái),而在內(nèi)存吃緊的時(shí)候又可以將這部分內(nèi)存清空掉恼除,提高物理內(nèi)存利用效率踪旷,并且所有這些對(duì)應(yīng)用程序是都透明的
- 共享內(nèi)存:比如動(dòng)態(tài)庫(kù)只需要在內(nèi)存中存儲(chǔ)一份,然后將它映射到不同進(jìn)程的虛擬地址空間中豁辉,讓進(jìn)程覺(jué)得自己獨(dú)占了這個(gè)文件令野。進(jìn)程間的內(nèi)存共享也可以通過(guò)映射同一塊物理內(nèi)存到進(jìn)程的不同虛擬地址空間來(lái)實(shí)現(xiàn)共享
- 物理內(nèi)存管理:物理地址空間全部由操作系統(tǒng)管理,進(jìn)程無(wú)法直接分配和回收徽级,從而系統(tǒng)可以更好的利用內(nèi)存气破,平衡進(jìn)程間對(duì)內(nèi)存的需求
2. 內(nèi)核空間和用戶空間
操作系統(tǒng)的核心是內(nèi)核,獨(dú)立于普通的應(yīng)用程序餐抢,可以訪問(wèn)受保護(hù)的內(nèi)存空間现使,也有訪問(wèn)底層硬件設(shè)備的權(quán)限。為了避免用戶進(jìn)程直接操作內(nèi)核旷痕,保證內(nèi)核安全碳锈,操作系統(tǒng)將虛擬內(nèi)存劃分為兩部分,一部分是內(nèi)核空間(Kernel-space)欺抗,一部分是用戶空間(User-space)售碳。 在 Linux 系統(tǒng)中,內(nèi)核模塊運(yùn)行在內(nèi)核空間绞呈,對(duì)應(yīng)的進(jìn)程處于內(nèi)核態(tài)贸人;而用戶程序運(yùn)行在用戶空間,對(duì)應(yīng)的進(jìn)程處于用戶態(tài)报强。
內(nèi)核進(jìn)程和用戶進(jìn)程所占的虛擬內(nèi)存比例是 1:3灸姊,而 Linux x86_32 系統(tǒng)的尋址空間(虛擬存儲(chǔ)空間)為 4G(2的32次方),將最高的 1G 的字節(jié)(從虛擬地址 0xC0000000 到 0xFFFFFFFF)供內(nèi)核進(jìn)程使用秉溉,稱為內(nèi)核空間力惯;而較低的 3G 的字節(jié)(從虛擬地址 0x00000000 到 0xBFFFFFFF)碗誉,供各個(gè)用戶進(jìn)程使用,稱為用戶空間父晶。下圖是一個(gè)進(jìn)程的用戶空間和內(nèi)核空間的內(nèi)存布局:
2.1. 內(nèi)核空間
內(nèi)核空間總是駐留在內(nèi)存中哮缺,它是為操作系統(tǒng)的內(nèi)核保留的。應(yīng)用程序是不允許直接在該區(qū)域進(jìn)行讀寫或直接調(diào)用內(nèi)核代碼定義的函數(shù)的甲喝。上圖左側(cè)區(qū)域?yàn)閮?nèi)核進(jìn)程對(duì)應(yīng)的虛擬內(nèi)存尝苇,按訪問(wèn)權(quán)限可以分為進(jìn)程私有和進(jìn)程共享兩塊區(qū)域。
- 進(jìn)程私有的虛擬內(nèi)存:每個(gè)進(jìn)程都有單獨(dú)的內(nèi)核棧埠胖、頁(yè)表糠溜、task 結(jié)構(gòu)以及 mem_map 結(jié)構(gòu)等。
- 進(jìn)程共享的虛擬內(nèi)存:屬于所有進(jìn)程共享的內(nèi)存區(qū)域直撤,包括物理存儲(chǔ)器非竿、內(nèi)核數(shù)據(jù)和內(nèi)核代碼區(qū)域。
2.2. 用戶空間
每個(gè)普通的用戶進(jìn)程都有一個(gè)單獨(dú)的用戶空間谋竖,處于用戶態(tài)的進(jìn)程不能訪問(wèn)內(nèi)核空間中的數(shù)據(jù)红柱,也不能直接調(diào)用內(nèi)核函數(shù)的 ,因此要進(jìn)行系統(tǒng)調(diào)用的時(shí)候蓖乘,就要將進(jìn)程切換到內(nèi)核態(tài)才行锤悄。用戶空間包括以下幾個(gè)內(nèi)存區(qū)域:
- 運(yùn)行時(shí)棧:由編譯器自動(dòng)釋放,存放函數(shù)的參數(shù)值嘉抒,局部變量和方法返回值等零聚。每當(dāng)一個(gè)函數(shù)被調(diào)用時(shí),該函數(shù)的返回類型和一些調(diào)用的信息被存儲(chǔ)到棧頂众眨,調(diào)用結(jié)束后調(diào)用信息會(huì)被彈出彈出并釋放掉內(nèi)存握牧。棧區(qū)是從高地址位向低地址位增長(zhǎng)的,是一塊連續(xù)的內(nèi)在區(qū)域娩梨,最大容量是由系統(tǒng)預(yù)先定義好的沿腰,申請(qǐng)的棧空間超過(guò)這個(gè)界限時(shí)會(huì)提示溢出狈定,用戶能從棧中獲取的空間較小颂龙。
- 運(yùn)行時(shí)堆:用于存放進(jìn)程運(yùn)行中被動(dòng)態(tài)分配的內(nèi)存段,位于 BSS 和棧中間的地址位纽什。由卡發(fā)人員申請(qǐng)分配(malloc)和釋放(free)措嵌。堆是從低地址位向高地址位增長(zhǎng),采用鏈?zhǔn)酱鎯?chǔ)結(jié)構(gòu)芦缰。頻繁地 malloc/free 造成內(nèi)存空間的不連續(xù)企巢,產(chǎn)生大量碎片。當(dāng)申請(qǐng)堆空間時(shí)让蕾,庫(kù)函數(shù)按照一定的算法搜索可用的足夠大的空間浪规。因此堆的效率比棧要低的多或听。
- 代碼段:存放 CPU 可以執(zhí)行的機(jī)器指令,該部分內(nèi)存只能讀不能寫笋婿。通常代碼區(qū)是共享的誉裆,即其它執(zhí)行程序可調(diào)用它。假如機(jī)器中有數(shù)個(gè)進(jìn)程運(yùn)行相同的一個(gè)程序缸濒,那么它們就可以使用同一個(gè)代碼段足丢。
- 未初始化的數(shù)據(jù)段:存放未初始化的全局變量,BSS 的數(shù)據(jù)在程序開(kāi)始執(zhí)行之前被初始化為 0 或 NULL庇配。
- 已初始化的數(shù)據(jù)段:存放已初始化的全局變量斩跌,包括靜態(tài)全局變量、靜態(tài)局部變量以及常量讨永。
- 內(nèi)存映射區(qū)域:例如將動(dòng)態(tài)庫(kù)滔驶,共享內(nèi)存等虛擬空間的內(nèi)存映射到物理空間的內(nèi)存,一般是 mmap 函數(shù)所分配的虛擬內(nèi)存空間卿闹。
3. Linux的內(nèi)部層級(jí)結(jié)構(gòu)
內(nèi)核態(tài)可以執(zhí)行任意命令,調(diào)用系統(tǒng)的一切資源萝快,而用戶態(tài)只能執(zhí)行簡(jiǎn)單的運(yùn)算锻霎,不能直接調(diào)用系統(tǒng)資源。用戶態(tài)必須通過(guò)系統(tǒng)接口(System Call)揪漩,才能向內(nèi)核發(fā)出指令旋恼。比如,當(dāng)用戶進(jìn)程啟動(dòng)一個(gè) bash 時(shí)奄容,它會(huì)通過(guò) getpid() 對(duì)內(nèi)核的 pid 服務(wù)發(fā)起系統(tǒng)調(diào)用冰更,獲取當(dāng)前用戶進(jìn)程的 ID;當(dāng)用戶進(jìn)程通過(guò) cat 命令查看主機(jī)配置時(shí)昂勒,它會(huì)對(duì)內(nèi)核的文件子系統(tǒng)發(fā)起系統(tǒng)調(diào)用蜀细。
- 內(nèi)核空間可以訪問(wèn)所有的 CPU 指令和所有的內(nèi)存空間、I/O 空間和硬件設(shè)備戈盈。
- 用戶空間只能訪問(wèn)受限的資源奠衔,如果需要特殊權(quán)限,可以通過(guò)系統(tǒng)調(diào)用獲取相應(yīng)的資源塘娶。
- 用戶空間允許頁(yè)面中斷归斤,而內(nèi)核空間則不允許。
- 內(nèi)核空間和用戶空間是針對(duì)線性地址空間的刁岸。
- x86 CPU中用戶空間是 0 - 3G 的地址范圍脏里,內(nèi)核空間是 3G - 4G 的地址范圍。x86_64 CPU 用戶空間地址范圍為0x0000000000000000 – 0x00007fffffffffff虹曙,內(nèi)核地址空間為 0xffff880000000000 - 最大地址迫横。
- 所有內(nèi)核進(jìn)程(線程)共用一個(gè)地址空間鸦难,而用戶進(jìn)程都有各自的地址空間。
有了用戶空間和內(nèi)核空間的劃分后员淫,Linux 內(nèi)部層級(jí)結(jié)構(gòu)可以分為三部分合蔽,從最底層到最上層依次是硬件、內(nèi)核空間和用戶空間介返,如下圖所示:
4. Linux I/O讀寫方式
Linux 提供了輪詢拴事、I/O 中斷以及 DMA 傳輸這 3 種磁盤與主存之間的數(shù)據(jù)傳輸機(jī)制。其中輪詢方式是基于死循環(huán)對(duì) I/O 端口進(jìn)行不斷檢測(cè)圣蝎。I/O 中斷方式是指當(dāng)數(shù)據(jù)到達(dá)時(shí)刃宵,磁盤主動(dòng)向 CPU 發(fā)起中斷請(qǐng)求,由 CPU 自身負(fù)責(zé)數(shù)據(jù)的傳輸過(guò)程徘公。 DMA 傳輸則在 I/O 中斷的基礎(chǔ)上引入了 DMA 磁盤控制器牲证,由 DMA 磁盤控制器負(fù)責(zé)數(shù)據(jù)的傳輸,降低了 I/O 中斷操作對(duì) CPU 資源的大量消耗关面。
4.1. I/O中斷原理
在 DMA 技術(shù)出現(xiàn)之前坦袍,應(yīng)用程序與磁盤之間的 I/O 操作都是通過(guò) CPU 的中斷完成的。每次用戶進(jìn)程讀取磁盤數(shù)據(jù)時(shí)等太,都需要 CPU 中斷捂齐,然后發(fā)起 I/O 請(qǐng)求等待數(shù)據(jù)讀取和拷貝完成,每次的 I/O 中斷都導(dǎo)致 CPU 的上下文切換缩抡。
- 用戶進(jìn)程向 CPU 發(fā)起 read 系統(tǒng)調(diào)用讀取數(shù)據(jù)奠宜,由用戶態(tài)切換為內(nèi)核態(tài),然后一直阻塞等待數(shù)據(jù)的返回瞻想。
- CPU 在接收到指令以后對(duì)磁盤發(fā)起 I/O 請(qǐng)求压真,將磁盤數(shù)據(jù)先放入磁盤控制器緩沖區(qū)。
- 數(shù)據(jù)準(zhǔn)備完成以后蘑险,磁盤向 CPU 發(fā)起 I/O 中斷滴肿。
- CPU 收到 I/O 中斷以后將磁盤緩沖區(qū)中的數(shù)據(jù)拷貝到內(nèi)核緩沖區(qū),然后再?gòu)膬?nèi)核緩沖區(qū)拷貝到用戶緩沖區(qū)漠其。
- 用戶進(jìn)程由內(nèi)核態(tài)切換回用戶態(tài)嘴高,解除阻塞狀態(tài),然后等待 CPU 的下一個(gè)執(zhí)行時(shí)間鐘和屎。
4.2. DMA傳輸原理
DMA 的全稱叫直接內(nèi)存存人┩浴(Direct Memory Access),是一種允許外圍設(shè)備(硬件子系統(tǒng))直接訪問(wèn)系統(tǒng)主內(nèi)存的機(jī)制柴信。也就是說(shuō)套啤,基于 DMA 訪問(wèn)方式,系統(tǒng)主內(nèi)存于硬盤或網(wǎng)卡之間的數(shù)據(jù)傳輸可以繞開(kāi) CPU 的全程調(diào)度。目前大多數(shù)的硬件設(shè)備潜沦,包括磁盤控制器萄涯、網(wǎng)卡、顯卡以及聲卡等都支持 DMA 技術(shù)唆鸡。
整個(gè)數(shù)據(jù)傳輸操作在一個(gè) DMA 控制器的控制下進(jìn)行的涝影。CPU 除了在數(shù)據(jù)傳輸開(kāi)始和結(jié)束時(shí)做一點(diǎn)處理外(開(kāi)始和結(jié)束時(shí)候要做中斷處理),在傳輸過(guò)程中 CPU 可以繼續(xù)進(jìn)行其他的工作争占。這樣在大部分時(shí)間里燃逻,CPU 計(jì)算和 I/O 操作都處于并行操作,使整個(gè)計(jì)算機(jī)系統(tǒng)的效率大大提高臂痕。
有了 DMA 磁盤控制器接管數(shù)據(jù)讀寫請(qǐng)求以后伯襟,CPU 從繁重的 I/O 操作中解脫,數(shù)據(jù)讀取操作的流程如下:
- 用戶進(jìn)程向 CPU 發(fā)起 read 系統(tǒng)調(diào)用讀取數(shù)據(jù)握童,由用戶態(tài)切換為內(nèi)核態(tài)姆怪,然后一直阻塞等待數(shù)據(jù)的返回。
- CPU 在接收到指令以后對(duì) DMA 磁盤控制器發(fā)起調(diào)度指令澡绩。
- DMA 磁盤控制器對(duì)磁盤發(fā)起 I/O 請(qǐng)求稽揭,將磁盤數(shù)據(jù)先放入磁盤控制器緩沖區(qū),CPU 全程不參與此過(guò)程英古。
- 數(shù)據(jù)讀取完成后淀衣,DMA 磁盤控制器會(huì)接受到磁盤的通知,將數(shù)據(jù)從磁盤控制器緩沖區(qū)拷貝到內(nèi)核緩沖區(qū)召调。
- DMA 磁盤控制器向 CPU 發(fā)出數(shù)據(jù)讀完的信號(hào),由 CPU 負(fù)責(zé)將數(shù)據(jù)從內(nèi)核緩沖區(qū)拷貝到用戶緩沖區(qū)蛮浑。
- 用戶進(jìn)程由內(nèi)核態(tài)切換回用戶態(tài)唠叛,解除阻塞狀態(tài),然后等待 CPU 的下一個(gè)執(zhí)行時(shí)間鐘沮稚。
5. 傳統(tǒng)I/O方式
為了更好的理解零拷貝解決的問(wèn)題艺沼,我們首先了解一下傳統(tǒng) I/O 方式存在的問(wèn)題。在 Linux 系統(tǒng)中蕴掏,傳統(tǒng)的訪問(wèn)方式是通過(guò) write() 和 read() 兩個(gè)系統(tǒng)調(diào)用實(shí)現(xiàn)的障般,通過(guò) read() 函數(shù)讀取文件到到緩存區(qū)中,然后通過(guò) write() 方法把緩存中的數(shù)據(jù)輸出到網(wǎng)絡(luò)端口盛杰,偽代碼如下:
read(file_fd, tmp_buf, len);
write(socket_fd, tmp_buf, len);
下圖分別對(duì)應(yīng)傳統(tǒng) I/O 操作的數(shù)據(jù)讀寫流程挽荡,整個(gè)過(guò)程涉及 2 次 CPU 拷貝、2 次 DMA 拷貝總共 4 次拷貝即供,以及 4 次上下文切換定拟,下面簡(jiǎn)單地闡述一下相關(guān)的概念。
- 上下文切換:當(dāng)用戶程序向內(nèi)核發(fā)起系統(tǒng)調(diào)用時(shí)逗嫡,CPU 將用戶進(jìn)程從用戶態(tài)切換到內(nèi)核態(tài)青自;當(dāng)系統(tǒng)調(diào)用返回時(shí)株依,CPU 將用戶進(jìn)程從內(nèi)核態(tài)切換回用戶態(tài)。
- CPU拷貝:由 CPU 直接處理數(shù)據(jù)的傳送延窜,數(shù)據(jù)拷貝時(shí)會(huì)一直占用 CPU 的資源恋腕。
- DMA拷貝:由 CPU 向DMA磁盤控制器下達(dá)指令,讓 DMA 控制器來(lái)處理數(shù)據(jù)的傳送逆瑞,數(shù)據(jù)傳送完畢再把信息反饋給 CPU荠藤,從而減輕了 CPU 資源的占有率。
5.1. 傳統(tǒng)讀操作
當(dāng)應(yīng)用程序執(zhí)行 read 系統(tǒng)調(diào)用讀取一塊數(shù)據(jù)的時(shí)候呆万,如果這塊數(shù)據(jù)已經(jīng)存在于用戶進(jìn)程的頁(yè)內(nèi)存中商源,就直接從內(nèi)存中讀取數(shù)據(jù);如果數(shù)據(jù)不存在谋减,則先將數(shù)據(jù)從磁盤加載數(shù)據(jù)到內(nèi)核空間的讀緩存(read buffer)中牡彻,再?gòu)淖x緩存拷貝到用戶進(jìn)程的頁(yè)內(nèi)存中。
read(file_fd, tmp_buf, len);
基于傳統(tǒng)的 I/O 讀取方式出爹,read 系統(tǒng)調(diào)用會(huì)觸發(fā) 2 次上下文切換庄吼,1 次 DMA 拷貝和 1 次 CPU 拷貝,發(fā)起數(shù)據(jù)讀取的流程如下:
- 用戶進(jìn)程通過(guò) read() 函數(shù)向內(nèi)核(kernel)發(fā)起系統(tǒng)調(diào)用严就,上下文從用戶態(tài)(user space)切換為內(nèi)核態(tài)(kernel space)总寻。
- CPU利用DMA控制器將數(shù)據(jù)從主存或硬盤拷貝到內(nèi)核空間(kernel space)的讀緩沖區(qū)(read buffer)。
- CPU將讀緩沖區(qū)(read buffer)中的數(shù)據(jù)拷貝到用戶空間(user space)的用戶緩沖區(qū)(user buffer)梢为。
- 上下文從內(nèi)核態(tài)(kernel space)切換回用戶態(tài)(user space)渐行,read 調(diào)用執(zhí)行返回。
5.2. 傳統(tǒng)寫操作
當(dāng)應(yīng)用程序準(zhǔn)備好數(shù)據(jù)铸董,執(zhí)行 write 系統(tǒng)調(diào)用發(fā)送網(wǎng)絡(luò)數(shù)據(jù)時(shí)祟印,先將數(shù)據(jù)從用戶空間的頁(yè)緩存拷貝到內(nèi)核空間的網(wǎng)絡(luò)緩沖區(qū)(socket buffer)中,然后再將寫緩存中的數(shù)據(jù)拷貝到網(wǎng)卡設(shè)備完成數(shù)據(jù)發(fā)送粟害。
write(socket_fd, tmp_buf, len);
基于傳統(tǒng)的 I/O 寫入方式蕴忆,write() 系統(tǒng)調(diào)用會(huì)觸發(fā) 2 次上下文切換,1 次 CPU 拷貝和 1 次 DMA 拷貝悲幅,用戶程序發(fā)送網(wǎng)絡(luò)數(shù)據(jù)的流程如下:
- 用戶進(jìn)程通過(guò) write() 函數(shù)向內(nèi)核(kernel)發(fā)起系統(tǒng)調(diào)用套鹅,上下文從用戶態(tài)(user space)切換為內(nèi)核態(tài)(kernel space)。
- CPU 將用戶緩沖區(qū)(user buffer)中的數(shù)據(jù)拷貝到內(nèi)核空間(kernel space)的網(wǎng)絡(luò)緩沖區(qū)(socket buffer)汰具。
- CPU 利用 DMA 控制器將數(shù)據(jù)從網(wǎng)絡(luò)緩沖區(qū)(socket buffer)拷貝到網(wǎng)卡進(jìn)行數(shù)據(jù)傳輸卓鹿。
- 上下文從內(nèi)核態(tài)(kernel space)切換回用戶態(tài)(user space),write 系統(tǒng)調(diào)用執(zhí)行返回郁副。
6. 零拷貝方式
在 Linux 中零拷貝技術(shù)主要有 3 個(gè)實(shí)現(xiàn)思路:用戶態(tài)直接 I/O减牺、減少數(shù)據(jù)拷貝次數(shù)以及寫時(shí)復(fù)制技術(shù)。
- 用戶態(tài)直接 I/O:應(yīng)用程序可以直接訪問(wèn)硬件存儲(chǔ),操作系統(tǒng)內(nèi)核只是輔助數(shù)據(jù)傳輸拔疚。這種方式依舊存在用戶空間和內(nèi)核空間的上下文切換肥隆,硬件上的數(shù)據(jù)直接拷貝至了用戶空間,不經(jīng)過(guò)內(nèi)核空間稚失。因此栋艳,直接 I/O 不存在內(nèi)核空間緩沖區(qū)和用戶空間緩沖區(qū)之間的數(shù)據(jù)拷貝。
- 減少數(shù)據(jù)拷貝次數(shù):在數(shù)據(jù)傳輸過(guò)程中句各,避免數(shù)據(jù)在用戶空間緩沖區(qū)和系統(tǒng)內(nèi)核空間緩沖區(qū)之間的CPU拷貝吸占,以及數(shù)據(jù)在系統(tǒng)內(nèi)核空間內(nèi)的CPU拷貝,這也是當(dāng)前主流零拷貝技術(shù)的實(shí)現(xiàn)思路凿宾。
- 寫時(shí)復(fù)制技術(shù):寫時(shí)復(fù)制指的是當(dāng)多個(gè)進(jìn)程共享同一塊數(shù)據(jù)時(shí)矾屯,如果其中一個(gè)進(jìn)程需要對(duì)這份數(shù)據(jù)進(jìn)行修改,那么將其拷貝到自己的進(jìn)程地址空間中,如果只是數(shù)據(jù)讀取操作則不需要進(jìn)行拷貝操作。
6.1. 用戶態(tài)直接I/O
用戶態(tài)直接 I/O 使得應(yīng)用進(jìn)程或運(yùn)行在用戶態(tài)(user space)下的庫(kù)函數(shù)直接訪問(wèn)硬件設(shè)備裆装,數(shù)據(jù)直接跨過(guò)內(nèi)核進(jìn)行傳輸,內(nèi)核在數(shù)據(jù)傳輸過(guò)程除了進(jìn)行必要的虛擬存儲(chǔ)配置工作之外排作,不參與任何其他工作,這種方式能夠直接繞過(guò)內(nèi)核亚情,極大提高了性能妄痪。
用戶態(tài)直接 I/O 只能適用于不需要內(nèi)核緩沖區(qū)處理的應(yīng)用程序,這些應(yīng)用程序通常在進(jìn)程地址空間有自己的數(shù)據(jù)緩存機(jī)制楞件,稱為自緩存應(yīng)用程序衫生,如數(shù)據(jù)庫(kù)管理系統(tǒng)就是一個(gè)代表。其次土浸,這種零拷貝機(jī)制會(huì)直接操作磁盤 I/O障簿,由于 CPU 和磁盤 I/O 之間的執(zhí)行時(shí)間差距,會(huì)造成大量資源的浪費(fèi)栅迄,解決方案是配合異步 I/O 使用。
6.2. mmap + write
一種零拷貝方式是使用 mmap + write 代替原來(lái)的 read + write 方式皆怕,減少了 1 次 CPU 拷貝操作毅舆。mmap 是 Linux 提供的一種內(nèi)存映射文件方法,即將一個(gè)進(jìn)程的地址空間中的一段虛擬地址映射到磁盤文件地址愈腾,mmap + write 的偽代碼如下:
tmp_buf = mmap(file_fd, len);
write(socket_fd, tmp_buf, len);
使用 mmap 的目的是將內(nèi)核中讀緩沖區(qū)(read buffer)的地址與用戶空間的緩沖區(qū)(user buffer)進(jìn)行映射憋活,從而實(shí)現(xiàn)內(nèi)核緩沖區(qū)與應(yīng)用程序內(nèi)存的共享,省去了將數(shù)據(jù)從內(nèi)核讀緩沖區(qū)(read buffer)拷貝到用戶緩沖區(qū)(user buffer)的過(guò)程虱黄,然而內(nèi)核讀緩沖區(qū)(read buffer)仍需將數(shù)據(jù)到內(nèi)核寫緩沖區(qū)(socket buffer)悦即,大致的流程如下圖所示:
基于 mmap + write 系統(tǒng)調(diào)用的零拷貝方式,整個(gè)拷貝過(guò)程會(huì)發(fā)生 4 次上下文切換,1 次 CPU 拷貝和 2 次 DMA 拷貝辜梳,用戶程序讀寫數(shù)據(jù)的流程如下:
- 用戶進(jìn)程通過(guò) mmap() 函數(shù)向內(nèi)核(kernel)發(fā)起系統(tǒng)調(diào)用粱甫,上下文從用戶態(tài)(user space)切換為內(nèi)核態(tài)(kernel space)。
- 將用戶進(jìn)程的內(nèi)核空間的讀緩沖區(qū)(read buffer)與用戶空間的緩存區(qū)(user buffer)進(jìn)行內(nèi)存地址映射作瞄。
- CPU利用DMA控制器將數(shù)據(jù)從主存或硬盤拷貝到內(nèi)核空間(kernel space)的讀緩沖區(qū)(read buffer)茶宵。
- 上下文從內(nèi)核態(tài)(kernel space)切換回用戶態(tài)(user space),mmap 系統(tǒng)調(diào)用執(zhí)行返回宗挥。
- 用戶進(jìn)程通過(guò) write() 函數(shù)向內(nèi)核(kernel)發(fā)起系統(tǒng)調(diào)用乌庶,上下文從用戶態(tài)(user space)切換為內(nèi)核態(tài)(kernel space)。
- CPU將讀緩沖區(qū)(read buffer)中的數(shù)據(jù)拷貝到的網(wǎng)絡(luò)緩沖區(qū)(socket buffer)契耿。
- CPU利用DMA控制器將數(shù)據(jù)從網(wǎng)絡(luò)緩沖區(qū)(socket buffer)拷貝到網(wǎng)卡進(jìn)行數(shù)據(jù)傳輸瞒大。
- 上下文從內(nèi)核態(tài)(kernel space)切換回用戶態(tài)(user space),write 系統(tǒng)調(diào)用執(zhí)行返回搪桂。
mmap 主要的用處是提高 I/O 性能透敌,特別是針對(duì)大文件。對(duì)于小文件锅棕,內(nèi)存映射文件反而會(huì)導(dǎo)致碎片空間的浪費(fèi)拙泽,因?yàn)閮?nèi)存映射總是要對(duì)齊頁(yè)邊界,最小單位是 4 KB裸燎,一個(gè) 5 KB 的文件將會(huì)映射占用 8 KB 內(nèi)存顾瞻,也就會(huì)浪費(fèi) 3 KB 內(nèi)存。
mmap 的拷貝雖然減少了 1 次拷貝德绿,提升了效率荷荤,但也存在一些隱藏的問(wèn)題。當(dāng) mmap 一個(gè)文件時(shí)移稳,如果這個(gè)文件被另一個(gè)進(jìn)程所截獲蕴纳,那么 write 系統(tǒng)調(diào)用會(huì)因?yàn)樵L問(wèn)非法地址被 SIGBUS 信號(hào)終止,SIGBUS 默認(rèn)會(huì)殺死進(jìn)程并產(chǎn)生一個(gè) coredump个粱,服務(wù)器可能因此被終止古毛。
6.3. sendfile
sendfile 系統(tǒng)調(diào)用在 Linux 內(nèi)核版本 2.1 中被引入,目的是簡(jiǎn)化通過(guò)網(wǎng)絡(luò)在兩個(gè)通道之間進(jìn)行的數(shù)據(jù)傳輸過(guò)程都许。sendfile 系統(tǒng)調(diào)用的引入稻薇,不僅減少了 CPU 拷貝的次數(shù),還減少了上下文切換的次數(shù)胶征,它的偽代碼如下:
sendfile(socket_fd, file_fd, len);
通過(guò) sendfile 系統(tǒng)調(diào)用塞椎,數(shù)據(jù)可以直接在內(nèi)核空間內(nèi)部進(jìn)行 I/O 傳輸,從而省去了數(shù)據(jù)在用戶空間和內(nèi)核空間之間的來(lái)回拷貝睛低。與 mmap 內(nèi)存映射方式不同的是案狠, sendfile 調(diào)用中 I/O 數(shù)據(jù)對(duì)用戶空間是完全不可見(jiàn)的服傍。也就是說(shuō),這是一次完全意義上的數(shù)據(jù)傳輸過(guò)程骂铁。
基于 sendfile 系統(tǒng)調(diào)用的零拷貝方式吹零,整個(gè)拷貝過(guò)程會(huì)發(fā)生 2 次上下文切換,1 次 CPU 拷貝和 2 次 DMA 拷貝从铲,用戶程序讀寫數(shù)據(jù)的流程如下:
- 用戶進(jìn)程通過(guò) sendfile() 函數(shù)向內(nèi)核(kernel)發(fā)起系統(tǒng)調(diào)用瘪校,上下文從用戶態(tài)(user space)切換為內(nèi)核態(tài)(kernel space)。
- CPU 利用 DMA 控制器將數(shù)據(jù)從主存或硬盤拷貝到內(nèi)核空間(kernel space)的讀緩沖區(qū)(read buffer)名段。
- CPU 將讀緩沖區(qū)(read buffer)中的數(shù)據(jù)拷貝到的網(wǎng)絡(luò)緩沖區(qū)(socket buffer)阱扬。
- CPU 利用 DMA 控制器將數(shù)據(jù)從網(wǎng)絡(luò)緩沖區(qū)(socket buffer)拷貝到網(wǎng)卡進(jìn)行數(shù)據(jù)傳輸。
- 上下文從內(nèi)核態(tài)(kernel space)切換回用戶態(tài)(user space)伸辟,sendfile 系統(tǒng)調(diào)用執(zhí)行返回麻惶。
相比較于 mmap 內(nèi)存映射的方式,sendfile 少了 2 次上下文切換信夫,但是仍然有 1 次 CPU 拷貝操作窃蹋。sendfile 存在的問(wèn)題是用戶程序不能對(duì)數(shù)據(jù)進(jìn)行修改,而只是單純地完成了一次數(shù)據(jù)傳輸過(guò)程静稻。
6.4. sendfile + DMA gather copy
Linux 2.4 版本的內(nèi)核對(duì) sendfile 系統(tǒng)調(diào)用進(jìn)行修改警没,為 DMA 拷貝引入了 gather 操作。它將內(nèi)核空間(kernel space)的讀緩沖區(qū)(read buffer)中對(duì)應(yīng)的數(shù)據(jù)描述信息(內(nèi)存地址振湾、地址偏移量)記錄到相應(yīng)的網(wǎng)絡(luò)緩沖區(qū)( socket buffer)中杀迹,由 DMA 根據(jù)內(nèi)存地址、地址偏移量將數(shù)據(jù)批量地從讀緩沖區(qū)(read buffer)拷貝到網(wǎng)卡設(shè)備中押搪,這樣就省去了內(nèi)核空間中僅剩的 1 次 CPU 拷貝操作树酪,sendfile 的偽代碼如下:
sendfile(socket_fd, file_fd, len);
在硬件的支持下,sendfile 拷貝方式不再?gòu)膬?nèi)核緩沖區(qū)的數(shù)據(jù)拷貝到 socket 緩沖區(qū)大州,取而代之的僅僅是緩沖區(qū)文件描述符和數(shù)據(jù)長(zhǎng)度的拷貝续语,這樣 DMA 引擎直接利用 gather 操作將頁(yè)緩存中數(shù)據(jù)打包發(fā)送到網(wǎng)絡(luò)中即可,本質(zhì)就是和虛擬內(nèi)存映射的思路類似厦画。
基于 sendfile + DMA gather copy 系統(tǒng)調(diào)用的零拷貝方式疮茄,整個(gè)拷貝過(guò)程會(huì)發(fā)生 2 次上下文切換、0 次 CPU 拷貝以及 2 次 DMA 拷貝根暑,用戶程序讀寫數(shù)據(jù)的流程如下:
- 用戶進(jìn)程通過(guò) sendfile() 函數(shù)向內(nèi)核(kernel)發(fā)起系統(tǒng)調(diào)用娃豹,上下文從用戶態(tài)(user space)切換為內(nèi)核態(tài)(kernel space)。
- CPU 利用 DMA 控制器將數(shù)據(jù)從主存或硬盤拷貝到內(nèi)核空間(kernel space)的讀緩沖區(qū)(read buffer)购裙。
- CPU 把讀緩沖區(qū)(read buffer)的文件描述符(file descriptor)和數(shù)據(jù)長(zhǎng)度拷貝到網(wǎng)絡(luò)緩沖區(qū)(socket buffer)。
- 基于已拷貝的文件描述符(file descriptor)和數(shù)據(jù)長(zhǎng)度鹃栽,CPU 利用 DMA 控制器的 gather/scatter 操作直接批量地將數(shù)據(jù)從內(nèi)核的讀緩沖區(qū)(read buffer)拷貝到網(wǎng)卡進(jìn)行數(shù)據(jù)傳輸躏率。
- 上下文從內(nèi)核態(tài)(kernel space)切換回用戶態(tài)(user space)躯畴,sendfile 系統(tǒng)調(diào)用執(zhí)行返回。
sendfile + DMA gather copy 拷貝方式同樣存在用戶程序不能對(duì)數(shù)據(jù)進(jìn)行修改的問(wèn)題薇芝,而且本身需要硬件的支持蓬抄,它只適用于將數(shù)據(jù)從文件拷貝到 socket 套接字上的傳輸過(guò)程。
6.5. splice
sendfile 只適用于將數(shù)據(jù)從文件拷貝到 socket 套接字上夯到,同時(shí)需要硬件的支持嚷缭,這也限定了它的使用范圍。Linux 在 2.6.17 版本引入 splice 系統(tǒng)調(diào)用耍贾,不僅不需要硬件支持阅爽,還實(shí)現(xiàn)了兩個(gè)文件描述符之間的數(shù)據(jù)零拷貝。splice 的偽代碼如下:
splice(fd_in, off_in, fd_out, off_out, len, flags);
splice 系統(tǒng)調(diào)用可以在內(nèi)核空間的讀緩沖區(qū)(read buffer)和網(wǎng)絡(luò)緩沖區(qū)(socket buffer)之間建立管道(pipeline)荐开,從而避免了兩者之間的 CPU 拷貝操作付翁。
基于 splice 系統(tǒng)調(diào)用的零拷貝方式,整個(gè)拷貝過(guò)程會(huì)發(fā)生 2 次上下文切換晃听,0 次 CPU 拷貝以及 2 次 DMA 拷貝百侧,用戶程序讀寫數(shù)據(jù)的流程如下:
- 用戶進(jìn)程通過(guò) splice() 函數(shù)向內(nèi)核(kernel)發(fā)起系統(tǒng)調(diào)用,上下文從用戶態(tài)(user space)切換為內(nèi)核態(tài)(kernel space)能扒。
- CPU 利用 DMA 控制器將數(shù)據(jù)從主存或硬盤拷貝到內(nèi)核空間(kernel space)的讀緩沖區(qū)(read buffer)佣渴。
- CPU 在內(nèi)核空間的讀緩沖區(qū)(read buffer)和網(wǎng)絡(luò)緩沖區(qū)(socket buffer)之間建立管道(pipeline)。
- CPU 利用 DMA 控制器將數(shù)據(jù)從網(wǎng)絡(luò)緩沖區(qū)(socket buffer)拷貝到網(wǎng)卡進(jìn)行數(shù)據(jù)傳輸初斑。
- 上下文從內(nèi)核態(tài)(kernel space)切換回用戶態(tài)(user space)辛润,splice 系統(tǒng)調(diào)用執(zhí)行返回。
splice 拷貝方式也同樣存在用戶程序不能對(duì)數(shù)據(jù)進(jìn)行修改的問(wèn)題越平。除此之外频蛔,它使用了 Linux 的管道緩沖機(jī)制,可以用于任意兩個(gè)文件描述符中傳輸數(shù)據(jù)秦叛,但是它的兩個(gè)文件描述符參數(shù)中有一個(gè)必須是管道設(shè)備晦溪。
6.6. 寫時(shí)復(fù)制
在某些情況下,內(nèi)核緩沖區(qū)可能被多個(gè)進(jìn)程所共享挣跋,如果某個(gè)進(jìn)程想要這個(gè)共享區(qū)進(jìn)行 write 操作三圆,由于 write 不提供任何的鎖操作,那么就會(huì)對(duì)共享區(qū)中的數(shù)據(jù)造成破壞避咆,寫時(shí)復(fù)制的引入就是 Linux 用來(lái)保護(hù)數(shù)據(jù)的舟肉。
寫時(shí)復(fù)制指的是當(dāng)多個(gè)進(jìn)程共享同一塊數(shù)據(jù)時(shí),如果其中一個(gè)進(jìn)程需要對(duì)這份數(shù)據(jù)進(jìn)行修改查库,那么就需要將其拷貝到自己的進(jìn)程地址空間中路媚。這樣做并不影響其他進(jìn)程對(duì)這塊數(shù)據(jù)的操作,每個(gè)進(jìn)程要修改的時(shí)候才會(huì)進(jìn)行拷貝樊销,所以叫寫時(shí)拷貝整慎。這種方法在某種程度上能夠降低系統(tǒng)開(kāi)銷脏款,如果某個(gè)進(jìn)程永遠(yuǎn)不會(huì)對(duì)所訪問(wèn)的數(shù)據(jù)進(jìn)行更改,那么也就永遠(yuǎn)不需要拷貝裤园。
6.7. 緩沖區(qū)共享
緩沖區(qū)共享方式完全改寫了傳統(tǒng)的 I/O 操作撤师,因?yàn)閭鹘y(tǒng) I/O 接口都是基于數(shù)據(jù)拷貝進(jìn)行的,要避免拷貝就得去掉原先的那套接口并重新改寫拧揽,所以這種方法是比較全面的零拷貝技術(shù)剃盾,目前比較成熟的一個(gè)方案是在 Solaris 上實(shí)現(xiàn)的 fbuf(Fast Buffer,快速緩沖區(qū))淤袜。
fbuf 的思想是每個(gè)進(jìn)程都維護(hù)著一個(gè)緩沖區(qū)池痒谴,這個(gè)緩沖區(qū)池能被同時(shí)映射到用戶空間(user space)和內(nèi)核態(tài)(kernel space),內(nèi)核和用戶共享這個(gè)緩沖區(qū)池饮怯,這樣就避免了一系列的拷貝操作闰歪。
緩沖區(qū)共享的難度在于管理共享緩沖區(qū)池需要應(yīng)用程序、網(wǎng)絡(luò)軟件以及設(shè)備驅(qū)動(dòng)程序之間的緊密合作蓖墅,而且如何改寫 API 目前還處于試驗(yàn)階段并不成熟库倘。
7. Linux零拷貝對(duì)比
無(wú)論是傳統(tǒng) I/O 拷貝方式還是引入零拷貝的方式,2 次 DMA Copy 是都少不了的论矾,因?yàn)閮纱?DMA 都是依賴硬件完成的教翩。下面從 CPU 拷貝次數(shù)、DMA 拷貝次數(shù)以及系統(tǒng)調(diào)用幾個(gè)方面總結(jié)一下上述幾種 I/O 拷貝方式的差別贪壳。
拷貝方式 | CPU拷貝 | DMA拷貝 | 系統(tǒng)調(diào)用 | 上下文切換 |
---|---|---|---|---|
傳統(tǒng)方式(read + write) | 2 | 2 | read / write | 4 |
內(nèi)存映射(mmap + write) | 1 | 2 | mmap / write | 4 |
sendfile | 1 | 2 | sendfile | 2 |
sendfile + DMA gather copy | 0 | 2 | sendfile | 2 |
splice | 0 | 2 | splice | 2 |
8. Java NIO零拷貝實(shí)現(xiàn)
在 Java NIO 中的通道(Channel)就相當(dāng)于操作系統(tǒng)的內(nèi)核空間(kernel space)的緩沖區(qū)饱亿,而緩沖區(qū)(Buffer)對(duì)應(yīng)的相當(dāng)于操作系統(tǒng)的用戶空間(user space)中的用戶緩沖區(qū)(user buffer)。
- 通道(Channel)是全雙工的(雙向傳輸)闰靴,它既可能是讀緩沖區(qū)(read buffer)彪笼,也可能是網(wǎng)絡(luò)緩沖區(qū)(socket buffer)。
- 緩沖區(qū)(Buffer)分為堆內(nèi)存(HeapBuffer)和堆外內(nèi)存(DirectBuffer)蚂且,這是通過(guò) malloc() 分配出來(lái)的用戶態(tài)內(nèi)存配猫。
堆外內(nèi)存(DirectBuffer)在使用后需要應(yīng)用程序手動(dòng)回收,而堆內(nèi)存(HeapBuffer)的數(shù)據(jù)在 GC 時(shí)可能會(huì)被自動(dòng)回收杏死。因此泵肄,在使用 HeapBuffer 讀寫數(shù)據(jù)時(shí),為了避免緩沖區(qū)數(shù)據(jù)因?yàn)?GC 而丟失淑翼,NIO 會(huì)先把 HeapBuffer 內(nèi)部的數(shù)據(jù)拷貝到一個(gè)臨時(shí)的 DirectBuffer 中的本地內(nèi)存(native memory)腐巢,這個(gè)拷貝涉及到 sun.misc.Unsafe.copyMemory() 的調(diào)用,背后的實(shí)現(xiàn)原理與 memcpy() 類似玄括。 最后冯丙,將臨時(shí)生成的 DirectBuffer 內(nèi)部的數(shù)據(jù)的內(nèi)存地址傳給 I/O 調(diào)用函數(shù),這樣就避免了再去訪問(wèn) Java 對(duì)象處理 I/O 讀寫遭京。
8.1. MappedByteBuffer
MappedByteBuffer 是 NIO 基于內(nèi)存映射(mmap)這種零拷貝方式的提供的一種實(shí)現(xiàn)银还,它繼承自 ByteBuffer风宁。FileChannel 定義了一個(gè) map() 方法,它可以把一個(gè)文件從 position 位置開(kāi)始的 size 大小的區(qū)域映射為內(nèi)存映像文件蛹疯。抽象方法 map() 方法在 FileChannel 中的定義如下:
public abstract MappedByteBuffer map(MapMode mode, long position, long size)
throws IOException;
- mode:限定內(nèi)存映射區(qū)域(MappedByteBuffer)對(duì)內(nèi)存映像文件的訪問(wèn)模式,包括只可讀(READ_ONLY)热监、可讀可寫(READ_WRITE)和寫時(shí)拷貝(PRIVATE)三種模式捺弦。
- position:文件映射的起始地址,對(duì)應(yīng)內(nèi)存映射區(qū)域(MappedByteBuffer)的首地址孝扛。
- size:文件映射的字節(jié)長(zhǎng)度列吼,從 position 往后的字節(jié)數(shù),對(duì)應(yīng)內(nèi)存映射區(qū)域(MappedByteBuffer)的大小苦始。
MappedByteBuffer 相比 ByteBuffer 新增了 fore()寞钥、load() 和 isLoad() 三個(gè)重要的方法:
- fore():對(duì)于處于 READ_WRITE 模式下的緩沖區(qū),把對(duì)緩沖區(qū)內(nèi)容的修改強(qiáng)制刷新到本地文件陌选。
- load():將緩沖區(qū)的內(nèi)容載入物理內(nèi)存中理郑,并返回這個(gè)緩沖區(qū)的引用。
- isLoaded():如果緩沖區(qū)的內(nèi)容在物理內(nèi)存中咨油,則返回 true您炉,否則返回 false。
下面給出一個(gè)利用 MappedByteBuffer 對(duì)文件進(jìn)行讀寫的使用示例:
private final static String CONTENT = "Zero copy implemented by MappedByteBuffer";
private final static String FILE_NAME = "/mmap.txt";
private final static String CHARSET = "UTF-8";
- 寫文件數(shù)據(jù):打開(kāi)文件通道 fileChannel 并提供讀權(quán)限役电、寫權(quán)限和數(shù)據(jù)清空權(quán)限赚爵,通過(guò) fileChannel 映射到一個(gè)可寫的內(nèi)存緩沖區(qū) mappedByteBuffer,將目標(biāo)數(shù)據(jù)寫入 mappedByteBuffer法瑟,通過(guò) force() 方法把緩沖區(qū)更改的內(nèi)容強(qiáng)制寫入本地文件冀膝。
@Test
public void writeToFileByMappedByteBuffer() {
Path path = Paths.get(getClass().getResource(FILE_NAME).getPath());
byte[] bytes = CONTENT.getBytes(Charset.forName(CHARSET));
try (FileChannel fileChannel = FileChannel.open(path, StandardOpenOption.READ,
StandardOpenOption.WRITE, StandardOpenOption.TRUNCATE_EXISTING)) {
MappedByteBuffer mappedByteBuffer = fileChannel.map(READ_WRITE, 0, bytes.length);
if (mappedByteBuffer != null) {
mappedByteBuffer.put(bytes);
mappedByteBuffer.force();
}
} catch (IOException e) {
e.printStackTrace();
}
}
- 讀文件數(shù)據(jù):打開(kāi)文件通道 fileChannel 并提供只讀權(quán)限,通過(guò) fileChannel 映射到一個(gè)只可讀的內(nèi)存緩沖區(qū) mappedByteBuffer霎挟,讀取 mappedByteBuffer 中的字節(jié)數(shù)組即可得到文件數(shù)據(jù)窝剖。
@Test
public void readFromFileByMappedByteBuffer() {
Path path = Paths.get(getClass().getResource(FILE_NAME).getPath());
int length = CONTENT.getBytes(Charset.forName(CHARSET)).length;
try (FileChannel fileChannel = FileChannel.open(path, StandardOpenOption.READ)) {
MappedByteBuffer mappedByteBuffer = fileChannel.map(READ_ONLY, 0, length);
if (mappedByteBuffer != null) {
byte[] bytes = new byte[length];
mappedByteBuffer.get(bytes);
String content = new String(bytes, StandardCharsets.UTF_8);
assertEquals(content, "Zero copy implemented by MappedByteBuffer");
}
} catch (IOException e) {
e.printStackTrace();
}
}
下面介紹 map() 方法的底層實(shí)現(xiàn)原理。map() 方法是 java.nio.channels.FileChannel 的抽象方法氓扛,由子類 sun.nio.ch.FileChannelImpl.java 實(shí)現(xiàn)枯芬,下面是和內(nèi)存映射相關(guān)的核心代碼:
public MappedByteBuffer map(MapMode mode, long position, long size) throws IOException {
int pagePosition = (int)(position % allocationGranularity);
long mapPosition = position - pagePosition;
long mapSize = size + pagePosition;
try {
addr = map0(imode, mapPosition, mapSize);
} catch (OutOfMemoryError x) {
System.gc();
try {
Thread.sleep(100);
} catch (InterruptedException y) {
Thread.currentThread().interrupt();
}
try {
addr = map0(imode, mapPosition, mapSize);
} catch (OutOfMemoryError y) {
throw new IOException("Map failed", y);
}
}
int isize = (int)size;
Unmapper um = new Unmapper(addr, mapSize, isize, mfd);
if ((!writable) || (imode == MAP_RO)) {
return Util.newMappedByteBufferR(isize, addr + pagePosition, mfd, um);
} else {
return Util.newMappedByteBuffer(isize, addr + pagePosition, mfd, um);
}
}
map() 方法通過(guò)本地方法 map0() 為文件分配一塊虛擬內(nèi)存,作為它的內(nèi)存映射區(qū)域采郎,然后返回這塊內(nèi)存映射區(qū)域的起始地址千所。
- 文件映射需要在 Java 堆中創(chuàng)建一個(gè) MappedByteBuffer 的實(shí)例。如果第一次文件映射導(dǎo)致 OOM蒜埋,則手動(dòng)觸發(fā)垃圾回收淫痰,休眠 100ms 后再嘗試映射,如果失敗則拋出異常整份。
- 通過(guò) Util 的 newMappedByteBuffer (可讀可寫)方法或者 newMappedByteBufferR(僅讀) 方法方法反射創(chuàng)建一個(gè) DirectByteBuffer 實(shí)例待错,其中 DirectByteBuffer 是 MappedByteBuffer 的子類籽孙。
map() 方法返回的是內(nèi)存映射區(qū)域的起始地址,通過(guò)(起始地址 + 偏移量)就可以獲取指定內(nèi)存的數(shù)據(jù)火俄。這樣一定程度上替代了 read() 或 write() 方法犯建,底層直接采用 sun.misc.Unsafe 類的 getByte() 和 putByte() 方法對(duì)數(shù)據(jù)進(jìn)行讀寫。
private native long map0(int prot, long position, long mapSize) throws IOException;
上面是本地方法(native method)map0 的定義瓜客,它通過(guò) JNI(Java Native Interface)調(diào)用底層 C 的實(shí)現(xiàn)适瓦,這個(gè) native 函數(shù)(Java_sun_nio_ch_FileChannelImpl_map0)的實(shí)現(xiàn)位于 JDK 源碼包下的 native/sun/nio/ch/FileChannelImpl.c 這個(gè)源文件里面。
JNIEXPORT jlong JNICALL
Java_sun_nio_ch_FileChannelImpl_map0(JNIEnv *env, jobject this,
jint prot, jlong off, jlong len)
{
void *mapAddress = 0;
jobject fdo = (*env)->GetObjectField(env, this, chan_fd);
jint fd = fdval(env, fdo);
int protections = 0;
int flags = 0;
if (prot == sun_nio_ch_FileChannelImpl_MAP_RO) {
protections = PROT_READ;
flags = MAP_SHARED;
} else if (prot == sun_nio_ch_FileChannelImpl_MAP_RW) {
protections = PROT_WRITE | PROT_READ;
flags = MAP_SHARED;
} else if (prot == sun_nio_ch_FileChannelImpl_MAP_PV) {
protections = PROT_WRITE | PROT_READ;
flags = MAP_PRIVATE;
}
mapAddress = mmap64(
0, /* Let OS decide location */
len, /* Number of bytes to map */
protections, /* File permissions */
flags, /* Changes are shared */
fd, /* File descriptor of mapped file */
off); /* Offset into file */
if (mapAddress == MAP_FAILED) {
if (errno == ENOMEM) {
JNU_ThrowOutOfMemoryError(env, "Map failed");
return IOS_THROWN;
}
return handle(env, -1, "Map failed");
}
return ((jlong) (unsigned long) mapAddress);
}
可以看出 map0() 函數(shù)最終是通過(guò) mmap64() 這個(gè)函數(shù)對(duì) Linux 底層內(nèi)核發(fā)出內(nèi)存映射的調(diào)用谱仪, mmap64() 函數(shù)的原型如下:
#include <sys/mman.h>
void *mmap64(void *addr, size_t len, int prot, int flags, int fd, off64_t offset);
下面詳細(xì)介紹一下 mmap64() 函數(shù)各個(gè)參數(shù)的含義以及參數(shù)可選值:
- addr:文件在用戶進(jìn)程空間的內(nèi)存映射區(qū)中的起始地址玻熙,是一個(gè)建議的參數(shù),通撤柙埽可設(shè)置為 0 或 NULL嗦随,此時(shí)由內(nèi)核去決定真實(shí)的起始地址。當(dāng) flags 為 MAP_FIXED 時(shí)敬尺,addr 就是一個(gè)必選的參數(shù)枚尼,即需要提供一個(gè)存在的地址。
- len:文件需要進(jìn)行內(nèi)存映射的字節(jié)長(zhǎng)度
- prot:控制用戶進(jìn)程對(duì)內(nèi)存映射區(qū)的訪問(wèn)權(quán)限
- PROT_READ:讀權(quán)限
- PROT_WRITE:寫權(quán)限
- PROT_EXEC:執(zhí)行權(quán)限
- PROT_NONE:無(wú)權(quán)限
- flags:控制內(nèi)存映射區(qū)的修改是否被多個(gè)進(jìn)程共享
- MAP_PRIVATE:對(duì)內(nèi)存映射區(qū)數(shù)據(jù)的修改不會(huì)反映到真正的文件筷转,數(shù)據(jù)修改發(fā)生時(shí)采用寫時(shí)復(fù)制機(jī)制
- MAP_SHARED:對(duì)內(nèi)存映射區(qū)的修改會(huì)同步到真正的文件姑原,修改對(duì)共享此內(nèi)存映射區(qū)的進(jìn)程是可見(jiàn)的
- MAP_FIXED:不建議使用,這種模式下 addr 參數(shù)指定的必須的提供一個(gè)存在的 addr 參數(shù)
- fd:文件描述符呜舒。每次 map 操作會(huì)導(dǎo)致文件的引用計(jì)數(shù)加 1锭汛,每次 unmap 操作或者結(jié)束進(jìn)程會(huì)導(dǎo)致引用計(jì)數(shù)減 1
- offset:文件偏移量。進(jìn)行映射的文件位置袭蝗,從文件起始地址向后的位移量
下面總結(jié)一下 MappedByteBuffer 的特點(diǎn)和不足之處:
- MappedByteBuffer 使用是堆外的虛擬內(nèi)存唤殴,因此分配(map)的內(nèi)存大小不受 JVM 的 -Xmx 參數(shù)限制,但是也是有大小限制的到腥。
- 如果當(dāng)文件超出 Integer.MAX_VALUE 字節(jié)限制時(shí)朵逝,可以通過(guò) position 參數(shù)重新 map 文件后面的內(nèi)容。
- MappedByteBuffer 在處理大文件時(shí)性能的確很高乡范,但也存內(nèi)存占用配名、文件關(guān)閉不確定等問(wèn)題,被其打開(kāi)的文件只有在垃圾回收的才會(huì)被關(guān)閉晋辆,而且這個(gè)時(shí)間點(diǎn)是不確定的渠脉。
- MappedByteBuffer 提供了文件映射內(nèi)存的 mmap() 方法,也提供了釋放映射內(nèi)存的 unmap() 方法瓶佳。然而 unmap() 是 FileChannelImpl 中的私有方法芋膘,無(wú)法直接顯示調(diào)用。因此,用戶程序需要通過(guò) Java 反射的調(diào)用 sun.misc.Cleaner 類的 clean() 方法手動(dòng)釋放映射占用的內(nèi)存區(qū)域为朋。
public static void clean(final Object buffer) throws Exception {
AccessController.doPrivileged((PrivilegedAction<Void>) () -> {
try {
Method getCleanerMethod = buffer.getClass().getMethod("cleaner", new Class[0]);
getCleanerMethod.setAccessible(true);
Cleaner cleaner = (Cleaner) getCleanerMethod.invoke(buffer, new Object[0]);
cleaner.clean();
} catch(Exception e) {
e.printStackTrace();
}
});
}
8.2. DirectByteBuffer
DirectByteBuffer 的對(duì)象引用位于 Java 內(nèi)存模型的堆里面臂拓,JVM 可以對(duì) DirectByteBuffer 的對(duì)象進(jìn)行內(nèi)存分配和回收管理,一般使用 DirectByteBuffer 的靜態(tài)方法 allocateDirect() 創(chuàng)建 DirectByteBuffer 實(shí)例并分配內(nèi)存习寸。
public static ByteBuffer allocateDirect(int capacity) {
return new DirectByteBuffer(capacity);
}
DirectByteBuffer 內(nèi)部的字節(jié)緩沖區(qū)位在于堆外的(用戶態(tài))直接內(nèi)存胶惰,它是通過(guò) Unsafe 的本地方法 allocateMemory() 進(jìn)行內(nèi)存分配,底層調(diào)用的是操作系統(tǒng)的 malloc() 函數(shù)霞溪。
DirectByteBuffer(int cap) {
super(-1, 0, cap, cap);
boolean pa = VM.isDirectMemoryPageAligned();
int ps = Bits.pageSize();
long size = Math.max(1L, (long)cap + (pa ? ps : 0));
Bits.reserveMemory(size, cap);
long base = 0;
try {
base = unsafe.allocateMemory(size);
} catch (OutOfMemoryError x) {
Bits.unreserveMemory(size, cap);
throw x;
}
unsafe.setMemory(base, size, (byte) 0);
if (pa && (base % ps != 0)) {
address = base + ps - (base & (ps - 1));
} else {
address = base;
}
cleaner = Cleaner.create(this, new Deallocator(base, size, cap));
att = null;
}
除此之外童番,初始化 DirectByteBuffer 時(shí)還會(huì)創(chuàng)建一個(gè) Deallocator 線程,并通過(guò) Cleaner 的 freeMemory() 方法來(lái)對(duì)直接內(nèi)存進(jìn)行回收操作威鹿,freeMemory() 底層調(diào)用的是操作系統(tǒng)的 free() 函數(shù)。
private static class Deallocator implements Runnable {
private static Unsafe unsafe = Unsafe.getUnsafe();
private long address;
private long size;
private int capacity;
private Deallocator(long address, long size, int capacity) {
assert (address != 0);
this.address = address;
this.size = size;
this.capacity = capacity;
}
public void run() {
if (address == 0) {
return;
}
unsafe.freeMemory(address);
address = 0;
Bits.unreserveMemory(size, capacity);
}
}
由于使用 DirectByteBuffer 分配的是系統(tǒng)本地的內(nèi)存轨香,不在 JVM 的管控范圍之內(nèi)忽你,因此直接內(nèi)存的回收和堆內(nèi)存的回收不同,直接內(nèi)存如果使用不當(dāng)臂容,很容易造成 OutOfMemoryError科雳。
說(shuō)了這么多,那么 DirectByteBuffer 和零拷貝有什么關(guān)系脓杉?前面有提到在 MappedByteBuffer 進(jìn)行內(nèi)存映射時(shí)糟秘,它的 map() 方法會(huì)通過(guò) Util.newMappedByteBuffer() 來(lái)創(chuàng)建一個(gè)緩沖區(qū)實(shí)例,初始化的代碼如下:
static MappedByteBuffer newMappedByteBuffer(int size, long addr, FileDescriptor fd,
Runnable unmapper) {
MappedByteBuffer dbb;
if (directByteBufferConstructor == null)
initDBBConstructor();
try {
dbb = (MappedByteBuffer)directByteBufferConstructor.newInstance(
new Object[] { new Integer(size), new Long(addr), fd, unmapper });
} catch (InstantiationException | IllegalAccessException | InvocationTargetException e) {
throw new InternalError(e);
}
return dbb;
}
private static void initDBBRConstructor() {
AccessController.doPrivileged(new PrivilegedAction<Void>() {
public Void run() {
try {
Class<?> cl = Class.forName("java.nio.DirectByteBufferR");
Constructor<?> ctor = cl.getDeclaredConstructor(
new Class<?>[] { int.class, long.class, FileDescriptor.class,
Runnable.class });
ctor.setAccessible(true);
directByteBufferRConstructor = ctor;
} catch (ClassNotFoundException | NoSuchMethodException |
IllegalArgumentException | ClassCastException x) {
throw new InternalError(x);
}
return null;
}});
}
DirectByteBuffer 是 MappedByteBuffer 的具體實(shí)現(xiàn)類球散。實(shí)際上尿赚,Util.newMappedByteBuffer() 方法通過(guò)反射機(jī)制獲取 DirectByteBuffer 的構(gòu)造器,然后創(chuàng)建一個(gè) DirectByteBuffer 的實(shí)例蕉堰,對(duì)應(yīng)的是一個(gè)單獨(dú)用于內(nèi)存映射的構(gòu)造方法:
protected DirectByteBuffer(int cap, long addr, FileDescriptor fd, Runnable unmapper) {
super(-1, 0, cap, cap, fd);
address = addr;
cleaner = Cleaner.create(this, unmapper);
att = null;
}
因此凌净,除了允許分配操作系統(tǒng)的直接內(nèi)存以外,DirectByteBuffer 本身也具有文件內(nèi)存映射的功能屋讶,這里不做過(guò)多說(shuō)明冰寻。我們需要關(guān)注的是,DirectByteBuffer 在 MappedByteBuffer 的基礎(chǔ)上提供了內(nèi)存映像文件的隨機(jī)讀取 get() 和寫入 write() 的操作皿渗。
- 內(nèi)存映像文件的隨機(jī)讀操作
public byte get() {
return ((unsafe.getByte(ix(nextGetIndex()))));
}
public byte get(int i) {
return ((unsafe.getByte(ix(checkIndex(i)))));
}
- 內(nèi)存映像文件的隨機(jī)寫操作
public ByteBuffer put(byte x) {
unsafe.putByte(ix(nextPutIndex()), ((x)));
return this;
}
public ByteBuffer put(int i, byte x) {
unsafe.putByte(ix(checkIndex(i)), ((x)));
return this;
}
內(nèi)存映像文件的隨機(jī)讀寫都是借助 ix() 方法實(shí)現(xiàn)定位的斩芭, ix() 方法通過(guò)內(nèi)存映射空間的內(nèi)存首地址(address)和給定偏移量 i 計(jì)算出指針地址,然后由 unsafe 類的 get() 和 put() 方法和對(duì)指針指向的數(shù)據(jù)進(jìn)行讀取或?qū)懭搿?/p>
private long ix(int i) {
return address + ((long)i << 0);
}
8.3. FileChannel
FileChannel 是一個(gè)用于文件讀寫乐疆、映射和操作的通道划乖,同時(shí)它在并發(fā)環(huán)境下是線程安全的,基于 FileInputStream诀拭、FileOutputStream 或者 RandomAccessFile 的 getChannel() 方法可以創(chuàng)建并打開(kāi)一個(gè)文件通道迁筛。FileChannel 定義了 transferFrom() 和 transferTo() 兩個(gè)抽象方法,它通過(guò)在通道和通道之間建立連接實(shí)現(xiàn)數(shù)據(jù)傳輸?shù)摹?/p>
- transferTo():通過(guò) FileChannel 把文件里面的源數(shù)據(jù)寫入一個(gè) WritableByteChannel 的目的通道。
public abstract long transferTo(long position, long count, WritableByteChannel target)
throws IOException;
- transferFrom():把一個(gè)源通道 ReadableByteChannel 中的數(shù)據(jù)讀取到當(dāng)前 FileChannel 的文件里面细卧。
public abstract long transferFrom(ReadableByteChannel src, long position, long count)
throws IOException;
下面給出 FileChannel 利用 transferTo() 和 transferFrom() 方法進(jìn)行數(shù)據(jù)傳輸?shù)氖褂檬纠?/p>
private static final String CONTENT = "Zero copy implemented by FileChannel";
private static final String SOURCE_FILE = "/source.txt";
private static final String TARGET_FILE = "/target.txt";
private static final String CHARSET = "UTF-8";
首先在類加載根路徑下創(chuàng)建 source.txt 和 target.txt 兩個(gè)文件尉桩,對(duì)源文件 source.txt 文件寫入初始化數(shù)據(jù)。
@Before
public void setup() {
Path source = Paths.get(getClassPath(SOURCE_FILE));
byte[] bytes = CONTENT.getBytes(Charset.forName(CHARSET));
try (FileChannel fromChannel = FileChannel.open(source, StandardOpenOption.READ,
StandardOpenOption.WRITE, StandardOpenOption.TRUNCATE_EXISTING)) {
fromChannel.write(ByteBuffer.wrap(bytes));
} catch (IOException e) {
e.printStackTrace();
}
}
對(duì)于 transferTo() 方法而言贪庙,目的通道 toChannel 可以是任意的單向字節(jié)寫通道 WritableByteChannel蜘犁;而對(duì)于 transferFrom() 方法而言,源通道 fromChannel 可以是任意的單向字節(jié)讀通道 ReadableByteChannel止邮。其中这橙,F(xiàn)ileChannel、SocketChannel 和 DatagramChannel 等通道實(shí)現(xiàn)了 WritableByteChannel 和 ReadableByteChannel 接口导披,都是同時(shí)支持讀寫的雙向通道屈扎。為了方便測(cè)試,下面給出基于 FileChannel 完成 channel-to-channel 的數(shù)據(jù)傳輸示例撩匕。
- 通過(guò) transferTo() 將 fromChannel 中的數(shù)據(jù)拷貝到 toChannel
@Test
public void transferTo() throws Exception {
try (FileChannel fromChannel = new RandomAccessFile(
getClassPath(SOURCE_FILE), "rw").getChannel();
FileChannel toChannel = new RandomAccessFile(
getClassPath(TARGET_FILE), "rw").getChannel()) {
long position = 0L;
long offset = fromChannel.size();
fromChannel.transferTo(position, offset, toChannel);
}
}
- 通過(guò) transferFrom() 將 fromChannel 中的數(shù)據(jù)拷貝到 toChannel
@Test
public void transferFrom() throws Exception {
try (FileChannel fromChannel = new RandomAccessFile(
getClassPath(SOURCE_FILE), "rw").getChannel();
FileChannel toChannel = new RandomAccessFile(
getClassPath(TARGET_FILE), "rw").getChannel()) {
long position = 0L;
long offset = fromChannel.size();
toChannel.transferFrom(fromChannel, position, offset);
}
}
下面介紹 transferTo() 和 transferFrom() 方法的底層實(shí)現(xiàn)原理鹰晨,這兩個(gè)方法也是 java.nio.channels.FileChannel 的抽象方法,由子類 sun.nio.ch.FileChannelImpl.java 實(shí)現(xiàn)止毕。transferTo() 和 transferFrom() 底層都是基于 sendfile 實(shí)現(xiàn)數(shù)據(jù)傳輸?shù)哪@渲?FileChannelImpl.java 定義了 3 個(gè)常量,用于標(biāo)示當(dāng)前操作系統(tǒng)的內(nèi)核是否支持 sendfile 以及 sendfile 的相關(guān)特性扁凛。
private static volatile boolean transferSupported = true;
private static volatile boolean pipeSupported = true;
private static volatile boolean fileSupported = true;
- transferSupported:用于標(biāo)記當(dāng)前的系統(tǒng)內(nèi)核是否支持 sendfile() 調(diào)用忍疾,默認(rèn)為 true。
- pipeSupported:用于標(biāo)記當(dāng)前的系統(tǒng)內(nèi)核是否支持文件描述符(fd)基于管道(pipe)的 sendfile() 調(diào)用谨朝,默認(rèn)為 true卤妒。
- fileSupported:用于標(biāo)記當(dāng)前的系統(tǒng)內(nèi)核是否支持文件描述符(fd)基于文件(file)的 sendfile() 調(diào)用,默認(rèn)為 true叠必。
下面以 transferTo() 的源碼實(shí)現(xiàn)為例荚孵。FileChannelImpl 首先執(zhí)行 transferToDirectly() 方法,以 sendfile 的零拷貝方式嘗試數(shù)據(jù)拷貝纬朝。如果系統(tǒng)內(nèi)核不支持 sendfile收叶,進(jìn)一步執(zhí)行 transferToTrustedChannel() 方法,以 mmap 的零拷貝方式進(jìn)行內(nèi)存映射共苛,這種情況下目的通道必須是 FileChannelImpl 或者 SelChImpl 類型判没。如果以上兩步都失敗了,則執(zhí)行 transferToArbitraryChannel() 方法隅茎,基于傳統(tǒng)的 I/O 方式完成讀寫澄峰,具體步驟是初始化一個(gè)臨時(shí)的 DirectBuffer,將源通道 FileChannel 的數(shù)據(jù)讀取到 DirectBuffer辟犀,再寫入目的通道 WritableByteChannel 里面俏竞。
public long transferTo(long position, long count, WritableByteChannel target)
throws IOException {
// 計(jì)算文件的大小
long sz = size();
// 校驗(yàn)起始位置
if (position > sz)
return 0;
int icount = (int)Math.min(count, Integer.MAX_VALUE);
// 校驗(yàn)偏移量
if ((sz - position) < icount)
icount = (int)(sz - position);
long n;
if ((n = transferToDirectly(position, icount, target)) >= 0)
return n;
if ((n = transferToTrustedChannel(position, icount, target)) >= 0)
return n;
return transferToArbitraryChannel(position, icount, target);
}
接下來(lái)重點(diǎn)分析一下 transferToDirectly() 方法的實(shí)現(xiàn),也就是 transferTo() 通過(guò) sendfile 實(shí)現(xiàn)零拷貝的精髓所在』昊伲可以看到玻佩,transferToDirectlyInternal() 方法先獲取到目的通道 WritableByteChannel 的文件描述符 targetFD,獲取同步鎖然后執(zhí)行 transferToDirectlyInternal() 方法席楚。
private long transferToDirectly(long position, int icount, WritableByteChannel target)
throws IOException {
// 省略從target獲取targetFD的過(guò)程
if (nd.transferToDirectlyNeedsPositionLock()) {
synchronized (positionLock) {
long pos = position();
try {
return transferToDirectlyInternal(position, icount,
target, targetFD);
} finally {
position(pos);
}
}
} else {
return transferToDirectlyInternal(position, icount, target, targetFD);
}
}
最終由 transferToDirectlyInternal() 調(diào)用本地方法 transferTo0() 咬崔,嘗試以 sendfile 的方式進(jìn)行數(shù)據(jù)傳輸。如果系統(tǒng)內(nèi)核完全不支持 sendfile烦秩,比如 Windows 操作系統(tǒng)垮斯,則返回 UNSUPPORTED 并把 transferSupported 標(biāo)識(shí)為 false。如果系統(tǒng)內(nèi)核不支持 sendfile 的一些特性只祠,比如說(shuō)低版本的 Linux 內(nèi)核不支持 DMA gather copy 操作兜蠕,則返回 UNSUPPORTED_CASE 并把 pipeSupported 或者 fileSupported 標(biāo)識(shí)為 false。
private long transferToDirectlyInternal(long position, int icount,
WritableByteChannel target,
FileDescriptor targetFD) throws IOException {
assert !nd.transferToDirectlyNeedsPositionLock() ||
Thread.holdsLock(positionLock);
long n = -1;
int ti = -1;
try {
begin();
ti = threads.add();
if (!isOpen())
return -1;
do {
n = transferTo0(fd, position, icount, targetFD);
} while ((n == IOStatus.INTERRUPTED) && isOpen());
if (n == IOStatus.UNSUPPORTED_CASE) {
if (target instanceof SinkChannelImpl)
pipeSupported = false;
if (target instanceof FileChannelImpl)
fileSupported = false;
return IOStatus.UNSUPPORTED_CASE;
}
if (n == IOStatus.UNSUPPORTED) {
transferSupported = false;
return IOStatus.UNSUPPORTED;
}
return IOStatus.normalize(n);
} finally {
threads.remove(ti);
end (n > -1);
}
}
本地方法(native method)transferTo0() 通過(guò) JNI(Java Native Interface)調(diào)用底層 C 的函數(shù)抛寝,這個(gè) native 函數(shù)(Java_sun_nio_ch_FileChannelImpl_transferTo0)同樣位于 JDK 源碼包下的 native/sun/nio/ch/FileChannelImpl.c 源文件里面牺氨。JNI 函數(shù) Java_sun_nio_ch_FileChannelImpl_transferTo0() 基于條件編譯對(duì)不同的系統(tǒng)進(jìn)行預(yù)編譯,下面是 JDK 基于 Linux 系統(tǒng)內(nèi)核對(duì) transferTo() 提供的調(diào)用封裝墩剖。
#if defined(__linux__) || defined(__solaris__)
#include <sys/sendfile.h>
#elif defined(_AIX)
#include <sys/socket.h>
#elif defined(_ALLBSD_SOURCE)
#include <sys/types.h>
#include <sys/socket.h>
#include <sys/uio.h>
#define lseek64 lseek
#define mmap64 mmap
#endif
JNIEXPORT jlong JNICALL
Java_sun_nio_ch_FileChannelImpl_transferTo0(JNIEnv *env, jobject this,
jobject srcFDO,
jlong position, jlong count,
jobject dstFDO)
{
jint srcFD = fdval(env, srcFDO);
jint dstFD = fdval(env, dstFDO);
#if defined(__linux__)
off64_t offset = (off64_t)position;
jlong n = sendfile64(dstFD, srcFD, &offset, (size_t)count);
return n;
#elif defined(__solaris__)
result = sendfilev64(dstFD, &sfv, 1, &numBytes);
return result;
#elif defined(__APPLE__)
result = sendfile(srcFD, dstFD, position, &numBytes, NULL, 0);
return result;
#endif
}
對(duì) Linux、Solaris 以及 Apple 系統(tǒng)而言夷狰,transferTo0() 函數(shù)底層會(huì)執(zhí)行 sendfile64 這個(gè)系統(tǒng)調(diào)用完成零拷貝操作岭皂,sendfile64() 函數(shù)的原型如下:
#include <sys/sendfile.h>
ssize_t sendfile64(int out_fd, int in_fd, off_t *offset, size_t count);
下面簡(jiǎn)單介紹一下 sendfile64() 函數(shù)各個(gè)參數(shù)的含義:
- out_fd:待寫入的文件描述符
- in_fd:待讀取的文件描述符
- offset:指定 in_fd 對(duì)應(yīng)文件流的讀取位置,如果為空沼头,則默認(rèn)從起始位置開(kāi)始
- count:指定在文件描述符 in_fd 和 out_fd 之間傳輸?shù)淖止?jié)數(shù)
在 Linux 2.6.3 之前爷绘,out_fd 必須是一個(gè) socket,而從 Linux 2.6.3 以后进倍,out_fd 可以是任何文件土至。也就是說(shuō),sendfile64() 函數(shù)不僅可以進(jìn)行網(wǎng)絡(luò)文件傳輸猾昆,還可以對(duì)本地文件實(shí)現(xiàn)零拷貝操作陶因。
9. 其它的零拷貝實(shí)現(xiàn)
9.1. Netty零拷貝
Netty 中的零拷貝和上面提到的操作系統(tǒng)層面上的零拷貝不太一樣, 我們所說(shuō)的 Netty 零拷貝完全是基于(Java 層面)用戶態(tài)的,它的更多的是偏向于數(shù)據(jù)操作優(yōu)化這樣的概念垂蜗,具體表現(xiàn)在以下幾個(gè)方面:
- Netty 通過(guò) DefaultFileRegion 類對(duì) java.nio.channels.FileChannel 的 tranferTo() 方法進(jìn)行包裝楷扬,在文件傳輸時(shí)可以將文件緩沖區(qū)的數(shù)據(jù)直接發(fā)送到目的通道(Channel)
- ByteBuf 可以通過(guò) wrap 操作把字節(jié)數(shù)組、ByteBuf贴见、ByteBuffer 包裝成一個(gè) ByteBuf 對(duì)象, 進(jìn)而避免了拷貝操作
- ByteBuf 支持 slice 操作, 因此可以將 ByteBuf 分解為多個(gè)共享同一個(gè)存儲(chǔ)區(qū)域的 ByteBuf烘苹,避免了內(nèi)存的拷貝
- Netty 提供了 CompositeByteBuf 類,它可以將多個(gè) ByteBuf 合并為一個(gè)邏輯上的 ByteBuf片部,避免了各個(gè) ByteBuf 之間的拷貝
其中第 1 條屬于操作系統(tǒng)層面的零拷貝操作镣衡,后面 3 條只能算用戶層面的數(shù)據(jù)操作優(yōu)化。
9.2. RocketMQ和Kafka對(duì)比
RocketMQ 選擇了 mmap + write 這種零拷貝方式,適用于業(yè)務(wù)級(jí)消息這種小塊文件的數(shù)據(jù)持久化和傳輸;而 Kafka 采用的是 sendfile 這種零拷貝方式隆箩,適用于系統(tǒng)日志消息這種高吞吐量的大塊文件的數(shù)據(jù)持久化和傳輸辕漂。但是值得注意的一點(diǎn)是,Kafka 的索引文件使用的是 mmap + write 方式曾雕,數(shù)據(jù)文件使用的是 sendfile 方式。
消息隊(duì)列 | 零拷貝方式 | 優(yōu)點(diǎn) | 缺點(diǎn) |
---|---|---|---|
RocketMQ | mmap + write | 適用于小塊文件傳輸助被,頻繁調(diào)用時(shí)剖张,效率很高 | 不能很好的利用 DMA 方式,會(huì)比 sendfile 多消耗 CPU揩环,內(nèi)存安全性控制復(fù)雜搔弄,需要避免 JVM Crash 問(wèn)題 |
Kafka | sendfile | 可以利用 DMA 方式,消耗 CPU 較少丰滑,大塊文件傳輸效率高顾犹,無(wú)內(nèi)存安全性問(wèn)題 | 小塊文件效率低于 mmap 方式,只能是 BIO 方式傳輸褒墨,不能使用 NIO 方式 |
小結(jié)
本文開(kāi)篇詳述了 Linux 操作系統(tǒng)中的物理內(nèi)存和虛擬內(nèi)存炫刷,內(nèi)核空間和用戶空間的概念以及 Linux 內(nèi)部的層級(jí)結(jié)構(gòu)。在此基礎(chǔ)上郁妈,進(jìn)一步分析和對(duì)比傳統(tǒng) I/O 方式和零拷貝方式的區(qū)別浑玛,然后介紹了 Linux 內(nèi)核提供的幾種零拷貝實(shí)現(xiàn),包括內(nèi)存映射 mmap噩咪、sendfile顾彰、sendfile + DMA gather copy 以及 splice 幾種機(jī)制,并從系統(tǒng)調(diào)用和拷貝次數(shù)層面對(duì)它們進(jìn)行了對(duì)比胃碾。接下來(lái)從源碼著手分析了 Java NIO 對(duì)零拷貝的實(shí)現(xiàn)涨享,主要包括基于內(nèi)存映射(mmap)方式的 MappedByteBuffer 以及基于 sendfile 方式的 FileChannel。最后在篇末簡(jiǎn)單的闡述了一下 Netty 中的零拷貝機(jī)制仆百,以及 RocketMQ 和 Kafka 兩種消息隊(duì)列在零拷貝實(shí)現(xiàn)方式上的區(qū)別厕隧。
本帳號(hào)將持續(xù)分享后端技術(shù)干貨,包括虛擬機(jī)基礎(chǔ)俄周,多線程編程栏账,高性能框架,異步栈源、緩存和消息中間件挡爵,分布式和微服務(wù),架構(gòu)學(xué)習(xí)和進(jìn)階等學(xué)習(xí)資料和文章甚垦。