Go語言核心36講(Go語言實戰與應用二十)--學習筆記

MingsonZheng發表於2021-12-02

42 | bufio包中的資料型別 (上)

今天,我們來講另一個與 I/O 操作強相關的程式碼包bufio。bufio是“buffered I/O”的縮寫。顧名思義,這個程式碼包中的程式實體實現的 I/O 操作都內建了緩衝區。

bufio包中的資料型別主要有:

1、Reader;

2、Scanner;

3、Writer和ReadWriter。

與io包中的資料型別類似,這些型別的值也都需要在初始化的時候,包裝一個或多個簡單 I/O 介面型別的值。(這裡的簡單 I/O 介面型別指的就是io包中的那些簡單介面。)

下面,我們將通過一系列問題對bufio.Reader型別和bufio.Writer型別進行討論(以前者為主)。今天我的問題是:bufio.Reader型別值中的緩衝區起著怎樣的作用?

這道題的典型回答是這樣的。

bufio.Reader型別的值(以下簡稱Reader值)內的緩衝區,其實就是一個資料儲存中介,它介於底層讀取器與讀取方法及其呼叫方之間。所謂的底層讀取器,就是在初始化此類值的時候傳入的io.Reader型別的引數值。

Reader值的讀取方法一般都會先從其所屬值的緩衝區中讀取資料。同時,在必要的時候,它們還會預先從底層讀取器那裡讀出一部分資料,並暫存於緩衝區之中以備後用。

有這樣一個緩衝區的好處是,可以在大多數的時候降低讀取方法的執行時間。雖然,讀取方法有時還要負責填充緩衝區,但從總體來看,讀取方法的平均執行時間一般都會因此有大幅度的縮短。

問題解析

bufio.Reader型別並不是開箱即用的,因為它包含了一些需要顯式初始化的欄位。為了讓你能在後面更好地理解它的讀取方法的內部流程,我先在這裡簡要地解釋一下這些欄位,如下所示。

1、buf:[]byte型別的欄位,即位元組切片,代表緩衝區。雖然它是切片型別的,但是其長度卻會在初始化的時候指定,並在之後保持不變。

2、rd:io.Reader型別的欄位,代表底層讀取器。緩衝區中的資料就是從這裡拷貝來的。

3、r:int型別的欄位,代表對緩衝區進行下一次讀取時的開始索引。我們可以稱它為已讀計數。

4、w:int型別的欄位,代表對緩衝區進行下一次寫入時的開始索引。我們可以稱之為已寫計數。

5、err:error型別的欄位。它的值用於表示在從底層讀取器獲得資料時發生的錯誤。這裡的值在被讀取或忽略之後,該欄位會被置為nil。

6、lastByte:int型別的欄位,用於記錄緩衝區中最後一個被讀取的位元組。讀回退時會用到它的值。

7、lastRuneSize:int型別的欄位,用於記錄緩衝區中最後一個被讀取的 Unicode 字元所佔用的位元組數。讀回退的時候會用到它的值。這個欄位只會在其所屬值的ReadRune方法中才會被賦予有意義的值。在其他情況下,它都會被置為-1。

bufio包為我們提供了兩個用於初始化Reader值的函式,分別叫:

  • NewReader;
  • NewReaderSize;

它們都會返回一個*bufio.Reader型別的值。

NewReader函式初始化的Reader值會擁有一個預設尺寸的緩衝區。這個預設尺寸是 4096 個位元組,即:4 KB。而NewReaderSize函式則將緩衝區尺寸的決定權拋給了使用方。

由於這裡的緩衝區在一個Reader值的生命週期內其尺寸不可變,所以在有些時候是需要做一些權衡的。NewReaderSize函式就提供了這樣一個途徑。

在bufio.Reader型別擁有的讀取方法中,Peek方法和ReadSlice方法都會呼叫該型別一個名為fill的包級私有方法。fill方法的作用是填充內部緩衝區。我們在這裡就先重點說說它。

fill方法會先檢查其所屬值的已讀計數。如果這個計數不大於0,那麼有兩種可能。

一種可能是其緩衝區中的位元組都是全新的,也就是說它們都沒有被讀取過,另一種可能是緩衝區剛被壓縮過。

對緩衝區的壓縮包括兩個步驟。第一步,把緩衝區中在[已讀計數, 已寫計數)範圍之內的所有元素值(或者說位元組)都依次拷貝到緩衝區的頭部。

比如,把緩衝區中與已讀計數代表的索引對應位元組拷貝到索引0的位置,並把緊挨在它後邊的位元組拷貝到索引1的位置,以此類推。

這一步之所以不會有任何副作用,是因為它基於兩個事實。

第一事實,已讀計數之前的位元組都已經被讀取過,並且肯定不會再被讀取了,因此把它們覆蓋掉是安全的。

第二個事實,在壓縮緩衝區之後,已寫計數之後的位元組只可能是已被讀取過的位元組,或者是已被拷貝到緩衝區頭部的未讀位元組,又或者是代表未曾被填入資料的零值0x00。所以,後續的新位元組是可以被寫到這些位置上的。

在壓縮緩衝區的第二步中,fill方法會把已寫計數的新值設定為原已寫計數與原已讀計數的差。這個差所代表的索引,就是壓縮後第一次寫入位元組時的開始索引。

另外,該方法還會把已讀計數的值置為0。顯而易見,在壓縮之後,再讀取位元組就肯定要從緩衝區的頭部開始讀了。

image

(bufio.Reader 中的緩衝區壓縮)

實際上,fill方法只要在開始時發現其所屬值的已讀計數大於0,就會對緩衝區進行一次壓縮。之後,如果緩衝區中還有可寫的位置,那麼該方法就會對其進行填充。

在填充緩衝區的時候,fill方法會試圖從底層讀取器那裡,讀取足夠多的位元組,並儘量把從已寫計數代表的索引位置到緩衝區末尾之間的空間都填滿。

在這個過程中,fill方法會及時地更新已寫計數,以保證填充的正確性和順序性。另外,它還會判斷從底層讀取器讀取資料的時候,是否有錯誤發生。如果有,那麼它就會把錯誤值賦給其所屬值的err欄位,並終止填充流程。

好了,到這裡,我們暫告一個段落。在本題中,我對bufio.Reader型別的基本結構,以及相關的一些函式和方法進行了概括介紹,並且重點闡述了該型別的fill方法。

後者是我們在後面要說明的一些讀取流程的重要組成部分。你起碼要記住的是:這個fill方法大致都做了些什麼。

知識擴充套件

問題 1:bufio.Writer型別值中緩衝的資料什麼時候會被寫到它的底層寫入器?

我們先來看一下bufio.Writer型別都有哪些欄位:

1、err:error型別的欄位。它的值用於表示在向底層寫入器寫資料時發生的錯誤。

2、buf:[]byte型別的欄位,代表緩衝區。在初始化之後,它的長度會保持不變。

3、n:int型別的欄位,代表對緩衝區進行下一次寫入時的開始索引。我們可以稱之為已寫計數。

4、wr:io.Writer型別的欄位,代表底層寫入器。

bufio.Writer型別有一個名為Flush的方法,它的主要功能是把相應緩衝區中暫存的所有資料,都寫到底層寫入器中。資料一旦被寫進底層寫入器,該方法就會把它們從緩衝區中刪除掉。

不過,這裡的刪除有時候只是邏輯上的刪除而已。不論是否成功地寫入了所有的暫存資料,Flush方法都會妥當處置,並保證不會出現重寫和漏寫的情況。該型別的欄位n在此會起到很重要的作用。

bufio.Writer型別值(以下簡稱Writer值)擁有的所有資料寫入方法都會在必要的時候呼叫它的Flush方法。

比如,Write方法有時候會在把資料寫進緩衝區之後,呼叫Flush方法,以便為後續的新資料騰出空間。WriteString方法的行為與之類似。

又比如,WriteByte方法和WriteRune方法,都會在發現緩衝區中的可寫空間不足以容納新的位元組,或 Unicode 字元的時候,呼叫Flush方法。

此外,如果Write方法發現需要寫入的位元組太多,同時緩衝區已空,那麼它就會跨過緩衝區,並直接把這些資料寫到底層寫入器中。

而ReadFrom方法,則會在發現底層寫入器的型別是io.ReaderFrom介面的實現之後,直接呼叫其ReadFrom方法把引數值持有的資料寫進去。

總之,在通常情況下,只要緩衝區中的可寫空間無法容納需要寫入的新資料,Flush方法就一定會被呼叫。並且,bufio.Writer型別的一些方法有時候還會試圖走捷徑,跨過緩衝區而直接對接資料供需的雙方。

你可以在理解了這些內部機制之後,有的放矢地編寫你的程式碼。不過,在你把所有的資料都寫入Writer值之後,再呼叫一下它的Flush方法,顯然是最穩妥的。

package main

import (
	"bufio"
	"fmt"
	"strings"
)

func main() {
	comment := "Package bufio implements buffered I/O. " +
		"It wraps an io.Reader or io.Writer object, " +
		"creating another object (Reader or Writer) that " +
		"also implements the interface but provides buffering and " +
		"some help for textual I/O."
	basicReader := strings.NewReader(comment)
	fmt.Printf("The size of basic reader: %d\n", basicReader.Size())
	fmt.Println()

	// 示例1。
	fmt.Println("New a buffered reader ...")
	reader1 := bufio.NewReader(basicReader)
	fmt.Printf("The default size of buffered reader: %d\n", reader1.Size())
	// 此時reader1的緩衝區還沒有被填充。
	fmt.Printf("The number of unread bytes in the buffer: %d\n", reader1.Buffered())
	fmt.Println()

	// 示例2。
	bytes, err := reader1.Peek(7)
	if err != nil {
		fmt.Printf("error: %v\n", err)
	}
	fmt.Printf("Peeked contents(%d): %q\n", len(bytes), bytes)
	fmt.Printf("The number of unread bytes in the buffer: %d\n", reader1.Buffered())
	fmt.Println()

	// 示例3。
	buf1 := make([]byte, 7)
	n, err := reader1.Read(buf1)
	if err != nil {
		fmt.Printf("error: %v\n", err)
	}
	fmt.Printf("Read contents(%d): %q\n", n, buf1)
	fmt.Printf("The number of unread bytes in the buffer: %d\n", reader1.Buffered())
	fmt.Println()

	// 示例4。
	fmt.Printf("Reset the basic reader (size: %d) ...\n", len(comment))
	basicReader.Reset(comment)
	fmt.Printf("Reset the buffered reader (size: %d) ...\n", reader1.Size())
	reader1.Reset(basicReader)
	peekNum := len(comment) + 1
	fmt.Printf("Peek %d bytes ...\n", peekNum)
	bytes, err = reader1.Peek(peekNum)
	if err != nil {
		fmt.Printf("error: %v\n", err)
	}
	fmt.Printf("The number of peeked bytes: %d\n", len(bytes))
	fmt.Println()

	// 示例5。
	fmt.Printf("Reset the basic reader (size: %d) ...\n", len(comment))
	basicReader.Reset(comment)
	size := 300
	fmt.Printf("New a buffered reader with size %d ...\n", size)
	reader2 := bufio.NewReaderSize(basicReader, size)
	peekNum = size + 1
	fmt.Printf("Peek %d bytes ...\n", peekNum)
	bytes, err = reader2.Peek(peekNum)
	if err != nil {
		fmt.Printf("error: %v\n", err)
	}
	fmt.Printf("The number of peeked bytes: %d\n", len(bytes))
}

總結

今天我們從“bufio.Reader型別值中的緩衝區起著怎樣的作用”這道問題入手,介紹了一部分 bufio 包中的資料型別,在下一次的分享中,我會沿著這個問題繼續展開。

筆記原始碼

https://github.com/MingsonZheng/go-core-demo

知識共享許可協議

本作品採用知識共享署名-非商業性使用-相同方式共享 4.0 國際許可協議進行許可。

歡迎轉載、使用、重新發布,但務必保留文章署名 鄭子銘 (包含連結: http://www.cnblogs.com/MingsonZheng/ ),不得用於商業目的,基於本文修改後的作品務必以相同的許可釋出。

相關文章