IO到零拷貝:NIO底層原理與面試實(shí)戰(zhàn)解析)
簡(jiǎn)介基于ASP.NET的排課系統(tǒng)項(xiàng)目面向計(jì)算機(jī)專業(yè)畢業(yè)生與Web開(kāi)發(fā)學(xué)習(xí)者適合作為高校課程設(shè)計(jì)或畢業(yè)設(shè)計(jì)的完整參考。系統(tǒng)圍繞教務(wù)排課的實(shí)際需求可處理教師時(shí)間沖突、教室容量限制、課程先后順序、教學(xué)班劃分等約束問(wèn)題并通過(guò)ASP.NET技術(shù)將前端頁(yè)面、后臺(tái)處理邏輯與數(shù)據(jù)庫(kù)訪問(wèn)整合在一起幫助學(xué)習(xí)者理解完整的Web應(yīng)用分層結(jié)構(gòu)。資源包以RAR壓縮格式提供大小約1.18MB內(nèi)容涵蓋ASP.NET頁(yè)面及后臺(tái)代碼文件、SQL Server數(shù)據(jù)庫(kù)文件、web.config等關(guān)鍵配置以及配套的設(shè)計(jì)論文、使用說(shuō)明文檔便于按模塊瀏覽、運(yùn)行和二次開(kāi)發(fā)。目前已有248人瀏覽學(xué)習(xí)說(shuō)明這套課題方案在同類畢業(yè)設(shè)計(jì)中具備不錯(cuò)的參考熱度。實(shí)踐價(jià)值方面開(kāi)發(fā)者可以從中掌握數(shù)據(jù)庫(kù)建模的思路、貪心或回溯等排課算法的落地寫(xiě)法也能學(xué)習(xí)ASP.NET應(yīng)用在IIS或開(kāi)發(fā)環(huán)境中的部署與調(diào)試流程配套論文資料有助于梳理系統(tǒng)設(shè)計(jì)與寫(xiě)作框架但直接提交會(huì)影響查重畢業(yè)設(shè)計(jì)階段應(yīng)結(jié)合自身理解進(jìn)行修改和擴(kuò)展確保原創(chuàng)性。1. 簡(jiǎn)歷里的一句“精通IO”成了面試整場(chǎng)最危險(xiǎn)的伏筆簡(jiǎn)歷上寫(xiě)“精通IO操作”的時(shí)候心里想的是自己確實(shí)用過(guò)FileInputStream、OutputStream、BufferedInputStream還知道怎么用NIO的Buffer和Channel讀文件覺(jué)得這怎么也能算“熟練”。直到面試官把問(wèn)題拋出來(lái)——“NIO中零拷貝的原理與實(shí)現(xiàn)有哪些”——我才發(fā)現(xiàn)自己腦子里只有幾個(gè)零碎概念mmap可以映射文件、transferTo能高效發(fā)文件、DMA不占CPU。但要我講清楚從文件到網(wǎng)卡這條完整數(shù)據(jù)鏈路上每一步到底發(fā)生了什么、哪些過(guò)程被優(yōu)化掉了、底層調(diào)用對(duì)應(yīng)哪個(gè)系統(tǒng)調(diào)用我啞火了。那次沉默其實(shí)很值錢。它讓我意識(shí)到很多程序員對(duì)IO的理解停留在“會(huì)調(diào)API”的層面而零拷貝這道題正好戳在“會(huì)調(diào)API”和“懂不懂原理”之間。面試官想考察的不僅是知不知道“零拷貝”這個(gè)名詞還想看能不能把內(nèi)核態(tài)、用戶態(tài)、頁(yè)緩存、socket緩沖區(qū)、DMA這些角色擺到正確的位置上描述出一條完整的數(shù)據(jù)流轉(zhuǎn)路徑。1.1 為什么偏偏是“零拷貝”被拿來(lái)當(dāng)試金石IO操作是后端服務(wù)繞不開(kāi)的基本功但能被問(wèn)出深度的地方其實(shí)不多。零拷貝這個(gè)概念很特殊它從頂層的Java APIFileChannel.transferTo一直延伸到Linux內(nèi)核的sendfile實(shí)現(xiàn)再到網(wǎng)卡的DMA gather能力是一條貫穿了應(yīng)用層、內(nèi)核層、硬件層的完整鏈條。能把這條鏈講明白的候選人至少說(shuō)明他對(duì)IO發(fā)生過(guò)的事情有立體理解而不是停留在“調(diào)接口、讀返回”的平面認(rèn)識(shí)。另一方面零拷貝也是區(qū)分“背八股”和“真懂系統(tǒng)”的利器。很多面試者能背出“mmap減少一次拷貝sendfile減少兩次拷貝”但當(dāng)你追問(wèn)“哪一次拷貝被省掉了省掉之后CPU還干不干活”時(shí)大多就會(huì)露餡。面試官只需要順著問(wèn)題往下挖兩層就能判斷一個(gè)人到底有沒(méi)有真正運(yùn)用過(guò)這套機(jī)制。1.2 被問(wèn)到“零拷貝”時(shí)我當(dāng)時(shí)的真實(shí)腦內(nèi)場(chǎng)景我腦子里的第一反應(yīng)是想先給零拷貝下個(gè)定義但轉(zhuǎn)念一想又怕說(shuō)得太絕對(duì)被打臉。然后想到了“用戶態(tài)和內(nèi)核態(tài)”的切換次數(shù)猶豫要不要提想到了sendfile又記不清它和mmap在這個(gè)場(chǎng)景里到底哪個(gè)更優(yōu)想提DMA但不太確定能不能把DMA描述得足夠準(zhǔn)確。最后組織出來(lái)的語(yǔ)言就變成了幾句沒(méi)有邏輯遞進(jìn)的零散術(shù)語(yǔ)。面試官等了幾秒點(diǎn)點(diǎn)頭繼續(xù)問(wèn)了下一個(gè)問(wèn)題。那一刻我知道這道題我大致是掛了。復(fù)盤(pán)時(shí)我強(qiáng)烈意識(shí)到一個(gè)關(guān)鍵差距我用過(guò)NIO的Channel和Buffer但從來(lái)沒(méi)有認(rèn)真看過(guò)底層系統(tǒng)調(diào)用發(fā)生了什么。所以接下來(lái)的內(nèi)容是我把從一堆內(nèi)核資料和性能測(cè)試?yán)镏匦聯(lián)炱饋?lái)的知識(shí)重新梳理成了一條可以“講給別人聽(tīng)”的邏輯鏈。2. 傳統(tǒng)IO的賬本四次拷貝和四次切換花在哪了2.1 readwrite的數(shù)據(jù)搬運(yùn)鏈路一條一條拆開(kāi)看先看最經(jīng)典的場(chǎng)景服務(wù)端要把磁盤(pán)上一個(gè)文件完整傳給網(wǎng)絡(luò)客戶端。傳統(tǒng)Java寫(xiě)法大致是這樣循環(huán)讀文件字節(jié)再寫(xiě)入socket輸出流。表面上看就這么簡(jiǎn)單但底層每個(gè)字節(jié)都要經(jīng)過(guò)一條相當(dāng)長(zhǎng)的路。磁盤(pán)控制器通過(guò)DMADirect Memory Access把文件數(shù)據(jù)讀入內(nèi)核的page cache。這一步是硬件搬運(yùn)數(shù)據(jù)CPU不需要逐字節(jié)參與。read系統(tǒng)調(diào)用進(jìn)入內(nèi)核后CPU會(huì)把page cache中的數(shù)據(jù)復(fù)制到用戶態(tài)緩沖區(qū)。這就是為什么常說(shuō)“內(nèi)核態(tài)到用戶態(tài)有一次拷貝”而這次拷貝是實(shí)打?qū)嵪腃PU的。write系統(tǒng)調(diào)用執(zhí)行時(shí)CPU把用戶態(tài)緩沖區(qū)的數(shù)據(jù)復(fù)制到socket發(fā)送緩沖區(qū)。這又是一次消耗CPU的拷貝。網(wǎng)卡通過(guò)DMA從socket發(fā)送緩沖區(qū)取走數(shù)據(jù)發(fā)送到網(wǎng)絡(luò)。最后一次拷貝也由DMA完成。所以一條完整路徑是四次拷貝其中兩次是CPU拷貝。再加上read和write兩個(gè)系統(tǒng)調(diào)用每次系統(tǒng)調(diào)用都會(huì)經(jīng)歷用戶態(tài)切換到內(nèi)核態(tài)、執(zhí)行完再切回用戶態(tài)一共四次上下文切換。2.2 為什么說(shuō)這些開(kāi)銷對(duì)高并發(fā)服務(wù)是致命的單純看四次拷貝和四次切換好像也不是特別多??梢坏┌迅卟l(fā)放大來(lái)看就不一樣了。一個(gè)在線文件服務(wù)很可能每秒鐘要處理幾千個(gè)下載請(qǐng)求如果每個(gè)請(qǐng)求都讓CPU去搬運(yùn)幾兆甚至幾十兆的數(shù)據(jù)CPU的算力絕大部分都會(huì)耗在純粹的復(fù)制指令上業(yè)務(wù)邏輯根本搶不到時(shí)間片。上下文切換同樣是個(gè)放大器線程越多、鎖越多切換成本越高整個(gè)系統(tǒng)的吞吐量會(huì)被層層拖垮。當(dāng)時(shí)我寫(xiě)傳統(tǒng)IO代碼時(shí)很少會(huì)把“上下文切換”當(dāng)成一個(gè)顯著性能瓶頸來(lái)考慮。后來(lái)在實(shí)際壓測(cè)中發(fā)現(xiàn)同樣一份文件傳輸邏輯把中間的用戶態(tài)緩存去掉改走零拷貝路徑CPU占用率能下降一個(gè)數(shù)量級(jí)。這個(gè)結(jié)果不是某一處優(yōu)化帶來(lái)的而是整個(gè)鏈路里節(jié)省下來(lái)的拷貝指令和系統(tǒng)調(diào)用次數(shù)共同貢獻(xiàn)的。理解這層背景之后就能明白面試官為什么那么熱衷于問(wèn)零拷貝——因?yàn)樗谡鎸?shí)生產(chǎn)環(huán)境中是真的能救性能的。3. 從減少拷貝到“CPU零參與”mmap與sendfile的進(jìn)化路徑3.1 mmapwrite干掉用戶態(tài)到內(nèi)核態(tài)的那次復(fù)制mmap的思路是把文件映射到進(jìn)程的虛擬地址空間。映射完成后用戶態(tài)代碼讀文件時(shí)可以直接通過(guò)內(nèi)存地址訪問(wèn)page cache中的內(nèi)容省去了傳統(tǒng)read中把數(shù)據(jù)從內(nèi)核緩沖區(qū)復(fù)制到用戶緩沖區(qū)的動(dòng)作。但數(shù)據(jù)最終要發(fā)送到socket所以還得再調(diào)用write把數(shù)據(jù)發(fā)出去。這個(gè)write調(diào)用會(huì)把page cache里的數(shù)據(jù)通過(guò)CPU復(fù)制到socket發(fā)送緩沖區(qū)。這時(shí)的拷貝數(shù)量從傳統(tǒng)IO的四次變成三次磁盤(pán)DMA到page cache、CPU從page cache到socket緩沖區(qū)、DMA從socket緩沖區(qū)到網(wǎng)卡。上下文切換依然是四次因?yàn)檫€是兩個(gè)系統(tǒng)調(diào)用。mmap的價(jià)值在于省掉了一次CPU拷貝但仍然沒(méi)有把CPU從數(shù)據(jù)搬運(yùn)中完全解放出來(lái)。3.2 sendfile讓數(shù)據(jù)全程留在內(nèi)核里sendfile系統(tǒng)調(diào)用的思路更激進(jìn)既然這個(gè)數(shù)據(jù)從頭到尾都要發(fā)給網(wǎng)絡(luò)為什么還要讓它在用戶態(tài)轉(zhuǎn)一圈直接在kernel里把數(shù)據(jù)從page cache送到socket緩沖區(qū)不行嗎sendfile正是這么做的。它只需要一次系統(tǒng)調(diào)用數(shù)據(jù)路徑變?yōu)榇疟P(pán)DMA把數(shù)據(jù)讀入page cache。CPU把page cache中的內(nèi)容復(fù)制到socket發(fā)送緩沖區(qū)。DMA把socket緩沖區(qū)數(shù)據(jù)送達(dá)網(wǎng)卡。對(duì)比來(lái)看系統(tǒng)調(diào)用從兩個(gè)省到一個(gè)上下文切換從四次變兩次拷貝從四次變?nèi)巍2贿^(guò)這里仍然保留了一次CPU拷貝即第二步。嚴(yán)格來(lái)說(shuō)在這個(gè)版本里CPU還是干了活。這也是很多文章里強(qiáng)調(diào)“sendfile不是嚴(yán)格零拷貝只是接近零拷貝”的原因。3.3 有了DMA gatherCPU在這場(chǎng)搬運(yùn)里徹底退場(chǎng)如果網(wǎng)卡支持scatter-gather功能內(nèi)核就不需要預(yù)先在socket緩沖區(qū)里準(zhǔn)備好一份完整數(shù)據(jù)而是可以把page cache中的數(shù)據(jù)位置、長(zhǎng)度等信息寫(xiě)進(jìn)緩沖區(qū)描述符由網(wǎng)卡DMA引擎主動(dòng)到內(nèi)存里去抓取。這樣一來(lái)第二步的CPU拷貝被徹底省掉sendfile的整個(gè)數(shù)據(jù)路徑只剩下兩次DMA拷貝磁盤(pán)DMA把數(shù)據(jù)讀入page cache。網(wǎng)卡DMA根據(jù)描述符直接從page cache讀取數(shù)據(jù)并發(fā)送。整個(gè)過(guò)程中沒(méi)有任何一次CPU參與的數(shù)據(jù)復(fù)制上下文切換只有一次系統(tǒng)調(diào)用帶來(lái)的兩次切換。這通常就是面試官嘴里的“零拷貝”最具象的落點(diǎn)。當(dāng)然這套方案依賴操作系統(tǒng)、網(wǎng)卡驅(qū)動(dòng)和文件系統(tǒng)是否支持并不是在所有環(huán)境下都能達(dá)到這個(gè)理想狀態(tài)。4. Java NIO里的零拷貝實(shí)踐FileChannel.transferTo怎么用才不踩坑4.1 一段標(biāo)準(zhǔn)的文件轉(zhuǎn)發(fā)代碼Java NIO給上層應(yīng)用提供了兩個(gè)核心入口FileChannel.transferTo和FileChannel.transferFrom。以網(wǎng)絡(luò)文件服務(wù)為例代碼可以簡(jiǎn)單到只有幾行Path file Paths.get(/var/data/package.tar); long fileSize Files.size(file); try (FileChannel inChannel FileChannel.open(file, StandardOpenOption.READ); SocketChannel socketChannel SocketChannel.open(new InetSocketAddress(host, port))) { long transferred 0; while (transferred fileSize) { transferred inChannel.transferTo(transferred, fileSize - transferred, socketChannel); } }這段代碼里inChannel.transferTo把文件內(nèi)容直接送往SocketChannel數(shù)據(jù)不經(jīng)過(guò)用戶態(tài)應(yīng)用緩沖區(qū)。需要注意一個(gè)很容易踩的坑transferTo一次不保證傳完所有數(shù)據(jù)返回值是實(shí)際搬運(yùn)的字節(jié)數(shù)所以必須循環(huán)累加否則大文件傳一半就結(jié)束了。4.2 底層系統(tǒng)調(diào)用與平臺(tái)差異transferTo是Java層面的API但真正干活的是底層系統(tǒng)調(diào)用。在Linux上它對(duì)應(yīng)sendfile在Windows上對(duì)應(yīng)transmitFile在macOS上實(shí)現(xiàn)路徑又不太一樣。正因?yàn)榈讓訉?shí)現(xiàn)不同測(cè)試性能時(shí)不能拿開(kāi)發(fā)機(jī)的macOS結(jié)果去代表整個(gè)生產(chǎn)環(huán)境。常見(jiàn)的情況是同一套代碼在Linux上能跑出很高的吞吐在macOS上卻慢了不少這不一定是JVM參數(shù)問(wèn)題而是系統(tǒng)底層的支持差異造成的。還有一個(gè)細(xì)節(jié)FileChannel.transferTo的目標(biāo)通道可以是SocketChannel但如果是非阻塞通道或底層協(xié)議對(duì)sendfile支持受限可能返回0或者只發(fā)送部分?jǐn)?shù)據(jù)。因此循環(huán)外還要考慮目標(biāo)通道可寫(xiě)狀態(tài)必要時(shí)配合Selector來(lái)調(diào)度。4.3 使用零拷貝前必須想清楚的限制零拷貝不是銀彈有很明顯的邊界。首先是數(shù)據(jù)不可變數(shù)據(jù)一旦進(jìn)入page cache后就直接被發(fā)送沒(méi)有機(jī)會(huì)在應(yīng)用層做修改、加密、壓縮或業(yè)務(wù)鑒權(quán)。如果業(yè)務(wù)需要對(duì)內(nèi)容做加工就必須先把數(shù)據(jù)讀進(jìn)用戶態(tài)傳統(tǒng)IO反而更合適。其次是目標(biāo)必須是socket或管道這類支持sendfile語(yǔ)義的通道不能隨便換成一個(gè)文件通道就指望它自動(dòng)零拷貝。再次是文件大小問(wèn)題如果文件只有幾KB傳統(tǒng)IO的開(kāi)銷未必很大零拷貝節(jié)省的系統(tǒng)調(diào)用和上下文切換可能被初始化、映射等成本抵消性能優(yōu)勢(shì)并不明顯。所以成熟的方案一定會(huì)先判斷數(shù)據(jù)是否“原樣轉(zhuǎn)發(fā)”再?zèng)Q定是否走零拷貝。這也解釋了為什么零拷貝最常出現(xiàn)在日志采集、文件分發(fā)、CDN響應(yīng)這類場(chǎng)景里。它們共同的特點(diǎn)是數(shù)據(jù)量大、內(nèi)容不變、目標(biāo)是網(wǎng)絡(luò)輸出。5. 下次再被問(wèn)到“NIO零拷貝”我會(huì)按這個(gè)順序作答5.1 把“數(shù)據(jù)流”講透再拋出結(jié)論把這段鏈路重新理順之后我的回答思路變成了下面這樣先描述傳統(tǒng)IO存在的問(wèn)題——read和write兩條系統(tǒng)調(diào)用導(dǎo)致四次上下文切換、四次數(shù)據(jù)拷貝其中兩次拷貝要消耗CPU周期。再引出零拷貝的本質(zhì)——不是追求“完全沒(méi)有拷貝”而是希望干掉消耗CPU的那些拷貝同時(shí)減少系統(tǒng)調(diào)用和上下文切換。然后落到Java NIO——FileChannel.transferTo在Linux上對(duì)應(yīng)sendfile由一次系統(tǒng)調(diào)用完成文件內(nèi)容從page cache到socket緩沖區(qū)的搬運(yùn)在網(wǎng)卡支持scatter-gather時(shí)甚至可以做到CPU全程不參與數(shù)據(jù)復(fù)制。最后補(bǔ)充一句適用范圍——數(shù)據(jù)不需要修改、以網(wǎng)絡(luò)發(fā)送為主要場(chǎng)景時(shí)零拷貝收益最大。這么回答好處是邏輯是一條鏈而不是幾個(gè)孤立的詞。就算面試官追到某個(gè)細(xì)節(jié)你也能順著鏈路繼續(xù)往下走。5.2 準(zhǔn)備好應(yīng)對(duì)幾個(gè)高概率追問(wèn)“零拷貝是不是就完全沒(méi)拷貝了”——不是。數(shù)據(jù)從磁盤(pán)到page cache、從page cache到網(wǎng)卡仍然有DMA拷貝。只是CPU拷貝被省掉了CPU不再親手搬數(shù)據(jù)。“mmap和sendfile的區(qū)別在哪里”——mmap通過(guò)內(nèi)存映射省掉內(nèi)核到用戶態(tài)的拷貝但還需要write把數(shù)據(jù)送到socket所以仍有CPU拷貝和兩次系統(tǒng)調(diào)用sendfile直接在文件與socket之間搬運(yùn)系統(tǒng)調(diào)用也精簡(jiǎn)成一次?!澳銈兊捻?xiàng)目里真的用過(guò)嗎在哪用的”——這時(shí)候最好有一個(gè)真實(shí)的業(yè)務(wù)案例。比如之前在日志歸檔服務(wù)里把歷史日志文件通過(guò)transferTo直接下發(fā)到客戶端吞吐提升非常明顯。如果沒(méi)實(shí)際用過(guò)就誠(chéng)實(shí)說(shuō)研究過(guò)并給出一個(gè)可落地的實(shí)驗(yàn)方案比如用兩個(gè)本地端口、一個(gè)臨時(shí)文件直接跑對(duì)比測(cè)試?!靶∥募灿昧憧截悊帷薄灰欢?。小文件省下的系統(tǒng)調(diào)用與上下文切換有限甚至有可能因?yàn)榈讓訉?shí)現(xiàn)開(kāi)銷而不劃算更適合用普通IO或內(nèi)存映射來(lái)處理。6. 那次沉默之后我再也不在簡(jiǎn)歷亂寫(xiě)“精通IO”那次面試結(jié)束后我給自己定了一條規(guī)矩凡是簡(jiǎn)歷上寫(xiě)“精通”的技術(shù)點(diǎn)必須能回答至少三個(gè)“為什么”。為什么這個(gè)方案更快為什么底層要選這個(gè)系統(tǒng)調(diào)用為什么這個(gè)方案在特定場(chǎng)景下不適用如果答不全就把“精通”改成“熟悉”。后來(lái)我用一個(gè)休息日的時(shí)間搭了最簡(jiǎn)單的測(cè)試環(huán)境本機(jī)起一個(gè)Socket服務(wù)客戶端去下載一個(gè)幾百M(fèi)B的臨時(shí)文件分別走傳統(tǒng)的FileInputStream加SocketOutputStream和FileChannel.transferTo兩條路徑。對(duì)比之后數(shù)字差距非常直觀transferTo版本不光吞吐更高CPU占用也降得很明顯。如果環(huán)境允許再用strace看一次系統(tǒng)調(diào)用就能看到sendfile確實(shí)出現(xiàn)在系統(tǒng)調(diào)用列表里。這種親自動(dòng)手的確認(rèn)比記一百遍概念都管用。如果你也在準(zhǔn)備類似的技術(shù)面試或者想在項(xiàng)目里引入零拷貝優(yōu)化我建議你先從數(shù)據(jù)流入手把文件到網(wǎng)卡的路徑畫(huà)清楚再看Java NIO的API最后落到Linux系統(tǒng)調(diào)用驗(yàn)證。只要把這條鏈路想透了下次再有人問(wèn)起你就能從容地把答案一層一層講出來(lái)。本文還有配套的精品資源點(diǎn)擊獲取