漫談NIO(1)之計算機IO實現


1.前言

    此系列將盡可能詳細介紹斷更博客半年以來個人的一個成長,主要是對Netty的源碼的一個解讀記錄,將從整個計算機宏觀IO體系上,到Java的原生NIO例子最后到Netty的源碼解讀。不求完全掌握,但求知道前因后果,設計思路,來檢驗半年所學(之前是懶,水平不夠,現在寫博客查漏補缺)。介紹過程中所涉及的知識點可能有錯,請各位指教,相互學習。

2.為什么關注IO

    簡而言之,就是為了程序執行快,當然現代程序的瓶頸IO只占了其中一部分。IO為什么成為了系統瓶頸呢,引用網上一篇博客(這里)對CPU、內存和磁盤等存儲介質的運行比較,這里簡要說明一下:

    CPU的運行速度快的離譜,大部分簡單指令只要一個時鍾周期(操作系統是按時間片(幾十毫秒,不固定)運行程序的,這樣單核CPU就可以實現大的時間尺度上多個程序同時運行的假象),一個時間周期1/3納秒,足以見CPU速度之快。把一個時間周期看做1秒,從主存中讀取數據就花了4分鍾,果斷不能忍啊,所以現代計算機在主存和CPU之間會構建多級高速緩存,L1、L2、L3等。CPU盡可能從L1獲取數據,只要3秒,L1盡可能從L2獲取數據,只要14秒,同樣L2->L3,L3->內存。這樣可以在有限的成本增加,大幅度提高CPU和主存的數據交互帶來的性能浪費。當然這會帶來而外的問題,比如什么數據應該放在L1、L2、L3中,另外一個帶來的問題就是內存可見性問題,Java中多線程volatile字段的含義(多線程中線程A、B持有同一個變量,A修改了這個變量,確實在主存中該變量被修改了,但是B修改的時候會產生線程安全問題,因為由於高速緩存的存在,B讀取該變量不是從主存中讀取的,而是從高速緩存中讀取,此時這個值並沒有更新,導致A的操作對B線程而言是不可見的。volatile字段的作用就在於強制從主存中獲取該變量的值,而不是高速緩存。當然這樣做只保證了可見性,即A線程操作對B線程可見,並不能保證線程安全,還需要保證操作的原子性,這里就不再多解釋)。

    上述扯遠了,再來看內存獲取磁盤數據的速度,接上面的例子,大概是1年零3個月。納尼!!!1年零3個月獲取數據,CPU就工作了1秒?這就是IO對程序運行帶來的可怕的性能制約。阻塞式IO必須等待執行完IO操作,才能運行后面的操作,這對CPU性能是極大的浪費。雖然在時間片消耗完后CPU會切換到其它程序運行相關代碼,但是阻塞式IO帶來的性能浪費並沒有得到解決。所以我們才需要關注非阻塞式IO,讓程序繼續執行其它邏輯,等到IO操作完成后再執行相應的操作。IO的速度慢在尋道操作,即磁頭要先移動到正確的磁道上,然后磁盤旋轉到正確的位置上,讀取指定扇區的數據。RPM就是衡量磁盤的旋轉速度,越快耗時越少。另一個方式就是將文件連續存儲在磁盤上,這樣也就能節省尋道時間了,但實際上這種方式帶來的效果並不明顯,主要關注的還是單位時間上尋道和隨機IO操作次數,固態硬盤SSD在這方面做的就很好。還有一種方式就是硬盤的cache機制,其將一組零散的寫入操作合並成一個,磁盤控制寫入順序,進而減少尋道次數。一系列讀取操作也可以重組減少耗時。

3.現代IO操作模型

    上面扯了那么多,說明了IO對CPU的性能的浪費,NIO的主要目的是減少這部分無效損耗,並沒有對IO所需要的耗時有太多作用。NIO的意思是非阻塞IO,其實現方式有多種,下面准備介紹一下目前IO的主要模型。

    在介紹IO模型之前,首先要明白幾個概念,才會清楚為什么NIO可以減少CPU的性能浪費。現代操作系統為了保證系統的正確執行,分成了兩種操作模式:用戶模式和監督程序模式(內核模式)。這么做的原因是為了保護操作系統和用戶程序不受其它程序的影響,實現方法是:將能引起損害的機器指令作為特權指令,用戶執行特權指令的時候,硬件不會執行該指令,而是認為該指令是非法指令,會以陷阱的方式通知操作系統,由操作系統內核處理,最后轉交給用戶程序,IO操作就是一個特權指令。操作系統進程有其獨享的內核空間,用戶程序數據及相關內容放在用戶空間中,用戶發起一個IO請求,首先就會切換到內核模式,將數據讀取到內核空間中去,之后將這部分數據拷貝到用戶空間中,切換到用戶模式繼續執行用戶程序,這就是一個基本的IO流程。這里插入一段文件網絡傳輸零拷貝的知識:將文件通過網絡傳輸涉及了兩次IO,一次磁盤IO,一次網絡IO,由於上述所講的操作系統的安全策略,內核會先讀取文件數據到內核空間,再拷貝到用戶空間,傳輸到網絡中去的時候,又將數據從用戶空間拷貝到了內核空間,這造成了多次拷貝的浪費,實際上我們只需要將數據讀取到內核空間,直接傳輸到網絡中去就可以了。零拷貝的做法就是將文件描述符和網絡描述符同時交給內核,指示內核將文件描述符的內容全部傳輸給網絡通道中,這樣就達到了零拷貝的效果了。

    有了上訴對操作系統的執行方式的了解,再來看IO模型就會比較好理解阻塞式IO和非阻塞式IO的區別,以及非阻塞式IO的好處了。下面介紹目前5種IO模型:

3.1 阻塞IO

block

    阻塞式IO就是用戶發起IO請求->內核等待數據加載->內核加載數據完畢->拷貝內核空間數據到用戶空間->提醒用戶程序完成IO讀取操作。

    整個過程種,用戶發起請求到最后一直都在等待內核處理好數據,之前也說過這段時間相對於CPU而言,執行是非常慢的,所以這段時間整個都是浪費了,一直處理等待之中。這就是阻塞式IO。

3.2 非阻塞IO

noblock

    阻塞式IO的特點就是等待數據和拷貝數據兩段都被阻塞了,后面就產生了上圖這種非阻塞式IO了。這種思路就是不斷的詢問內核是否把數據准備好了,如果沒好就繼續詢問,好了就開始拷貝數據。這種方式拷貝數據階段也式阻塞的,但是畢竟是內存操作,不會很慢,但是關鍵問題在於前面的輪詢,這個過程也是非常消耗CPU的,並沒有達到多好的效果。

3.3 多路復用IO

select

    IO multiplexing就是我們比較熟悉的select/epoll,也被稱為事件驅動IO。其可以在單個process中處理多個網絡連接。有一個專門的任務負責輪詢所有的socket,只要有一個socket有數據達到,就會通知用戶進程。當用戶進程調用了select,整個進程就會被block,內核會監視所有select負責的socket,當任何一個socket中的數據准備好了,select就會返回,用戶調用read操作,數據就會從內核拷貝到用戶進程。

    該方法和阻塞式IO沒有太大不同,可能更差,這里有兩個系統調用,但是其優勢就在於能夠處理多個連接,所以說大量連接此種方式更加,少量連接可能多線程+阻塞式IO要更出色。這種方式用戶進程其實也是一直被block的,不過是被select阻塞而不是socketIO。因此select()與非阻塞IO類似。

    實際上這個模型也有很多問題。如果連接數真的過多,select會消耗大量時間去輪詢各個句柄。epoll方式雖然能更高效,但是epoll方式受制於操作系統,跨平台困難。另外該模型將事件探測和事件響應混在了一起,如果事件響應執行過長,整個響應會延遲。通常使用事件驅動庫解決該問題libevent等。

    Java的NIO就是基於select實現的。

3.4 信號驅動IO

sigio

    信號驅動IO會注冊一個信號處理程序給內核,內核處理好了就會觸發該信號通知程序執行,最終處理數據。該方式真正做到第一階段的非阻塞,但是其不適合TCP協議。因為無法判斷該信號的具體含義,該信號產生的非常頻繁,UDP傳輸采取的較多。

3.5 異步IO

asy

    異步IO可以稱得上全程無阻塞,直到數據全部准備完成才會通知用戶執行后續操作。但是在netty上並沒有采取該種方式,其一是在linux系統上運行速度並沒有比epoll方式更快,其二是與Netty精心設計的線程模型相悖。windows系統實現了IOCP方式的異步IO,其是proactor模型。

3.6 總結

all

    上訴5種IO模型,只有異步IO方式達到了完全的非阻塞,阻塞式IO則是完全阻塞。但是常用的還是復用IO的方式,設計的好足以媲美aio,而且aio在某些情況性能不如epoll方式,和其具體實現有關。select/epoll和aio造就了兩種設計模式,前者是reactor,后者就是proactor。

    順帶一提,epoll就是用於解決select在大量連接時遍歷連接效率低下的問題,其是基於事件驅動,fd的限制數也遠大於1024。epoll_wait只會返回准備就緒的fd,使用nmap內存映射技術避免了內存復制的開銷。epoll還有個知識點就是edge-triggered和level-triggered,邊緣觸發和水平觸發。水平觸發指的是當准備就緒的fd沒有被用戶進程處理,下一次查詢仍會返回,這是select poll的觸發方式。邊緣觸發指的是無論准備好的fd有沒有被處理,下一次都不再返回。理論上邊緣觸發性能更高,但是實現非常復雜,任何意外的丟失事件都會造成請求處理錯誤。epoll默認使用水平觸發。

4.后記

    本節粗略介紹一下IO的相關概念,以便更好理解NIO,下一章介紹JAVA的NIO實現,這里指的就是select poll方式,結合代碼,更易理解多路信號復用IO。select poll的真正含義。


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM