计网 - 流和缓冲区:缓冲区的 flip 是怎么回事?
文章目錄
- Pre
- 流
- 為什么要緩沖區?
- 緩沖區
- 總結
Pre
流和緩沖區都是用來描述數據的。
計算機中,數據往往會被抽象成流,然后傳輸。比如讀取一個文件,數據會被抽象成文件流;播放一個視頻,視頻被抽象成視頻流。處理節點為了防止過載,又會使用緩沖區削峰(減少瞬間壓力)。在傳輸層協議當中,應用往往先把數據放入緩沖區,然后再將緩沖區提供給發送數據的程序。發送數據的程序,從緩沖區讀取出數據,然后進行發送。
流
流代表數據,具體來說是隨著時間產生的數據,類比自然界的河流。你不知道一個流什么時候會完結,直到你將流中的數據都讀完。
讀取文件的時候,文件被抽象成流。流的內部構造,決定了你每次能從文件中讀取多少數據。從流中讀取數據的操作,本質上是一種迭代器。流的內部構造決定了迭代器每次能讀出的數據規模。比如你可以設計一個讀文件的流,每次至少會讀出 4k 大小,也可以設計一個讀文件的程序,每次讀出一個字節大小。
通常情況讀取數據的流,是讀取流;寫入數據的流,是寫入流。那么一個寫入流還能被理解成隨著時間產生的數據嗎?其實是一樣的,隨著時間產生的數據,通過寫入流寫入某個文件,或者被其他線程、程序拿走使用。
思考一個問題:流中一定有數據嗎?
看上去的確是這樣。對于文件流來說,打開一個文件,形成讀取流。讀取流的本質當然是內存中的一個對象。當用戶讀取文件內容的時候,實際上是通過流進行讀取,看上去好像從流中讀取了數據,而本質上讀取的是文件的數據。從這個角度去觀察整體的設計,數據從文件到了流,然后再到了用戶線程,因此數據是經過流的。
但是仔細思考這個問題,可不可以將數據直接從文件傳輸到用戶線程呢?比如流對象中只設計一個整數型指針,一開始指向文件的頭部,每次發生讀取,都從文件中讀出內容,然后再返回給用戶線程。做完這次操作,指針自增。通過這樣的設計,流中就不需要再有數據了??梢?#xff0c;流中不一定要有數據。再舉一個極端的例子,如果我們設計一個隨機數的產生流,每次讀取流中的數據,都調用隨機數函數生成一個隨機數并返回,那么流中也不需要有數據的存儲。
為什么要緩沖區?
在上面的例子當中,我們討論的時候發現,設計文件流時,可以只保留一個位置指針,不用真的將整個文件都讀入內存,像下圖這樣:
把文件看作是一系列線性排列連續字節的合集,用戶線程調用流對象的讀取數據方法,每次從文件中讀取一個字節。流中只保留一個讀取位置 position,指向下一個要讀取的字節。
看上去這個方案可行,但實際上性能極差。因為從文件中讀取數據這個操作,是一次磁盤的 I/O 操作,非常耗時。正確的做法是每次讀取 2k、4k 這樣大小的數據,這是因為操作系統中的內存分頁通常是這樣的大小,而磁盤的讀寫往往是會適配頁表大小。而且現在的文件系統主要都是日志文件系統,存儲的并不是原始數據本身,也就是說多數情況下你看到的文件并不是一個連續緊密的字節線性排列,而是日志。
當你向磁盤讀取 2k 數據,讀取到的不一定是 2k 實際的數據,很有可能會比 2k 少,這是因為文件內容是以日志形式存儲,會有冗余
如上圖所示,內核每次從文件系統中讀取到的數據是確定的,但是里邊的有效數據是不確定的。
流對象的設計,至少應該支持兩種操作:一種是讀取一個字節,另一種是讀取多個字節。而無論讀取一個字節還是讀取多個字節,都應該適配內核的底層行為。也就是說,每次流對象讀取一個字節,內核可能會讀取 2k、4k 的數據。這樣的行為,才能真的做到減少磁盤的 I/O 操作。
那內核為什么不一次先讀取幾兆數據或者讀取更大的數據呢?這有兩個原因。
如果是高并發場景下,并發讀取數據時內存使用是根據并發數翻倍的,如果同時讀取的數據量過大,可能會導致內存不足。
讀取比 2k/4k……大很多倍的數據,比如 1M/2M 這種遠遠大于內存分頁大小的數據,并不能提升性能。
所以最后我們的解決辦就是創建兩個緩沖區 。
上圖中內核中的緩沖區,用于緩沖讀取文件中的數據。流中的緩沖區,用于緩沖內核中拷貝過來的數據。
為什么不把內核的緩沖區直接給到流呢?這是因為流對象工作在用戶空間,內核中的緩沖區工作在內核空間。用戶空間的程序不可以直接訪問內核空間的數據,這是操作系統的一種保護策略。
當然也存在一種叫作內存映射的方式,就是內核通過內存映射,直接將內核空間中的一塊內存區域分享給用戶空間只讀使用,這樣的方式可以節省一次數據拷貝。這個能力在 Java 的 NIO 中稱作 DirectMemory,對應 C 語言是 mmap。
緩沖區
上面的設計中,我們已經開始用緩沖區解決問題了。那么具體什么是緩沖區呢?緩沖區就是一塊用來做緩沖的內存區域。在上面的例子當中,為了應對頻繁的字節讀取,我們在內存當中設置一個 2k 大小緩沖區。這樣讀取 2048 次,才會真的發生一次讀取。同理,如果應對頻繁的字節寫入,也可以使用緩沖區。
不僅僅如此,比如說你設計一個秒殺系統,如果同時到達的流量過高,也可以使用緩沖區將用戶請求先存儲下來,再進行處理。這個操作我們稱為削峰,削去流量的峰值。
緩沖區中的數據通常具有樸素的公平,說白了就是排隊,先進先出(FIFO)。從數據結構的設計上,緩沖區像一個隊列。在實際的使用場景中,緩沖區有一些自己特別的需求,比如說緩沖區需要被重復利用。多次讀取數據,可以復用一個緩沖區,這樣可以節省內存,也可以減少分配和回收內存的開銷。
舉個例子:讀取一個流的數據到一個緩沖區,然后再將緩沖區中的數據交給另一個流。 比如說讀取文件流中的數據交給網絡流發送出去。首先,我們要將文件流的數據寫入緩沖區,然后網絡流會讀取緩沖區中的數據。這個過程會反反復復進行,直到文件內容全部發送。
這個設計中,緩沖區需要支持這幾種操作:
-
寫入數據
-
讀出數據
-
清空(應對下一次讀寫)
那么具體怎么設計這個緩沖區呢?首先,數據可以考慮存放到一個數組中,下圖是可以存 8 個字節的緩沖區:
寫入數據的時候,需要一個指針指向下一個可以寫入的位置,如下圖所示:
每次寫入數據,position 增 1,比如我們順序寫入 a,b,c,d 后,緩沖區如下圖所示:
那么如果這個時候,要切換到讀取狀態該怎么做呢?再增加一個讀取指針嗎?聰明的設計者想到了一個辦法,增加一個 limit 指針,隨著寫入指針一起增長,如下圖所示:
當需要切換到讀取狀態的時候,將 position 設置為 0,limit 不變即可。下圖中,我們可以從 0 開始讀取數據,每次讀取 position 增 1。
我們將 position 設置為 0,limit 不變的操作稱為flip操作,flip 本意是翻轉,在這個場景中是讀、寫狀態的切換。
讀取操作可以控制循環從 position 一直讀取到 limit,這樣就可以讀取出 a,b,c,d。那么如果要繼續寫入應該如何操作呢? 這個時候就需要用到緩沖區的clear操作,這個操作會清空緩沖區。具體來說,clear操作會把 position,limit 都設置為 0,而不需要真的一點點擦除緩沖區中已有的值,就可以做到重復利用緩沖區了。
寫入過程從 position = 0 開始,position 和 limit 一起自增。讀取時,用flip操作切換緩沖區讀寫狀態。讀取數據完畢,用clear操作重置緩沖區狀態。
總結
總結一下,流是隨著時間產生的數據。數據抽象成流,是因為客觀世界存在著這樣的現象。數據被抽象成流之后,我們不需要把所有的數據都讀取到內存當中進行計算和迭代,而是每次處理或者計算一個緩沖區的數據。
緩沖區的作用是緩沖,它在高頻的 I/O 操作中很有意義。針對不同場景,也不只有這一種緩沖區的設計,比如用雙向鏈表實現隊列(FIFO 結構)可以作為緩沖區;Redis 中的列表可以作為緩沖區;RocketMQ,Kafka 等也可以作為緩沖區。針對某些特定場景,比如高并發場景下的下單處理,可能會用訂單隊列表(MySQL 的表)作為緩沖區。
因此從這個角度來說,作為開發者我們首先要有緩沖的意識,去減少 I/O 的次數,提升 I/O 的性能,然后才是思考具體的緩沖策略。
總結
以上是生活随笔為你收集整理的计网 - 流和缓冲区:缓冲区的 flip 是怎么回事?的全部內容,希望文章能夠幫你解決所遇到的問題。
- 上一篇: 计网 - TCP 实战:如何进行 TCP
- 下一篇: 计网 - 网络 I/O 模型:BIO、N